Mocking Native Modules and the Network

Replace Expo modules and network calls with controlled fakes, so a test's result depends only on the code it tests.

Syntaxjest.mock('expo-location'); jest.mocked(Location.getCurrentPositionAsync).mockResolvedValue(fix);

A mock is a stand-in you control. Tests need them for two things: native modules, which have no implementation under Node, and the network, which is slow, shared and occasionally down.

Native modules

jest-expo already mocks the native side of the Expo SDK, but those mocks mostly return undefined. When a screen reads the result of a call, you have to supply one. jest.mock('expo-location') turns every export into a jest.fn(), and jest.mocked(Location) types them, so mockResolvedValue is checked against the module's real return type.

Mock the module, not your own wrapper around it. If you mock lib/geo.ts, the code you actually wrote is the part that never runs.

The network

A test that hits the real API is not a test. It fails on a train, it fails on a CI runner without secrets, and it fails when someone edits the data. It also cannot produce a 500 on demand, which is the case you most need to cover.

Two approaches. msw 3 intercepts at the request level: handlers come from msw/http, the server from msw/node, and your code still calls fetch for real, so the URL and the parsing are exercised. Or stub fetch with jest.spyOn(global, 'fetch') — fewer moving parts, but it checks your request less honestly.

Example

Example · tsx
// __tests__/NearbyScreen.test.tsx
import * as Location from 'expo-location';
import { render, screen } from '@testing-library/react-native';
import NearbyScreen from '../components/NearbyScreen';

// Hoisted above the imports: every export becomes a jest.fn() that
// resolves to undefined until a test says otherwise.
jest.mock('expo-location');

const mockedLocation = jest.mocked(Location);

beforeEach(() => {
  jest.clearAllMocks();

  // The cast keeps the fixture short; the fields the screen reads are real.
  mockedLocation.requestForegroundPermissionsAsync.mockResolvedValue({
    status: 'granted',
    granted: true,
  } as Location.LocationPermissionResponse);

  mockedLocation.getCurrentPositionAsync.mockResolvedValue({
    coords: {
      latitude: 51.5007,
      longitude: -0.1246,
      accuracy: 5,
      altitude: null,
      altitudeAccuracy: null,
      heading: null,
      speed: null,
    },
    timestamp: 1767225600000, // fixed, so the test never depends on now
  });
});

it('shows the distance once the fix arrives', async () => {
  await render(<NearbyScreen />);

  expect(await screen.findByText('Westminster · 0.4 km')).toBeOnTheScreen();
  expect(mockedLocation.getCurrentPositionAsync).toHaveBeenCalledTimes(1);
});

it('offers settings when permission is refused', async () => {
  mockedLocation.requestForegroundPermissionsAsync.mockResolvedValue({
    status: 'denied',
    granted: false,
  } as Location.LocationPermissionResponse);

  await render(<NearbyScreen />);

  expect(await screen.findByText('Open settings')).toBeOnTheScreen();
  expect(mockedLocation.getCurrentPositionAsync).not.toHaveBeenCalled();
});

When to use it

  • Test a permissions screen for both the granted and the denied answer, without touching device settings.
  • Drive a list through loading, empty, error and loaded states on demand.
  • Assert that a sign-in flow writes the token to secure storage and reads it back on launch.
  • Keep the suite green on a CI runner that has no network access at all.

More examples

One msw server for the whole suite

Set this up once and every test in the project gets a fake API without knowing it exists. Because msw intercepts the request rather than replacing fetch, a typo in your URL or a wrong HTTP method fails the test, which a hand-written fetch stub happily accepts. Note that setupServer comes from msw/node while http and HttpResponse come from msw/http; mixing those imports up is the most common setup error.

Example · ts
// jest.setup.ts — listed in the Jest config under setupFilesAfterEnv
import { http, HttpResponse } from 'msw/http';
import { setupServer } from 'msw/node';

export const server = setupServer(
  http.get('https://api.example.com/trips', () =>
    HttpResponse.json([{ id: 't_1', title: 'Lisbon' }]),
  ),
  http.post('https://api.example.com/trips', async ({ request }) => {
    const body = (await request.json()) as { title: string };
    return HttpResponse.json({ id: 't_2', title: body.title }, { status: 201 });
  }),
);

beforeAll(() => {
  // In msw 3 this option is onUnhandledFrame; msw 2 called it
  // onUnhandledRequest. 'error' fails the test instead of warning.
  server.listen({ onUnhandledFrame: 'error' });
});

afterEach(() => server.resetHandlers()); // drop per-test overrides
afterAll(() => server.close());

Overriding one endpoint to test the failure path

This is the case the real API cannot give you on demand, and the case users hit on a flaky connection. The same trick covers a slow response (return after a delay) and an empty list, which is the state most often left unstyled. Keep the happy-path handler in the shared setup file and override only the endpoint the test is about.

Example · ts
// __tests__/TripList.error.test.ts
import { http, HttpResponse } from 'msw/http';
import { render, screen, userEvent } from '@testing-library/react-native';
import { server } from '../jest.setup';
import TripList from '../components/TripList';

it('offers a retry after a server error', async () => {
  // server.use adds a handler for this test only; resetHandlers removes it.
  server.use(
    http.get('https://api.example.com/trips', () =>
      new HttpResponse(null, { status: 500 }),
    ),
  );

  await render(<TripList />);

  expect(await screen.findByText('Could not load trips')).toBeOnTheScreen();

  // Put the working handler back, then retry.
  server.use(
    http.get('https://api.example.com/trips', () =>
      HttpResponse.json([{ id: 't_1', title: 'Lisbon' }]),
    ),
  );

  const user = userEvent.setup();
  await user.press(screen.getByRole('button', { name: 'Try again' }));

  expect(await screen.findByText('Lisbon')).toBeOnTheScreen();
});

A manual mock with a working implementation

Auto-mocking returns undefined everywhere, which is useless for storage: a test that writes a token and then reads it back needs the read to return what was written. An in-memory Map is a few lines and behaves correctly for that. Use this shape for any native module that is really a key-value store, and clear it between tests so one test cannot leave a token behind for the next.

Example · ts
// __tests__/support/mockSecureStore.ts
// The name must start with "mock", or Jest refuses the out-of-scope
// reference inside a hoisted jest.mock factory.
const mockStore = new Map<string, string>();

jest.mock('expo-secure-store', () => ({
  getItemAsync: jest.fn(async (key: string) => mockStore.get(key) ?? null),
  setItemAsync: jest.fn(async (key: string, value: string) => {
    mockStore.set(key, value);
  }),
  deleteItemAsync: jest.fn(async (key: string) => {
    mockStore.delete(key);
  }),
}));

beforeEach(() => {
  mockStore.clear();
});

export { mockStore };

Discussion

  • Be the first to comment on this lesson.