# Jest and RNTL setup for Expo

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

```bash
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

```js
// 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:

```json
{ "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`

```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

```ts
// 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);
```

```ts
// 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:

```ts
server.use(http.get('*/orders', () => HttpResponse.error()));        // network failure
server.use(http.get('*/orders', () => new HttpResponse(null, { status: 500 })));
```

## 5. Provider wrapper

```tsx
// 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.

```ts
// 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:

```tsx
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

```ts
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` |
