Two questions decide a testing strategy: what is worth testing, and what can you reach. In React Native the second is unusual — a lot of what you want to exercise is native code that does not exist in Node.
The setup
jest-expo is the preset. It configures the transform, the module mapping and the
environment, and it handles the fact that React Native ships untranspiled ES modules — which is what
transformIgnorePatterns in the Jest config is for.
React Native Testing Library renders components and queries them the way a user would find them.
Start with the pure parts
it('bumps the quantity of a matching line instead of adding a second one', () => {
const state: CartState = { items: [line({ quantity: 2 })], orderType: 'DELIVERY' };
const next = cartReducer(state, {
type: 'ADD_ITEM',
payload: line({ lineId: 'line-2', quantity: 3 }),
});
expect(next.items).toHaveLength(1);
expect(next.items[0]?.quantity).toBe(5);
expect(next.items[0]?.lineId).toBe('line-1');
});No rendering, no mocks, no async — a function, an input and an output. The cart reducer and the money arithmetic are the highest-value tests in the demo app precisely because they are the rules that would cost real money to get wrong, and they run in milliseconds.
This is the argument for keeping business logic out of components. Anything you have to render to test is harder to test.
Mocking the native modules
The keychain, the crypto source and Stripe's SDK do not exist in Node. Mock them once, centrally:
jest.mock('expo-secure-store', () => {
const store = new Map<string, string>();
return {
getItemAsync: jest.fn(async (key: string) => store.get(key) ?? null),
setItemAsync: jest.fn(async (key: string, value: string) => {
store.set(key, value);
}),
deleteItemAsync: jest.fn(async (key: string) => {
store.delete(key);
}),
__store: store,
};
});A Map is enough to prove that a token written is a token read back. Doing it in
jest.setup.ts means no individual test has to know, and the mock is shared behaviour
rather than twenty copies.
Mock the id generator as a counter, not a random value: a test asserting "these two cart lines are different" needs ids that differ, and a snapshot needs them reproducible. A counter gives both.
⚠️ jest.mock factories are hoisted above the imports. They cannot
close over an ordinary const — Jest refuses to compile it. Either prefix the variable
with mock, which is Jest's explicit escape hatch, or require inside the
factory.
Rendering components
it('renders its title and fires onPress', async () => {
const onPress = jest.fn();
await render(<Button title="Add to cart" onPress={onPress} />);
await fireEvent.press(screen.getByText('Add to cart'));
expect(onPress).toHaveBeenCalledTimes(1);
});⚠️ Note the awaits. React Native Testing Library 14 made render and
fireEvent asynchronous, because React 19 can render concurrently and the tree is not
guaranteed committed when the call returns. Forgetting one produces
"render function has not been called" from screen — which sounds like
exactly the opposite problem, and costs you a while.
Query the way a user finds things
Prefer getByText and getByLabelText over testID. Querying
by accessibility label means your tests exercise the same information a screen reader gets, so a
missing label breaks a test rather than only a person. Keep testID for things with no
accessible name — a container, a specific row in a list of identical rows.
Testing a provider
The highest-value component test in the demo app renders the cart provider with a tiny harness and asserts the behaviour around the reducer: that it never writes an empty cart over a saved one, that three quick taps are one network write rather than three, that a saved cart the server has forgotten is dropped rather than retried forever.
Two traps found while writing it, both worth stealing:
Fake timers deadlock. Faking timers also fakes the ones AsyncStorage's mock and React's scheduler use, and the test then waits forever for a promise only a real tick resolves. Half a second of real waiting is the cheaper trade.
A careless mock can hang the suite. A mocked useMenu returning a
fresh object each call gives an effect a new dependency every render — so it re-runs, dispatches,
re-renders, forever. The real provider memoises; the mock has to as well.
What unit tests cannot reach
Navigation across several screens, the real keychain, AppState, the payment sheet.
For those you need the app running.
Maestro and Detox drive a real build on a simulator or device. Maestro is YAML and far easier to start with; Detox is faster and more precise once a suite gets large.
The demo app takes a third route: it drives the web target with Playwright.
Expo builds the same components through react-native-web, and testID becomes
data-testid, so 31 tests exercise browse, cart, checkout, auth and profile against the
real backend. It is cheap and it runs in CI without a simulator.
Be honest about what that cannot cover: the payment sheet has no web build, Alert is
a no-op there, and accessibilityState is never mapped to aria-*. Those
gaps are the reason the web suite is a complement to a device run, not a replacement for one.
What to actually aim for
Cover the logic thoroughly — reducers, pricing, validation, the API client — because it is cheap and it is where the expensive bugs live. Cover the components that encode a rule. Cover the flows end to end. Do not chase a coverage number over screens whose only job is layout; a snapshot test of a view tree mostly asserts that nobody changed the view tree.
What is next
Internals: Hermes, JSI and the New Architecture — how the thing actually works.