Week 16: Unit & Component Testing (Jest + RNTL)

Every screen built over the past fifteen weeks has been verified by hand, in a simulator. This week starts building a real safety net — unit and component tests that catch a regression before a person has to find it, using Jest and React Native Testing Library, the same testing philosophy this site's React course teaches, adapted for native modules and async device APIs.

Phase 7 of 8 Week 16 of 22 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Configure Jest for a React Native / Expo project, including mocking native modules
  • Render and query components with React Native Testing Library
  • Test a custom hook and async data-fetching logic
  • Know when a snapshot test helps and when it just adds noise

1. Jest Config & Mocking Native Modules

terminal
npx expo install jest-expo jest @testing-library/react-native --dev
package.json
{
  "scripts": { "test": "jest" },
  "jest": {
    "preset": "jest-expo",
    "transformIgnorePatterns": [
      "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)"
    ]
  }
}

jest-expo ships with mocks for most Expo native modules already — calling Notifications.scheduleNotificationAsync in a test doesn't try to talk to a real device; it resolves against a mock. A module without a built-in mock needs one added manually in a jest.mock() call, pointing at a fake implementation matching its real shape.

2. React Native Testing Library

CartScreen.test.tsx
import { render, screen, fireEvent } from '@testing-library/react-native';
import CartScreen from './CartScreen';

test('removing an item removes it from the list', () => {
  render(<CartScreen />);

  expect(screen.getByText('Wireless Mouse x1')).toBeOnTheScreen();

  fireEvent.press(screen.getByText('Wireless Mouse x1'));

  expect(screen.queryByText('Wireless Mouse x1')).not.toBeOnTheScreen();
});

RNTL's philosophy, carried over from its web counterpart: query the screen the way a user would find something — by visible text, a role, or an accessibility label — not by internal component names or implementation details a refactor could change without actually breaking anything real.

3. Testing Hooks & Async Code

testing a screen with an async query
import { render, screen, waitFor } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import PostsScreen from './PostsScreen';

function renderWithProviders(ui: React.ReactElement) {
  const queryClient = new QueryClient();
  return render(
    <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
  );
}

test('shows posts once loaded', async () => {
  renderWithProviders(<PostsScreen />);

  expect(screen.getByTestId('loading-spinner')).toBeOnTheScreen();

  await waitFor(() => {
    expect(screen.getByText('First Post')).toBeOnTheScreen();
  });
});

waitFor polls its callback until it stops throwing (or times out) — the correct way to assert on anything that resolves asynchronously, rather than a fixed setTimeout that's either too slow or flaky under load.

4. Snapshot Tests: When They Help

A snapshot test records a component's rendered output and fails if it changes — cheap to write, but a large snapshot diff on every unrelated change trains a team to blindly run jest --updateSnapshot without reading it, which defeats the point.

  • Good fit: small, stable, presentational components (a badge, a price formatter) where "did the output change at all" is exactly the right question.
  • Poor fit: whole screens, or anything that changes often for legitimate reasons — an assertion like getByText('First Post') stays meaningful there; a full-screen snapshot mostly just tracks churn.

5. Hands-on Exercise

Hands-on

Add a real test suite to an earlier screen

Write unit and component tests covering rendering, interaction, and async data for a screen from this course.

Requirements:

  1. Configure Jest with jest-expo and React Native Testing Library in the project from an earlier week.
  2. A component test for a list screen (e.g. the cart or notes screen) covering initial render and at least one user interaction (press, remove, toggle).
  3. An async test for a screen using TanStack Query (Week 10), asserting the loading state renders first and the loaded content appears after waitFor.
  4. A test for a custom hook (e.g. useIsOnline from Week 11, or useCartStore) using renderHook from the testing library.
  5. One deliberate snapshot test on a small presentational component, with a short comment explaining why a snapshot (rather than an explicit assertion) is the right tool there.
Hint

If a test involving TanStack Query hangs or times out, confirm you're wrapping the component under test in its own fresh QueryClientProvider per test — a shared client across tests carries cached state between them, which is a common source of tests that pass alone but fail in the full suite.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does jest-expo ship with built-in mocks for most Expo native modules?

A Jest test runs in Node, with no real device, camera, or notification system available — without mocks, calling a native module in a test would throw or hang. jest-expo's mocks let code exercise those calls in a test the same way it would in the app, resolving to a fake but realistic result.

Q2

Why does React Native Testing Library favor queries like getByText or getByRole over selecting components by internal name?

Those queries assert on what a real user actually perceives and interacts with — visible text, accessible roles and labels — so a test built on them only breaks when user-facing behavior genuinely changes, not when an internal refactor (renaming a component, restructuring its internals) leaves behavior identical.

Q3

Why is a fixed setTimeout before asserting on async UI worse than waitFor?

A fixed delay is either too short (flaky, fails under slower CI load) or wastefully long (slows the whole suite down) — there's no value that's reliably correct. waitFor polls its assertion until it passes or a timeout is hit, adapting to how long the async work actually takes.

Q4

Why is a full-screen snapshot test a poor fit for a screen that changes frequently for legitimate reasons?

Every legitimate change produces a large, hard-to-review diff, which trains developers to run --updateSnapshot reflexively without actually reading what changed — at that point the test no longer catches real regressions, it just adds friction. An explicit assertion on the specific thing that matters (a particular piece of text, an element being present) stays meaningful through unrelated changes.