React Native testing without overcomplicating it
Testing in React Native scares many developers more than it should.
Not because testing is especially mysterious, but because people mix too many things too early: Jest, React Native Testing Library, Detox, Maestro, mocks, device simulators, CI pipelines, snapshots, and then everything starts to feel heavier than it really is.
So let’s simplify it.
If you are starting out, you only need to understand three levels:
- Unit tests
- Integration tests
- End-to-end tests
Each one answers a different question.
Why this matters #
A lot of teams say “we should add tests” but never define what they want to protect.
That is the first mistake.
- A unit test protects small pieces of logic.
- An integration test checks that a few pieces work together.
- An e2e test validates a real user flow in the app.
If you use the wrong tool for the wrong question, testing becomes slow, annoying, and expensive.
What does this mean in practice?
Do not use e2e for everything. Do not mock the whole universe in unit tests. And do not expect one testing layer to replace all the others.
First, what should you install? #
For the basics in React Native, a very common starting point is:
Jestas the test runner@testing-library/react-nativeto render components and interact with them- built-in Jest matchers from
@testing-library/react-native
If your project was created with React Native CLI, part of the Jest setup may already exist. If you are using Expo, the setup is slightly different, but the mental model is the same.
Here is a common install for a TypeScript project:
npm install -D jest @types/jest
npm install -D @testing-library/react-nativeIn many React Native projects you will also need Babel/Jest config that already comes from the app template, so check the existing project before adding random packages just because a tutorial told you to.
If you are in Expo, this is usually the more natural starting point:
npx expo install @testing-library/react-native
npm install -D jest @types/jestThe important point is not memorizing one magical install command. The important point is understanding the role of each tool.
A minimal Jest setup #
In simple terms:
jest.config.jstells Jest how to run testsjest.setup.tscan load shared test setup when you need it
An example setup could look like this:
// jest.config.js
module.exports = {
preset: "react-native",
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
testPathIgnorePatterns: ["/node_modules/", "/e2e/"],
};And if you want a shared setup file, it can be something simple like this:
// jest.setup.ts
beforeAll(() => {
// Put global test setup here if your suite needs it.
});
afterEach(() => {
jest.clearAllMocks();
});And in package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}Useful commands:
npm testruns all Jest testsnpm run test:watchreruns tests while you codenpm run test:coverageshows how much code is being touched by tests
Coverage is useful, but do not worship the percentage. A project can have 90% coverage and still miss the important bugs.
What is a unit test? #
A unit test checks one small piece of behavior in isolation.
Usually that means:
- a function
- a utility
- a formatter
- a validation rule
- sometimes a very small component with simple behavior
Why should you care?
Because unit tests are usually fast, cheap, and great for protecting business logic.
Let’s say you have a helper that decides if a password is valid.
// src/features/auth/isValidPassword.ts
export function isValidPassword(password: string): boolean {
return password.length >= 8;
}Now the test:
// src/features/auth/isValidPassword.test.ts
import { isValidPassword } from "./isValidPassword";
describe("isValidPassword", () => {
it("returns false for short passwords", () => {
expect(isValidPassword("1234")).toBe(false);
});
it("returns true for passwords with 8 or more characters", () => {
expect(isValidPassword("12345678")).toBe(true);
});
});That is a unit test.
Small input, small output, clear expectation.
What is an integration test? #
An integration test checks that multiple parts work together correctly.
In React Native, this usually means rendering a component, simulating user interaction, and verifying the resulting UI.
Now we are not only testing one function. We are testing the collaboration between:
- the component
- its state
- user interaction
- maybe a child component or form logic
Here is a very small example: a login form that enables a button only when the email looks valid.
// src/features/auth/LoginForm.tsx
import { useState } from "react";
import { Button, TextInput, View } from "react-native";
export function LoginForm() {
const [email, setEmail] = useState("");
const isValid = email.includes("@");
return (
<View>
<TextInput
placeholder="Email"
value={email}
onChangeText={setEmail}
/>
<Button title="Continue" disabled={!isValid} onPress={() => {}} />
</View>
);
}And the integration test:
// src/features/auth/LoginForm.test.tsx
import { fireEvent, render, screen } from "@testing-library/react-native";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
it("enables the button after entering a valid email", () => {
render(<LoginForm />);
const input = screen.getByPlaceholderText("Email");
const button = screen.getByText("Continue");
expect(button).toBeDisabled();
fireEvent.changeText(input, "marco@example.com");
expect(button).toBeEnabled();
});
});This is already more valuable than many people expect.
Why?
Because it behaves much closer to how a user interacts with the app. You are no longer testing only logic in a vacuum.
What is an e2e test? #
An e2e test, or end-to-end test, validates a real flow in the running app.
Not just a rendered component in a test environment. The real app.
Examples:
- open the app
- tap “Login”
- type credentials
- submit the form
- verify the home screen appears
This is the testing layer that gives the highest confidence, but it is also the slowest and usually the most expensive to maintain.
In simple terms:
- Unit tests ask: does this small piece of logic work?
- Integration tests ask: do these pieces work together?
- E2E tests ask: does the user flow actually work on a device or simulator?
A simple mental model #
You do not need hundreds of e2e tests.
A practical setup for many teams is:
- many unit tests for business logic
- a healthy number of integration tests for screens and flows
- a small number of e2e tests for critical paths
Critical paths usually mean:
- sign in
- sign up
- checkout
- onboarding
- payment
- core navigation
Options for e2e in React Native #
The two most practical options to discuss today are Detox and Maestro.
There are others, like Appium, but for most React Native teams starting from
scratch, Detox and Maestro are the main conversation.
Detox #
Detox is a more code-centric e2e framework. It has been strongly associated with React Native for years. It is also one of the oldest and most widely used options in the React Native ecosystem, partly because it has been around for so long.
Why people like it:
- It is built for real app interaction.
- It gives good control for advanced flows.
- It feels closer to an engineering-heavy test setup.
What is the trade-off?
- Setup can feel heavier.
- It asks the team to be comfortable with a code-first testing workflow.
- Maintaining larger suites can become work very quickly if the team is not disciplined.
A tiny example looks like this:
describe("Login flow", () => {
beforeAll(async () => {
await device.launchApp();
});
it("logs in successfully", async () => {
await element(by.id("email-input")).typeText("marco@example.com");
await element(by.id("password-input")).typeText("12345678");
await element(by.text("Continue")).tap();
await expect(element(by.text("Welcome back"))).toBeVisible();
});
});You can see the style immediately: it is code-first and explicit.
Maestro #
Maestro takes a different approach.
It is built around YAML flows instead of code-first test files, and that changes the workflow quite a bit.
Why people like it:
- It is easy to read
- It keeps test flows outside the application code
- It is friendly for QA and non-native specialists
- It fits well when multiple roles need to collaborate on test coverage
Trade-offs:
- It is a different workflow from code-centric test suites
- Teams that want every test expressed as application-adjacent code may prefer a different style
- You need discipline in how flows are named, organized, and maintained
A simple Maestro flow:
appId: com.example.app
---
- launchApp
- tapOn: "Email"
- inputText: "marco@example.com"
- tapOn: "Password"
- inputText: "12345678"
- tapOn: "Continue"
- assertVisible: "Welcome back"This is one reason many teams adopt Maestro quickly: you can read the test like a checklist.
And if you are working with Expo, this becomes even more practical. Expo itself has a guide showing how to integrate Maestro into the Expo SDLC through EAS Workflows, which is another good signal that Maestro fits naturally in that ecosystem.
Detox vs Maestro, my practical opinion #
The real difference is workflow.
Detox is more code-centric. Maestro is more flow-centric.
That matters because e2e is not only about what the framework can do. It is also about who can maintain the tests and how naturally they fit into your delivery process.
I would lean toward Detox when:
- the team is more comfortable with code-centric testing
- the people maintaining e2e are mainly React Native engineers
- you already know e2e discipline matters in your workflow
I would lean toward Maestro when:
- QA should be able to add and maintain flows without adding app code
- you want tests that are easy to read by engineers, QA, and product-minded teammates
- you want e2e to stay closer to user journeys than to framework-specific test code
- you are using Expo and want a path that fits naturally with current Expo workflow tooling
So the better choice depends on how your team writes, owns, and maintains e2e tests.
For a more feature-by-feature comparison, Maestro published the following breakdown in its article on flakiness in React Native testing:
| Feature | Detox | Maestro |
|---|---|---|
| Testing Approach | Gray-box (in-app instrumentation) | Black-box (external monitoring) |
| Synchronization | Tracks JS thread, UI queue, and network | Observes UI hierarchy and animation settling |
| Flakiness Rate | Very low (below 2%) | Very low (below 1%) |
| Retry Logic | Minimal (uses idle sync) | Automatic retries for transient issues |
| Animation Handling | Waits for native UI queue to clear | Smart wait with a 2-second settle limit |
| Network Handling | Tracks asynchronous requests | Relies on element visibility and timeouts |
| Element Lookup Timeout | Configurable | 17 seconds (hardcoded) |
| Setup Complexity | Moderate (requires native build changes) | Low (single binary, no app changes) |
| Execution Speed (Login) | 8-12 seconds | 12-18 seconds |
| Test Authoring | JavaScript/TypeScript | Declarative YAML |
Source: Detox vs Maestro: Reducing flakiness in React Native
Common mistakes when starting #
- Writing e2e tests before having stable screen identifiers
- Testing implementation details instead of behavior
- Mocking so much that tests stop representing reality
- Expecting unit tests to catch navigation or device-level issues
- Adding one giant flaky e2e suite and calling the job done
A reasonable starting strategy #
If you are starting from zero, I would do this:
- Add Jest and React Native Testing Library first.
- Write unit tests for utilities and business rules.
- Add integration tests for important forms and screens.
- Add only a few e2e tests for critical paths.
- Expand the suite only when the team is actually maintaining it well.
That order matters.
Many teams jump directly to e2e because it looks impressive. But e2e without the lower layers often becomes slow feedback plus fragile maintenance.
Links #
- Jest documentation
- React Native Testing Library
- Built-in Jest matchers in React Native Testing Library
- Detox
- Maestro
- Expo EAS Workflows example with Maestro e2e tests
- Appium
Conclusions #
Testing in React Native becomes much easier once you stop treating it as one big thing.
Unit tests, integration tests, and e2e tests solve different problems. That is the main idea to keep.
And between Detox and Maestro, my practical default in many modern teams would be Maestro when test ownership should extend beyond React Native engineers, especially if QA needs to contribute without wiring more code into the app.