Mobile Testing Basics
10 examples to get you started with Mobile Testing - 7 basic and 3 intermediate.
Search across all documentation pages
10 examples to get you started with Mobile Testing - 7 basic and 3 intermediate.
Expo SDK 57 ships Jest integration through jest-expo. Add React Native Testing Library for component tests:
npx create-expo-app@latest MyTestApp --template blank-typescript
cd MyTestApp
npx expo install jest-expo @testing-library/react-native @types/jest --devWire the preset in package.json and add a setup file for RNTL matchers:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
},
"jest": {
"preset": "jest-expo",
"setupFilesAfterEnv": ["<rootDir>/jest.setup.ts"]
}
}// jest.setup.ts
import "@testing-library/react-native/extend-expect";Run the suite locally before every PR:
npm testTooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3. E2E snippets assume a dev or release build - Expo Go alone is not enough for Detox or most Maestro production flows.
Mobile apps need a layered strategy: fast tests run on every save; slow tests run before release.
┌─────────────┐
│ E2E flows │ few - Maestro / Detox on real builds
├─────────────┤
│ Integration │ some - screens + navigation + mocked APIs
├─────────────┤
│ Component │ many - RNTL, user-visible behavior
├─────────────┤
│ Unit │ most - pure functions, reducers, hooks logic
└─────────────┘| Layer | Runs in | Typical tools | When it fails |
|---|---|---|---|
| Unit | Node (ms) | Jest | Bad math, bad state transitions |
| Component | Node (ms) | Jest + RNTL | Wrong label, missing button |
| Integration | Node (s) | Jest + RNTL + mocks | Screen flow breaks across files |
| E2E | Simulator/device (min) | Maestro, Detox | Real navigation, native bridges, timing |
expo-secure-store, fetch, and sensors in Jest; exercise them in E2ERelated: Testing Best Practices - what to run before every store submission
Unit tests are the pyramid base: no React tree, no native modules, sub-millisecond feedback.
// src/lib/formatPrice.ts
export function formatPrice(cents: number, currency = "USD"): string {
if (!Number.isFinite(cents) || cents < 0) {
throw new RangeError("cents must be a non-negative finite number");
}
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(cents / 100);
}// src/lib/formatPrice.test.ts
import { formatPrice } from "./formatPrice";
describe("formatPrice", () => {
it("formats whole dollars", () => {
expect(formatPrice(1200)).toBe("$12.00");
});
it("rejects negative values", () => {
expect(() => formatPrice(-1)).toThrow(RangeError);
});
});*.test.ts next to the module or under __tests__/ - both work; pick one convention per reporender() here - if you need a component tree, that is a component or integration testRelated: Jest Setup for Expo - preset, transforms, and snapshot discipline
Component tests assert what the user sees - text, roles, and press outcomes - not internal state variable names.
// src/components/PrimaryButton.tsx
import { Pressable, Text, StyleSheet } from "react-native";
type Props = {
label: string;
onPress: () => void;
};
export function PrimaryButton({ label, onPress }: Props) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
style={styles.button}
>
<Text style={styles.label}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: { backgroundColor: "#2563eb", padding: 12, borderRadius: 8 },
label: { color: "#fff", textAlign: "center", fontWeight: "600" },
});// src/components/PrimaryButton.test.tsx
import { render, screen, userEvent } from "@testing-library/react-native";
import { PrimaryButton } from "./PrimaryButton";
describe("PrimaryButton", () => {
it("calls onPress when tapped", async () => {
const user = userEvent.setup();
const onPress = jest.fn();
render(<PrimaryButton label="Continue" onPress={onPress} />);
await user.press(screen.getByRole("button", { name: "Continue" }));
expect(onPress).toHaveBeenCalledTimes(1);
});
});userEvent.press simulates the full press lifecycle - prefer it over raw fireEvent for closer-to-user behaviorgetByRole + name when possible - it enforces accessible labels you will reuse in E2Erender still runs in Node; native views are mocked - that is why the layer is fastRelated: React Native Testing Library - query priority, async utilities, and anti-patterns
RNTL query priority mirrors accessibility: label and role beat testID, and testID beats brittle CSS-like selectors (which do not exist in RN anyway).
import { View, Text, TextInput, StyleSheet } from "react-native";
export function EmailField() {
return (
<View>
<Text nativeID="email-label">Email</Text>
<TextInput
accessibilityLabel="Email"
accessibilityLabelledBy="email-label"
placeholder="you@example.com"
style={styles.input}
/>
</View>
);
}
const styles = StyleSheet.create({
input: { borderWidth: 1, borderColor: "#d1d5db", padding: 10, borderRadius: 8 },
});import { render, screen } from "@testing-library/react-native";
import { EmailField } from "./EmailField";
it("exposes the email field by accessible name", () => {
render(<EmailField />);
expect(screen.getByLabelText("Email")).toBeTruthy();
});getByLabelText ties tests to the same strings VoiceOver and TalkBack read aloudtestID only when role and label cannot disambiguate duplicate controlsUNSAFE_getByType) - refactors break those tests instantlyRelated: React Native Testing Library -
within, asyncfindBy*, and debug output
Jest runs in Node - native bridges are unavailable unless mocked. Mock the module, not your component's internals.
// src/hooks/useHapticTap.ts
import * as Haptics from "expo-haptics";
export async function useHapticTap() {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}// src/hooks/useHapticTap.test.ts
jest.mock("expo-haptics", () => ({
impactAsync: jest.fn(),
ImpactFeedbackStyle: { Light: "light" },
}));
import * as Haptics from "expo-haptics";
import { useHapticTap } from "./useHapticTap";
it("fires light impact on tap", async () => {
await useHapticTap();
expect(Haptics.impactAsync).toHaveBeenCalledWith("light");
});jest.mock before imports of the module under test (Jest hoists mocks, but keep the pattern consistent)jest-expo auto-mocks many Expo modules; override when you need deterministic return valuesRelated: Mocking Native Modules - manual mocks,
__mocks__, and false-confidence traps
Integration tests render a screen (or navigator slice), interact across child components, and assert the combined outcome.
// src/screens/SignInScreen.tsx
import { useState } from "react";
import { View, Text, TextInput, Pressable, StyleSheet } from "react-native";
export function SignInScreen({ onSubmit }: { onSubmit: (email: string) => void }) {
const [email, setEmail] = useState("");
const canSubmit = email.includes("@");
return (
<View style={styles.screen}>
<Text accessibilityRole="header">Sign in</Text>
<TextInput
accessibilityLabel="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
style={styles.input}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel="Continue"
disabled={!canSubmit}
onPress={() => onSubmit(email)}
style={[styles.button, !canSubmit && styles.disabled]}
>
<Text style={styles.buttonText}>Continue</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
screen: { padding: 24, gap: 12 },
input: { borderWidth: 1, borderColor: "#d1d5db", padding: 10, borderRadius: 8 },
button: { backgroundColor: "#2563eb", padding: 12, borderRadius: 8 },
disabled: { opacity: 0.4 },
buttonText: { color: "#fff", textAlign: "center", fontWeight: "600" },
});// src/screens/SignInScreen.test.tsx
import { render, screen, userEvent } from "@testing-library/react-native";
import { SignInScreen } from "./SignInScreen";
it("submits a valid email", async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<SignInScreen onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "user@example.com");
await user.press(screen.getByRole("button", { name: "Continue" }));
expect(onSubmit).toHaveBeenCalledWith("user@example.com");
});fetch, expo-secure-store) so the test stays deterministicNavigationContainer and seed initial stateRelated: Jest Setup for Expo - mocking navigation and global fetch
Snapshots catch unintended visual tree drift, but overuse creates noisy diffs teams rubber-stamp.
import { render } from "@testing-library/react-native";
import { Text, View } from "react-native";
function Badge({ count }: { count: number }) {
return (
<View>
<Text>{count} unread</Text>
</View>
);
}
it("matches stable badge markup", () => {
const { toJSON } = render(<Badge count={3} />);
expect(toJSON()).toMatchSnapshot();
});getByRole expectationsRelated: Jest Setup for Expo - snapshot serializers and update workflow
waitForNetwork-backed UI needs async queries - render loading first, then assert settled content.
import { useEffect, useState } from "react";
import { Text, View, ActivityIndicator } from "react-native";
type Profile = { name: string };
export function ProfileCard({ userId }: { userId: string }) {
const [profile, setProfile] = useState<Profile | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetch(`https://api.example.com/users/${userId}`)
.then((res) => res.json())
.then((data: Profile) => {
if (!cancelled) setProfile(data);
})
.catch(() => {
if (!cancelled) setError("Could not load profile");
});
return () => {
cancelled = true;
};
}, [userId]);
if (error) return <Text>{error}</Text>;
if (!profile) return <ActivityIndicator accessibilityLabel="Loading profile" />;
return (
<View>
<Text accessibilityRole="header">{profile.name}</Text>
</View>
);
}import { render, screen, waitFor } from "@testing-library/react-native";
import { ProfileCard } from "./ProfileCard";
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ name: "Riley" }),
}) as jest.Mock;
});
it("shows the profile name after fetch resolves", async () => {
render(<ProfileCard userId="42" />);
expect(screen.getByLabelText("Loading profile")).toBeTruthy();
await waitFor(() => {
expect(screen.getByRole("header", { name: "Riley" })).toBeTruthy();
});
});findBy* as sugar for waitFor + getBy* when you only need presencemock fetch (or MSW) in Jest - never hit real APIs in unit or integration testsmockRejectedValue - mobile networks fail constantlyRelated: Mocking Native Modules - stubbing
fetchand Expo networking modules
End-to-end tests sit at the pyramid top - few flows, real binary, real gestures. Pick the runner by build pipeline and flakiness tolerance.
# .maestro/flows/sign-in.yaml
appId: com.example.mytestapp
---
- launchApp
- tapOn: "Sign in"
- inputText: "user@example.com"
- tapOn: "Continue"
- assertVisible: "Welcome"// e2e/signIn.e2e.ts - Detox (requires dev client / release build)
describe("Sign in", () => {
beforeAll(async () => {
await device.launchApp();
});
it("shows welcome after valid email", async () => {
await element(by.text("Sign in")).tap();
await element(by.label("Email")).typeText("user@example.com");
await element(by.text("Continue")).tap();
await expect(element(by.text("Welcome"))).toBeVisible();
});
});| Tool | Best for | Tradeoff |
|---|---|---|
| Maestro | YAML smoke flows, CI without heavy native test harness | Less gray-box control over async native timers |
| Detox | Deep native sync, biometrics, complex navigation stacks | Higher setup cost, Xcode/Android toolchain required |
expo run:ios in CI firstRelated: Maestro E2E - YAML flows and flaky-test controls | Detox E2E - gray-box synchronization
package.json Scripts and Contract GuardsTie the pyramid to commands developers actually run - and guard API shapes so integration tests do not lie.
{
"scripts": {
"test": "jest --ci",
"test:unit": "jest --testPathPattern=\\.test\\.ts$",
"test:integration": "jest --testPathPattern=screens/",
"test:e2e:maestro": "maestro test .maestro/flows",
"test:contract": "jest --testPathPattern=contract"
}
}// src/api/contracts/userSchema.ts
import { z } from "zod";
export const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
displayName: z.string().min(1),
});
export type User = z.infer<typeof userSchema>;// src/api/contracts/userSchema.contract.test.ts
import { userSchema } from "./userSchema";
const fixture = {
id: "550e8400-e29b-41d4-a716-446655440000",
email: "user@example.com",
displayName: "Riley",
};
it("mobile client accepts the user payload the server documents", () => {
expect(() => userSchema.parse(fixture)).not.toThrow();
});test:unit vs test:integration lets CI parallelize fast and slow jobs - same pyramid, different runnersmain, and revisit Testing Best PracticesRelated: Contract Tests for APIs - Pact and schema strategies | Visual Regression & Storybook - on-device component catalogs
Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026