Every React Native codebase I inherit has the same testing setup: Jest plus a pile of snapshot tests. The snapshots catch formatting drift and miss every real bug. When a test fails, the developer presses u to update and moves on without reading the diff.
That is not a test suite. That is a noise generator.
Here is the testing strategy I use for client work. No snapshots. Behavioral tests. Real API mocking. It catches actual bugs instead of pretending to.
Why snapshots waste your time
A snapshot test serializes the rendered component to a string and compares it next run. Any change — adding a prop, renaming a variable, updating a library — fails the test. The developer updates the snapshot without reading it.
The result: a green checkmark that proves the component renders, not that it works.
Snapshots catch:
- Typos in text content
- Library upgrades that change output format
Snapshots miss:
- Logic bugs
- State transitions
- Race conditions
- Accessibility regressions
- Anything that requires interaction
Trade them for behavioral tests.
The React Native Testing Library model
React Native Testing Library (RNTL) renders your component into a fake view tree. You query that tree the way a user would — by role, text, label — then fire events and assert on what changed.
import { render, screen, fireEvent } from '@testing-library/react-native'
import { LoginButton } from './LoginButton'
it('calls onPress when tapped', () => {
const onPress = jest.fn()
render(<LoginButton onPress={onPress} />)
fireEvent.press(screen.getByRole('button', { name: /log in/i }))
expect(onPress).toHaveBeenCalled()
})Two things to notice:
- The query uses role and accessible name. If the button loses its accessibility role, the test fails — which is correct, because real users find buttons by role too.
- The test does not inspect component internals. It tests behavior.
Query priority
RNTL gives you several query types. In order of preference:
getByRole— accessibility role and optional name. The best default. Validates your a11y setup as a side effect.getByLabelText— for inputs with labels.getByPlaceholderText— acceptable when the label is not associated.getByText— for non-interactive text.getByTestId— last resort. Use only when nothing else works.
The lower you go on that list, the more your tests couple to implementation. By the time you reach getByTestId, you are testing internals again.
Mocking APIs with MSW
Most React Native tests mock fetch per test. That gives you dozens of mock implementations drifting away from the real API contract. Six months later, you discover the mocks return the wrong shape and the tests lie.
MSW (Mock Service Worker) fixes this. You write handlers once. They run in tests and in local development.
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('https://api.example.com/users/me', () =>
HttpResponse.json({ id: 1, name: 'Mateusz' })
),
http.post('https://api.example.com/login', async ({ request }) => {
const body = await request.json()
if (body.email === 'fail@example.com') {
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
)
}
return HttpResponse.json({ token: 'fake-jwt' })
}),
]In tests, you override specific handlers when you need a different scenario:
import { setupServer } from 'msw/native'
import { handlers } from '@/mocks/handlers'
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
it('shows error on wrong password', async () => {
server.use(
http.post('*/login', () =>
HttpResponse.json({ error: 'Invalid' }, { status: 401 })
)
)
render(<LoginScreen />)
fireEvent.changeText(
screen.getByPlaceholderText(/email/i),
'mateusz@example.com'
)
fireEvent.changeText(
screen.getByPlaceholderText(/password/i),
'wrongpassword'
)
fireEvent.press(screen.getByRole('button', { name: /log in/i }))
expect(await screen.findByText(/invalid credentials/i)).toBeVisible()
})Now the mock lives next to the real contract. If the API changes, you update the handler and every test that depends on it fails loudly.
Test the edges, not the happy path
Happy path tests are easy to write and low value. The bugs live in edge cases:
- Empty list state
- Network error retry
- Slow network with a loading spinner that never dismisses
- Permission denied
- Background-to-foreground transitions
- Concurrent updates that race
For each screen, list every state it can be in. Loading. Empty. Error. Loaded. Loaded-but-stale. Write a test for each. That is where tests earn their cost.
What to actually test
Not everything deserves a test. My cutoff:
- Pure utility functions — always test. They are pure, fast, and bug-prone.
- Hooks with non-trivial logic — test with
@testing-library/react-hooksor the newrenderHookfrom RNTL. - Components with conditional rendering or state — test the branches.
- Purely presentational components with no logic — skip. Visual review is cheaper.
- Third-party libraries — never test. You are testing their code, not yours.
If a test takes more than ten seconds to write, ask whether it is testing real behavior or implementation details.
CI configuration
Two settings that matter:
- Coverage gate on changed files, not the whole repo — 70 percent on changed files catches untested PRs without demanding 100 percent coverage of legacy code.
- Fail on warnings — React warnings usually indicate real bugs. Surface them in CI and they get fixed.
{
"jest": {
"collectCoverageFrom": ["src/**/*.{ts,tsx}"],
"coverageThreshold": {
"global": { "branches": 70, "functions": 70, "lines": 70 }
}
}
}The honest truth
Most React Native test suites I inherit test the wrong things. They snapshot the render output and call it coverage. The app still ships with bugs because the tests never exercised the logic.
Behavioral tests with RNTL and MSW catch real bugs. They take longer to write. They are worth it.
A snapshot test is a green checkmark that proves your component rendered. A behavioral test proves it works.
Want a real test suite for your React Native app?
I have replaced snapshot disasters with behavioral test suites that actually catch bugs before they ship. Let's talk.
Frequently Asked Questions
How do I test React Native components?
Use React Native Testing Library with Jest. Render the component, query by role or text, fire events with fireEvent, and assert on what is rendered. Avoid testing internal state or methods — test behavior the user can observe.
Should I use snapshot tests in React Native?
No. Snapshot tests fail on any change, including meaningless formatting drift, and reviewers rubber-stamp the diffs. Write behavioral tests instead that assert what the user sees when they take an action.
How do I mock API calls in React Native tests?
Use Mock Service Worker (MSW). It intercepts network requests at the service worker layer, so you write the same handlers for tests and local development. This avoids per-test fetch mocking and keeps mocks close to real API contracts.
What is the difference between getByRole and getByTestId?
getByRole queries by accessibility role (button, text, image), which also validates your accessibility setup. getByTestId queries by an arbitrary data attribute. Prefer getByRole — it catches missing accessibility props and breaks when a component loses its semantic role.