EduFlow

Section 5 · Shipping

Testing with Jest and React Native Testing Library

18 min3 min readFill in the blanks

Lesson at a glance

Set up Jest with the jest-expo preset, unit-test Trailhead’s pure logic, test components the way users use them with React Native Testing Library, and mock native modules at the boundary.

  1. 1Setting up Jest
  2. 2Start with pure logic
  3. 3Testing components like a user
  4. 4Mocking the device at the boundary
  5. 5Where end-to-end tests fit

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Every lesson in this course has pushed logic out of components and into small pure functions: parseDistanceKm, hikesReducer, validateHikeForm, permissionAction, trackDistanceKm. That wasn’t only tidiness. On mobile, checking a change by hand means rebuilding, launching, navigating three screens deep and typing on a simulator keyboard. A test that checks the same thing in 20 milliseconds, every time you save, is the best trade you’ll make.

Setting up Jest

Expo’s preset, jest-expo, configures Jest for React Native: it transforms your TypeScript and JSX, and mocks the native side of Expo modules, so tests run in Node with no device. Install it and React Native Testing Library:

Terminal
npx expo install jest-expo jest @types/jest --dev
npx expo install @testing-library/react-native --dev

Then point Jest at the preset in package.json:

package.json
{
  "scripts": {
    "test": "jest --watchAll"
  },
  "jest": {
    "preset": "jest-expo"
  }
}

Expo’s guide also lists a transformIgnorePatterns entry for packages that ship untranspiled code; copy it from there when an import fails with “SyntaxError: Cannot use import statement outside a module”. Put tests in __tests__ folders next to the code they cover.

Start with pure logic

The parser from section 1 has many small rules. A table of cases documents them better than prose:

src/lib/__tests__/parseDistance.test.ts
import { parseDistanceKm } from '../parseDistance';

test.each([
  ['8.4', 8.4],
  ['8,4', 8.4],
  [' 12 km', 12],
  ['abc', null],
  ['0', null],
  ['1.2.3', null],
])('parseDistanceKm(%j) is %p', (input, expected) => {
  expect(parseDistanceKm(input)).toBe(expected);
});

Notice that the test cases are exactly the odd inputs you worried about when you wrote the function: the comma decimal, the trailing unit, the zero. Every bug report becomes one more row in a table like this, written before the fix, so the same bug can never quietly return.

Reducers get the same treatment: build a state, dispatch an action, check the result, and check that the input wasn’t mutated. These tests are fast, stable and need no mocks, which is why they should be most of your suite.

Testing components like a user

React Native Testing Library renders components in memory and lets you query them the way people perceive them: by role, label and text. Here’s a test for the QuickLog component from section 1:

src/components/__tests__/QuickLog.test.tsx
import { render, screen, userEvent } from '@testing-library/react-native';
import { QuickLog } from '../QuickLog';

test('saves a valid distance', async () => {
  const onSave = jest.fn();
  const user = userEvent.setup();
  await render(<QuickLog onSave={onSave} />);

  await user.type(screen.getByLabelText('Distance (km)'), '8,4');
  await user.press(screen.getByRole('button', { name: 'Save' }));

  expect(onSave).toHaveBeenCalledWith(8.4);
});

test('keeps Save disabled until the distance is valid', async () => {
  const user = userEvent.setup();
  await render(<QuickLog onSave={jest.fn()} />);

  await user.type(screen.getByLabelText('Distance (km)'), 'far');

  expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});

For getByLabelText to find the input, the TextInput needs accessibilityLabel="Distance (km)". That’s the point: a test that queries by role and label fails when the screen isn’t accessible, so your tests double as an accessibility check. userEvent simulates real interactions (focus, typing each character, press-in and press-out), and in current versions of the library both render and the interactions are async, so await them. Matchers like toBeOnTheScreen(), toHaveTextContent() and toBeDisabled() come with the library.

Mocking the device at the boundary

jest-expo stubs native modules, but stubs return nothing useful. When a component depends on a module’s answer, mock it with the answer you want to test:

TSX
import * as Location from 'expo-location';

jest.mock('expo-location', () => ({
  getForegroundPermissionsAsync: jest.fn(),
  requestForegroundPermissionsAsync: jest.fn(),
}));

test('offers Settings after a permanent denial', async () => {
  jest.mocked(Location.getForegroundPermissionsAsync).mockResolvedValue({
    status: 'denied', granted: false, canAskAgain: false, expires: 'never',
  } as Location.LocationPermissionResponse);

  await render(<TrackScreen />);

  expect(await screen.findByRole('button', { name: 'Open Settings' })).toBeOnTheScreen();
});

findBy… queries wait for the element to appear, which suits screens that load asynchronously. Mock at the edge of your app (native modules, fetch), not your own components, so the code under test is the code you ship. Screens that use Expo Router hooks can be rendered with renderRouter from expo-router/testing-library, which sets up an in-memory router with the routes you give it.

Where end-to-end tests fit

Unit and component tests can’t catch a broken native build, a permission prompt that never appears, or a keyboard covering the Save button. A few end-to-end tests on real builds cover that: tools such as Maestro (which EAS can run in the cloud) or Detox drive the actual app on a simulator. Keep them for the flows that would hurt most if broken, such as signing in and logging a hike, and let the fast tests cover everything else.

Next: turning the tested app into builds that real phones can install.

Key takeaways

  • The jest-expo preset configures Jest for React Native and mocks the native side of Expo modules, so tests run in Node without a device.
  • Pure functions such as parsers, reducers and validators give the most confidence per minute of test-writing; test them first.
  • React Native Testing Library renders components and lets you find elements by role, label and text, the way users and screen readers do.
  • In current versions of the library, render and userEvent interactions are async; await them.
  • Mock native modules like expo-location at the boundary with jest.mock, and assert on what the user sees.

Further reading

Check your understanding

3 questions

Question 1 of 3

Which Trailhead test gives the most confidence for the least effort?

Question 2 of 3

How should a component test find Trailhead’s Save button?

Question 3 of 3

A component calls Location.getForegroundPermissionsAsync(). In a Jest test it returns undefined and the component shows a spinner forever. What’s the right fix?

Practice

Fill in the blanks

Complete the form test

Fill in the blanks in this React Native Testing Library test for Trailhead’s HikeForm. It should set up user events, render the form, press the Save button found by its role, check that the distance error appears on screen, and finally check that onSave received the right hike.

import { render, screen, userEvent } from '@testing-library/react-native'; import { HikeForm } from '../HikeForm'; test('shows an error, then saves once the distance is fixed', async () => { const onSave = jest.fn(); const user = userEvent.(); await (<HikeForm onSave={onSave} />); const distance = screen.getByLabelText('Distance (km)'); await user.type(screen.getByLabelText('Name'), 'Ridge Loop'); await user.type(distance, 'far'); await user.(screen.getByRole('', { name: 'Save' })); expect(screen.getByText('Enter a distance in km, like 8.4.')).(); expect(onSave).not.toHaveBeenCalled(); await user.clear(distance); await user.type(distance, '8,4'); await user.press(screen.getByRole('button', { name: 'Save' })); expect(onSave).(expect.objectContaining({ name: 'Ridge Loop', distanceKm: 8.4 })); });