Skip to content

React Native Testing

Language: JavaScript

How to test React Native and Expo modules in the Superloom framework. This page covers the testing philosophy, injection patterns for framework modules, component testing with react-test-renderer, integration testing with Metro, and CI placement.

On This Page


Testing Philosophy

Helper modules test in pure Node with no Metro, no emulator, and no browser. The framework or engine is injected through shared_libs in the test loader, exactly as server modules inject adapters and cloud SDKs.

The reason is speed and isolation. A module that requires Metro to test cannot run in CI without the full Expo toolchain. A module that receives its platform dependencies through injection runs in milliseconds in pure Node, and the same test loader works in any CI environment.

App-level tests (screens, layouts, navigation) are the application project's responsibility, not the module's. Modules ship unit tests for their public API. Integration and E2E tests live in the consuming application's test suite.


Module Testing Tiers for Framework Modules

Framework modules and client-side driver wrappers test in pure Node. The framework or engine enters through shared_libs, not through a direct import.

Module tierInjected asStub strategy
js-react-helper-* (Class I)shared_libs.ReactReal react and react-test-renderer from node_modules. No stub needed
js-rnw-helper-* (Class I)shared_libs.React, capability slotsReal react and react-test-renderer. Platform APIs are stub objects with the surface the module calls
js-client-helper-*-ext-react (Class H)shared_libs.React, shared_libs.[Parent]Real react and react-test-renderer. Parent module loaded from registry or file:../
js-client-helper-*-ext-web (Class H)shared_libs.[Parent], DOM stubsParent from file:../. DOM APIs stubbed (document, FontFace)
js-client-helper-*-ext-rn (Class H)shared_libs.[Parent], native loader stubParent from file:../. Native loader is a stub with the engine's interface
js-client-helper-*-ext-expo (Class H)shared_libs.[Parent], Expo API stubParent from file:../. Expo API is a stub with the surface the module calls
js-rn-helper-* (Class C)shared_libs.[Engine]Engine stub in _test/ implementing the native interface
js-rnw-helper-* (Class C)shared_libs.[Engine]Engine stub in _test/ implementing the Expo SDK interface

Test Loader Shape for Expo-Bound Modules

The test loader builds the shared_libs container with the framework entry and capability stubs, then calls the module loader.

Class I: Standalone React Module

javascript
// _test/loader.js

import React from 'react';
import ReactTestRenderer from 'react-test-renderer';
import helperUtils from 'helper-utils';
import helperDebug from 'helper-debug';
import helperIdle from 'helper-idle';

const Utils = helperUtils();
const Debug = helperDebug();

const Idle = helperIdle({
  React,
  Utils,
  Debug
});

export default { React, ReactTestRenderer, Idle, Utils, Debug };

Real react and react-test-renderer run in Node. No Metro, no browser. The module's hooks render inside a test component and the test asserts on the rendered output.

Class H: Expo Extension with Capability Stub

javascript
// _test/loader.js

import React from 'react';
import ReactTestRenderer from 'react-test-renderer';
import helperUtils from 'helper-utils';
import helperDebug from 'helper-debug';
import helperFont from 'helper-font';
import helperFontExtExpo from 'helper-font-ext-expo';

const Utils = helperUtils();
const Debug = helperDebug();

// Stub the Expo font API surface
const FontLoader = {
  loadAsync: async function () { return; },
  isLoaded: () => true
};

// Load the pure parent from file reference
const Font = helperFont({
  Utils,
  Debug
});

// Load the Expo extension, injecting the parent and the capability stub
const ExpoExtension = helperFontExtExpo({
  React,
  Utils,
  Debug,
  Font,
  FontLoader
});

export default { React, ReactTestRenderer, Font, ExpoExtension, Utils, Debug };

The slot is named for the capability (FontLoader), not the vendor (ExpoFont). The same module runs against an Expo-backed loader, a bare RN loader, or this stub with no source edit. See Expo Guide for the capability injection pattern.

Class C: Engine Stub for Native Module Wrapper

javascript
// _test/loader.js

import helperUtils from 'helper-utils';
import helperDebug from 'helper-debug';
import helperKvMmkv from 'helper-kv-mmkv';

const Utils = helperUtils();
const Debug = helperDebug();

// Engine stub implementing the native module's interface
const MMKVStub = {
  getString: function (key) { return this._store[key] || null; },
  set: function (key, value) { this._store[key] = value; },
  delete: function (key) { delete this._store[key]; },
  _store: {}
};

const KV = helperKvMmkv({
  Utils,
  Debug,
  MMKV: MMKVStub
});

export default { KV, Utils, Debug, MMKVStub };

The engine stub lives in _test/ and implements the native module's JavaScript interface. It never imports the real native module. See Unit Test Authoring for the engine stub pattern.


Component Testing with react-test-renderer

A Class I module that ships hooks (for example, a useIdle or useTimer hook) tests the hook's logic by calling it inside a test component rendered with react-test-renderer. The test asserts on the rendered output or on side effects.

javascript
// _test/test.js

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

import React from 'react';
import ReactTestRenderer from 'react-test-renderer';

import lib from './loader.js';
const { Idle } = lib;

describe('useIdle hook', function () {

  it('should render active state initially', function () {

    function TestComponent () {

      const { isActive } = Idle.useIdle({ timeout_ms: 5000 });
      return React.createElement('Text', null, isActive ? 'active' : 'idle');

    }

    const renderer = ReactTestRenderer.create(
      React.createElement(TestComponent)
    );

    const json = renderer.toJSON();
    assert.strictEqual(json.children[0], 'active');

  });

});

No DOM, no browser, no Metro. react-test-renderer produces a JSON tree that the test inspects. This is the same pattern used by React extension modules that ship useTheme or useStyles hooks.

Testing State Transitions

For hooks that respond to time or events, the test advances mock time or triggers events and re-renders:

javascript
it('should transition to idle after timeout', function () {

  const clock = { now: 0 };
  const events = [];

  function TestComponent () {

    const { isActive } = Idle.useIdle({
      timeout_ms: 5000,
      clock: clock,
      eventSources: events
    });

    return React.createElement('Text', null, isActive ? 'active' : 'idle');

  }

  const renderer = ReactTestRenderer.create(
    React.createElement(TestComponent)
  );

  // Advance mock time past the timeout
  clock.now = 6000;
  renderer.update(React.createElement(TestComponent));

  const json = renderer.toJSON();
  assert.strictEqual(json.children[0], 'idle');

});

The hook receives clock and eventSources through injection, so the test controls time and events without real timers or platform APIs.


Integration Testing with Metro and Expo

Integration testing with Metro and Expo is an application-level concern, not a module-level concern. Modules do not ship integration tests that require Metro.

When Integration Testing Is Needed

ScenarioWhere it livesWhat it tests
Module loads in Metro without bundler errorsApplication projectImport resolution, platform-file selection
Font loading renders correctly on nativeApplication projectExpo font registration, theme token resolution
Component library renders on webApplication projectReact Native Web DOM mapping
Navigation works across platformsApplication projectExpo Router deep links, typed routes

React Native Web Alias Tier

Aliasing react-native to react-native-web in a test package lets shared components render under node --test with no Expo in the module graph. The alias maps the react-native import to react-native-web at resolution time, so components that call View, Text, and StyleSheet render through React DOM without Metro, without a browser, and without any expo-* package installed.

This makes the alias tier a portability check, not only a unit-test convenience. If a shared component imports expo-router or any other expo* package, the test fails with MODULE_NOT_FOUND because the test package has no Expo dependency. The failure is the signal: shared source has acquired an app-framework coupling that the portability fence forbids.

Manual Verification

For manual verification during development:

bash
npx expo start              # Start Metro
# Scan QR for Expo Go, or press i for iOS, a for Android, w for web

This verifies that the module loads and renders in the full Expo runtime. It is not automated and does not run in module CI.


CI Placement

Framework modules are offline modules. They need no Docker, no AWS credentials, and no dedicated CI job.

Module typeCI placementDockerAWS credentials
js-react-helper-* (Class I)test-offline matrixNoNo
js-rnw-helper-* (Class I)test-offline matrixNoNo
js-client-helper-*-ext-* (Class H)test-offline matrixNoNo
js-rn-helper-* (Class C)test-offline matrixNoNo

Add the module path to the test-offline matrix in the CI workflow. The publish job auto-detects it from the detect job output. See Module Testing for the full CI placement guide.


End-to-End Tests

End-to-end (E2E) tests verify the full application flow on a device or simulator. They use tools like Detox or Maestro and run against a built app binary.

E2E tests are an application concern. Modules do not ship E2E tests. The contract a module must satisfy is testable in pure Node through injection. The application project's E2E suite verifies that modules work together in the full runtime.

E2E toolScopeWhere it lives
DetoxNative app E2E on iOS and Android simulatorsApplication project
MaestroFlow-based UI testing on simulators and devicesApplication project
PlaywrightWeb E2E in a real browserApplication project

List windowing under React Native Web

FlatList and VirtualizedList under React Native Web must have a bounded height to make windowing possible. An unbounded list expands to fit its content and mounts the full roster, so a row-count assertion that passes locally does not prove virtualization.

A test that claims virtualization asserts an exact or fixed upper bound on the mounted-row count that is below the full roster, and reaches a late row by scrolling the real scroll node. A local-versus-CI row-count difference is evidence of a layout or viewport precondition, not permission to relax the threshold: loosening a threshold until it passes converts a real signal into a green check.

Application UI Acceptance

Application-level E2E tests verify the full runtime, but presence and interaction alone do not prove the rendered UI is correct. A page can load, display expected text, and handle interactions while its typography is broken, its layout overflows, or its visual output has regressed. Application UI acceptance uses four complementary gates, each proving something the others cannot.

The four-gate acceptance model

GateWhat it provesWhat it does not prove
ContractExact generated token values and provenance at the template-package levelBrowser layout or visual output
Functional readinessResponse 200, root mount, no console/page/request errors, primary interaction worksLayout geometry or visual correctness
Geometric layoutExact typography, spacing, overflow, overlap, and reflow at fixed viewportsVisual pixel equality
Visual regressionCommitted toHaveScreenshot baselines match the current renderSemantic correctness or why a change occurred

Unit tests do not claim browser layout coverage. Screenshot tests do not replace semantic or geometric assertions. A global font-size ceiling does not replace a semantic expectation.

External standards

  • Playwright visual comparisons: expect(page).toHaveScreenshot(), stable state first, same browser/OS environment, animations disabled.
  • WCAG 2.2 SC 1.4.10: no two-dimensional scrolling at 320 CSS px for vertical pages.
  • WCAG 2.2 SC 1.4.12: no clipping, overlap, or loss when line height, paragraph spacing, letter spacing, and word spacing are overridden to the criterion values.
  • Testing Library principle: functional locators prefer role/label/text; data-testid is reserved for geometry or otherwise inaccessible structure.
  • Material 3 official type scale is the authority for Material role values.

Viewports and deterministic geometry

Run page acceptance at fixed viewports (e.g., 320x568, 375x667, 768x1024, 1280x720). At every viewport:

  • document.documentElement.scrollWidth === document.documentElement.clientWidth (no horizontal overflow).
  • No visible element extends left of -1 or right of viewport width + 1.
  • No pair of designated page sections overlaps.
  • Root contains nonzero visible content and a route-ready sentinel is visible.
  • Vertical scrolling is allowed; a fixed page-height ceiling is forbidden.

At the narrowest viewport, repeat overflow, visibility, and overlap checks after applying WCAG text-spacing overrides (line-height 1.5, paragraph margin-bottom 2em, letter-spacing 0.12em, word-spacing 0.16em).

Exact typography and spacing

Assert computed pixels exactly in Chromium for deterministic values. Do not use ranges for values that are deterministic in a fixed browser environment. A global ceiling catches only extreme blowups and lets any wrong-but-smaller value through.

Dev-server freshness

Production Playwright must always use a fresh server (reuseExistingServer: false in CI and npm run verify). A build identity (git SHA + lockfile hash) served at a known endpoint lets a freshness check compare the served identity to the identity computed before launch. A dev:fresh command checks the configured port, fails loudly with PID and remedy on a mismatch, and never silently increments the port. Killing a stale process remains an explicit human action.

Visual baseline environment

A dedicated serial Playwright project runs visual regression in a pinned browser environment. Animations are disabled, color scheme is fixed, locale and timezone are fixed, and device scale factor is 1. Only stable page regions are captured after fonts and data are ready. Platform-specific snapshots use Playwright's standard naming; the CI-authoritative platform (Linux Chromium) is the only one committed. Use maxDiffPixels: 0 after eliminating external font and network nondeterminism. If a proven platform antialiasing difference remains, use the smallest measured maxDiffPixels and document the reason; never use a percentage threshold.

Script lifecycle

Permanent developer/CI commands with assertions go in committed scripts/. Product tests go in committed e2e/. Reusable cross-session audit tooling goes in the workspace __dev__/audits/. Disposable probes and output go in __dev__/investigations/<date>-<topic>/, never discovered by product test runners.


Further Reading

Released under the MIT License.