The full configuration behind SKILL.md §2, plus the mocks every Expo suite needs. Open this when setting up a project or when a test fails at import time.
Sources: https://docs.expo.dev/develop/unit-testing/ · http://oss.callstack.com/react-native-testing-library/docs/start/quick-start · https://docs.swmansion.com/react-native-reanimated/docs/guides/testing/ · https://mswjs.io/docs/integrations/react-native
1. Install
npx expo install jest-expo jest @types/jest --dev
npx expo install @testing-library/react-native --dev
npm i -D msw react-native-url-polyfill fast-text-encoding
jest-expo is what makes the suite work: it transforms React Native and Expo packages, provides the __DEV__ global, and knows the platform file extensions. A plain preset: 'react-native' in an Expo app fails on the first expo-* import.
2. Jest configuration
// jest.config.js
module.exports = {
preset: 'jest-expo',
resolver: 'react-native-reanimated/jest/resolver',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
collectCoverageFrom: ['src/features/**/*.{ts,tsx}', 'src/shared/**/*.{ts,tsx}'],
coverageThreshold: { global: { statements: 60 } },
};
The setup file belongs in setupFilesAfterEnv on Jest 28+; older Jest used setupFiles.
Per-platform runs, when you have .ios.tsx / .android.tsx forks:
{ "scripts": { "test:ios": "jest --config jest.config.js --testEnvironmentOptions '{\"platform\":\"ios\"}'" } }
jest-expo also ships platform presets (jest-expo/ios, jest-expo/android) that can be combined with Jest projects to run the suite twice in one command.
3. jest.setup.ts
import 'react-native-url-polyfill/auto';
import 'fast-text-encoding';
import { server } from './src/test/msw/server';
require('react-native-reanimated').setUpTests();
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => { server.resetHandlers(); jest.clearAllMocks(); });
afterAll(() => server.close());
onUnhandledRequest: 'error' is the line that pays for itself: an un-stubbed request fails loudly instead of leaving a test hanging until the timeout.
4. MSW for React Native
// src/test/msw/server.ts
import { setupServer } from 'msw/native'; // never 'msw/node' — it needs Node's http
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/test/msw/handlers/orders.ts
import { http, HttpResponse } from 'msw';
export const orderHandlers = [
http.get('*/orders', () => HttpResponse.json({ items: [{ id: '1', number: 'Order 1001' }] })),
http.post('*/orders', () => HttpResponse.json({ id: '42' }, { status: 201 })),
];
Use * prefixes so handlers match whatever base URL the API client builds. Per-test overrides go through server.use(...), including the failure cases:
server.use(http.get('*/orders', () => HttpResponse.error())); // network failure
server.use(http.get('*/orders', () => new HttpResponse(null, { status: 500 })));
5. Provider wrapper
// src/test/render.tsx
import type { ReactElement, ReactNode } from 'react';
import { render, type RenderOptions } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ThemeProvider } from '@/shared/theme';
const insets = { top: 47, bottom: 34, left: 0, right: 0 };
const frame = { x: 0, y: 0, width: 390, height: 844 };
export function renderWithProviders(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
});
function Wrapper({ children }: { children: ReactNode }) {
return (
<SafeAreaProvider initialMetrics={{ insets, frame }}>
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
</SafeAreaProvider>
);
}
return { queryClient, ...render(ui, { wrapper: Wrapper, ...options }) };
}
initialMetrics on SafeAreaProvider is required: without it useSafeAreaInsets() never resolves in tests and every screen using insets renders nothing. retry: false keeps error-path tests from waiting out three attempts.
6. Native module mocks
Register them once in the setup file rather than in every test.
// src/test/mocks/expo.ts
jest.mock('expo-secure-store', () => {
const store = new Map<string, string>();
return {
setItemAsync: jest.fn(async (k: string, v: string) => { store.set(k, v); }),
getItemAsync: jest.fn(async (k: string) => store.get(k) ?? null),
deleteItemAsync: jest.fn(async (k: string) => { store.delete(k); }),
};
});
jest.mock('expo-haptics', () => ({ impactAsync: jest.fn(), notificationAsync: jest.fn() }));
jest.mock('react-native-mmkv', () => {
const map = new Map<string, string>();
return {
MMKV: class {
getString(k: string) { return map.get(k); }
set(k: string, v: string) { map.set(k, String(v)); }
delete(k: string) { map.delete(k); }
},
};
});
A fake with real behaviour (the Map-backed store above) beats jest.fn() returning undefined: it lets you assert "the token was saved and read back" instead of "a function was called".
Permission hooks are mocked per test, because the three states are the point:
jest.mock('expo-camera', () => ({
...jest.requireActual('expo-camera'),
useCameraPermissions: jest.fn(),
}));
import { useCameraPermissions } from 'expo-camera';
(useCameraPermissions as jest.Mock).mockReturnValue([
{ granted: false, canAskAgain: false, status: 'denied' },
jest.fn(),
]);
7. Timers
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
user.press() takes at least ~130 ms of simulated time because it replays the real touch sequence, so a suite of twenty interactions is noticeably faster on fake timers. Without advanceTimers, interactions never resolve and the test times out.
8. Errors you will hit once
| Error | Cause | Fix |
|---|---|---|
Unable to resolve module http |
setupServer imported from msw/node |
Import from msw/native |
ReferenceError: __DEV__ is not defined |
Not using the jest-expo preset |
Set preset: 'jest-expo' |
| Screen renders nothing, no error | useSafeAreaInsets() unresolved |
SafeAreaProvider with initialMetrics in the wrapper |
act(...) warning after the test ends |
A pending query or timer | await findBy*, retry: false, and flush timers before asserting |
| Reanimated components throw on render | Missing resolver / setUpTests() |
Add both to the Jest config and setup file |
| Test passes alone, fails in the suite | Module mock state kept between tests | jest.clearAllMocks() and server.resetHandlers() in afterEach |