Mobile Apps with React Native and Expo · Device Features, Performance, Testing and Shipping
Testing React Native Apps
Test the logic with fast unit tests, components with React Native Testing Library (render, press, assert what the user sees), and the critical flows end to end. Know what each layer catches.
What you'll build
Tests for the sign-up form: unit tests for validation, a component test that types into fields and presses the button, and a plan for one end-to-end test on a real build.
The idea
A healthy mix, like a pyramid:
| Layer | Tool | Tests | Speed |
|---|---|---|---|
| Unit | Jest (jest-expo preset) |
Pure functions: validation, reducers, formatting | Milliseconds |
| Component | React Native Testing Library | A screen renders, the user types and taps, the right text appears | Fast, no device |
| End to end | Maestro or Detox | Real app on a simulator: sign up, buy, sign out | Slow; only key flows |
React Native Testing Library encourages testing what the user sees: find elements by text, label or role (getByRole("button", { name: "Create account" })), fire events, and assert on visible output, not on internal state.
Mock the edges (network, storage, native modules), not your own components.
The code
// signupForm.test.ts: unit tests for pure logic
import { validate } from "./signupForm";
test("rejects a short password", () => {
expect(validate({ name: "Ana", email: "[email protected]", password: "123" }).password)
.toBe("Use at least 8 characters");
});
// SignUp.test.tsx: a component test
import { render, screen, fireEvent, waitFor } from "@testing-library/react-native";
import SignUp from "./SignUp";
test("shows errors after submit, then calls onSubmit when fixed", async () => {
const onSubmit = jest.fn().mockResolvedValue(undefined);
render(<SignUp onSubmit={onSubmit} />);
fireEvent.press(screen.getByRole("button", { name: "Create account" }));
expect(screen.getByText("Enter your name")).toBeOnTheScreen();
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.changeText(screen.getByPlaceholderText("Name"), "Ana");
fireEvent.changeText(screen.getByPlaceholderText("Email"), "[email protected]");
fireEvent.changeText(screen.getByPlaceholderText("Password"), "correct-horse");
fireEvent.press(screen.getByRole("button", { name: "Create account" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ name: "Ana", email: "[email protected]", password: "correct-horse" }));
});
# e2e/signup.yaml: a Maestro flow on a real build
appId: com.example.myapp
---
- launchApp
- tapOn: "Name"
- inputText: "Ana"
- tapOn: "Email"
- inputText: "[email protected]"
- tapOn: "Password"
- inputText: "correct-horse"
- tapOn: "Create account"
- assertVisible: "Welcome, Ana"
Try the logic
Here's a tiny test runner with test and expect, the same shape as Jest, testing a cart total function. It shows how a unit test pins down behaviour, including the edge cases.
type Item = { price: number; qty: number };
function cartTotal(items: Item[], discountPercent = 0): number {
const subtotal = items.reduce((sum, i) => sum + Math.round(i.price * 100) * i.qty, 0); // work in cents
const discount = Math.round((subtotal * Math.min(Math.max(discountPercent, 0), 100)) / 100);
return (subtotal - discount) / 100;
}
const results: string[] = [];
function test(name: string, fn: () => void) {
try { fn(); results.push(`PASS ${name}`); } catch (e) { results.push(`FAIL ${name}: ${(e as Error).message}`); }
}
function expect<T>(actual: T) {
return {
toBe(expected: T) {
if (actual !== expected) throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
},
};
}
test("adds prices times quantities", () => expect(cartTotal([{ price: 2.5, qty: 2 }, { price: 1, qty: 3 }])).toBe(8));
test("empty cart is zero", () => expect(cartTotal([])).toBe(0));
test("avoids floating point drift", () => expect(cartTotal([{ price: 0.1, qty: 1 }, { price: 0.2, qty: 1 }])).toBe(0.3));
test("applies a percentage discount", () => expect(cartTotal([{ price: 50, qty: 1 }], 10)).toBe(45));
test("clamps discounts above 100%", () => expect(cartTotal([{ price: 50, qty: 1 }], 150)).toBe(0));
test("naive float sum would fail", () => expect(0.1 + 0.2).toBe(0.3)); // shows why we use cents
console.log(results.join("\n"));
Output
PASS adds prices times quantities PASS empty cart is zero PASS avoids floating point drift PASS applies a percentage discount PASS clamps discounts above 100% FAIL naive float sum would fail: expected 0.3, got 0.30000000000000004
The last test fails on purpose: 0.1 + 0.2 is 0.30000000000000004 in floating point, which is why the function works in cents.
Common mistakes
- Testing implementation details (state values, internal methods) instead of what the user sees; tests break on every refactor.
- Only end-to-end tests. Slow and flaky; keep them for a few critical flows.
- Unmocked native modules in Jest. Use the
jest-expopreset and mock what's left. - Forgetting async. Use
await waitFor/findBy…for anything that updates after a promise. - No tests for the money and auth logic, the places bugs cost most.
Key points
- Unit-test pure logic with Jest; it's fast and catches most bugs.
- Component tests with React Native Testing Library: query by role/text, act like a user.
- A few end-to-end flows (Maestro/Detox) on a real build.
- Mock the edges, not your own components.
Further reading: Unit testing with Jest in Expo, React Native Testing Library.
Practice questions
What should React Native Testing Library tests query and assert on?
Answer
What the user perceives: elements found by role, label, text or placeholder; actions via fireEvent (press, changeText); assertions on visible text and called callbacks, not on internal state or component internals.
How would you split tests across unit, component and end-to-end layers in a mobile app?
Answer
Many fast unit tests for pure logic (validation, reducers, money), component tests for screens' behaviour without a device, and a small number of end-to-end tests (Maestro or Detox) for critical flows like sign-up and checkout on a real build.
Open in the app to mark lessons complete, take notes and track your progress.