Testing It
Unit tests for the domain rules, component tests for the screens against a stubbed API, and one Maestro flow through sign-in.
npx expo install jest-expo jest @types/jest @testing-library/react-native --devThree layers, each answering a different question. Unit tests ask whether a rule is right. Component tests ask whether a screen renders the right thing for a given set of data. One end-to-end flow asks whether the app, installed and signed in, actually works. The layers get slower and less specific in that order, which is why there are many of the first and one of the last.
Setting it up
jest-expo is the preset. It configures the transform and stubs much of the Expo native layer, so an import of an Expo module in a test does not reach for a device. React Native Testing Library supplies render and screen. Neither renders pixels: they build the component tree and let you query it by what a user would read.
Component tests
Query by text and by accessibility label, never by a component name or an internal prop. A test that knows the structure breaks on every refactor and catches nothing. Wrap the screen in its own QueryClientProvider with retry: false, and stub the API module rather than fetch.
The end-to-end flow
Maestro drives the installed app from a short YAML file. One flow is enough for the capstone: launch with cleared state, sign in, see the feed, open an entry. It runs against a real build, so it is the only test that proves the native side is wired at all.
What trips people up
Data that arrives from a query is not there on the first render. findByText waits; getByText does not.
Example
// src/features/feed/FeedScreen.test.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react-native";
import type { ReactElement } from "react";
import * as apiClient from "../../api/client";
import type { Feed } from "../../domain/entry";
import FeedScreen from "./FeedScreen";
// Keep the real hooks and the real caching, replace only the request.
// Stubbing ../../api/entries would not work: useFeed calls getFeed through
// that module's own local binding, which replacing the module's exports
// never reaches, so the real fetch would still run.
jest.mock("../../api/client", () => ({
...jest.requireActual("../../api/client"),
request: jest.fn(),
}));
function renderWithQuery(ui: ReactElement) {
// retry: false, or a rejected query sits in the default retry loop and
// the test times out instead of failing.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
const page: Feed = {
entries: [
{
id: "e_1",
title: "North fence",
body: "Down in two places.",
photoUrl: null,
createdAt: "2026-03-04T09:12:00Z",
authorId: "u_1",
syncState: "synced",
},
],
nextCursor: null,
};
test("renders the first page of entries", async () => {
jest.mocked(apiClient.request).mockResolvedValue(page);
renderWithQuery(<FeedScreen />);
// findByText waits for the render after the promise resolves.
expect(await screen.findByText("North fence")).toBeTruthy();
});
test("shows the failure state when the request rejects", async () => {
jest.mocked(apiClient.request).mockRejectedValue(new Error("offline"));
renderWithQuery(<FeedScreen />);
expect(await screen.findByText(/could not load/i)).toBeTruthy();
});When to use it
- Proving the truncation and expiry rules are right before any screen exists.
- Checking that a screen shows the loading, empty, error and data states for the data it is given.
- Catching a release build whose API URL or permission strings are wrong, before it reaches a reviewer.
More examples
Unit tests for the domain rules
These run in milliseconds, need no renderer and no mocks, and cover the logic most likely to be subtly wrong. The third row is the one that matters: a truncated string must not come out longer than the limit it was given. Write the table first and the function second whenever the rule has edge cases.
// src/domain/entry.test.ts
import { isReadableOffline, preview, type Entry } from "./entry";
const base: Entry = {
id: "e_1",
title: "North fence",
body: "Down in two places.",
photoUrl: null,
createdAt: "2026-03-04T09:12:00Z",
authorId: "u_1",
syncState: "synced",
};
describe("preview", () => {
// test.each takes a table. The longhand is a loop of test() calls with
// the same body; each row reports as its own test either way.
const cases: [string, number, string][] = [
["short enough", 20, "short enough"],
["a b c", 20, "a b c"],
["0123456789", 5, "01..."],
];
test.each(cases)("%s truncated at %i", (body, max, expected) => {
expect(preview({ ...base, body }, max)).toBe(expected);
});
});
test("a failed entry is not readable offline", () => {
expect(isReadableOffline({ ...base, syncState: "failed" })).toBe(false);
expect(isReadableOffline({ ...base, body: "" })).toBe(false);
expect(isReadableOffline(base)).toBe(true);
});The one end-to-end journey
Maestro finds elements by the text a user can see, which is why accessible labels pay for themselves twice. One flow through the critical path is the right amount for an app this size: it catches the failures component tests cannot see, such as a missing permission string, a broken native dependency, or an API base URL that points at localhost in the release build.
# .maestro/sign-in.yml
# Runs against an installed build: eas build --profile preview, then
# maestro test .maestro/sign-in.yml with the app on a device or emulator.
appId: com.example.fieldlog
---
- launchApp:
clearState: true # start signed out, every run
- tapOn: "Email"
- inputText: "[email protected]"
- tapOn: "Password"
- inputText: "correct-horse"
- tapOn: "Sign in"
# Proves the token was stored and the navigator swapped stacks.
- assertVisible: "Feed"
- tapOn: "North fence"
- assertVisible: "Down in two places."Stubbing the native modules a screen touches
One setup file, loaded before every test, holding the stubs that would otherwise be repeated in a dozen files. Returning a string for CameraView is the standard trick for a native view: it renders as a leaf in the tree and queries still work around it. Add a module here the first time a test fails with an error from inside a native package.
// jest.setup.ts
// Referenced from package.json:
// "jest": {
// "preset": "jest-expo",
// "setupFilesAfterEnv": ["<rootDir>/jest.setup.ts"]
// }
// jest-expo stubs most of the Expo native layer, but a keychain has no
// sensible default. Give it one, so a screen that reads the session on
// mount resolves instead of throwing.
jest.mock("expo-secure-store", () => ({
setItemAsync: jest.fn(async () => undefined),
getItemAsync: jest.fn(async () => null),
deleteItemAsync: jest.fn(async () => undefined),
}));
// The camera is a view with imperative methods. Tests never need the
// real one; they need takePictureAsync to resolve with a path.
jest.mock("expo-camera", () => ({
CameraView: "CameraView",
useCameraPermissions: () => [{ granted: true, status: "granted" }, jest.fn()],
}));
Discussion