Org Skills

Jest and RNTL setup for Expo

Supporting material for react-native-testing. Agents load it on demand; it ships inside the skill folder.

RawSource

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