Files
minimax-skills/skills/react-native-dev/references/testing.md
2026-03-26 20:32:52 +08:00

7.9 KiB

Testing Reference

Jest, React Native Testing Library, and E2E testing for Expo/React Native apps.

Setup

npx expo install jest-expo @testing-library/react-native
// package.json
{
  "jest": {
    "preset": "jest-expo",
    "setupFilesAfterSetup": ["@testing-library/react-native/extend-expect"]
  }
}
npx jest                    # Run all tests
npx jest --watch            # Watch mode
npx jest --coverage         # Coverage report
npx jest path/to/test.tsx   # Single file

React Native Testing Library

Basic Component Test

// components/__tests__/Button.test.tsx
import { render, fireEvent, screen } from "@testing-library/react-native";
import { Button } from "../Button";

describe("Button", () => {
  it("renders label", () => {
    render(<Button label="Submit" onPress={() => {}} />);
    expect(screen.getByText("Submit")).toBeTruthy();
  });

  it("calls onPress when tapped", () => {
    const onPress = jest.fn();
    render(<Button label="Submit" onPress={onPress} />);
    fireEvent.press(screen.getByText("Submit"));
    expect(onPress).toHaveBeenCalledTimes(1);
  });

  it("is disabled when loading", () => {
    render(<Button label="Submit" onPress={() => {}} loading />);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

Queries

// Prefer accessible queries
screen.getByRole("button", { name: "Submit" });
screen.getByLabelText("Email");
screen.getByPlaceholderText("Enter email");

// Text content
screen.getByText("Welcome back");
screen.getByText(/welcome/i);   // Regex — case insensitive

// Test IDs (last resort)
screen.getByTestId("user-avatar");

// Async queries
await screen.findByText("Loaded content");       // Waits for element to appear
await screen.findAllByRole("listitem");

// Non-existence
expect(screen.queryByText("Error")).toBeNull();

User Events

import { userEvent } from "@testing-library/react-native";

it("submits form on valid input", async () => {
  const user = userEvent.setup();
  render(<LoginForm onSubmit={mockSubmit} />);

  await user.type(screen.getByPlaceholderText("Email"), "user@example.com");
  await user.type(screen.getByPlaceholderText("Password"), "password123");
  await user.press(screen.getByRole("button", { name: "Login" }));

  expect(mockSubmit).toHaveBeenCalledWith({
    email: "user@example.com",
    password: "password123",
  });
});

Testing Async State

import { waitFor, act } from "@testing-library/react-native";

it("shows user data after loading", async () => {
  render(<UserProfile userId="123" />);

  // Loading state
  expect(screen.getByTestId("loading-indicator")).toBeTruthy();

  // Wait for data
  await waitFor(() => {
    expect(screen.getByText("John Doe")).toBeTruthy();
  });

  expect(screen.queryByTestId("loading-indicator")).toBeNull();
});

Testing with React Query

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: { queries: { retry: false, gcTime: 0 } },
  });
}

function renderWithQuery(ui: ReactElement) {
  const client = createTestQueryClient();
  return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}

it("fetches and displays posts", async () => {
  // Mock fetch
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve([{ id: "1", title: "Post 1" }]),
  });

  renderWithQuery(<PostsList />);

  await waitFor(() => {
    expect(screen.getByText("Post 1")).toBeTruthy();
  });
});

Testing with Zustand

import { useAuthStore } from "../../stores/auth-store";

beforeEach(() => {
  // Reset store state before each test
  useAuthStore.setState({ user: null, token: null });
});

it("shows user name when logged in", () => {
  useAuthStore.setState({ user: { id: "1", name: "Alice" }, token: "tok" });
  render(<Header />);
  expect(screen.getByText("Alice")).toBeTruthy();
});

Testing Navigation (Expo Router)

import { renderRouter, screen } from "expo-router/testing-library";

it("navigates to detail screen", async () => {
  renderRouter({
    index: () => <HomeScreen />,
    "user/[id]": () => <UserScreen />,
  });

  fireEvent.press(screen.getByText("View Profile"));

  await waitFor(() => {
    expect(screen.getByTestId("user-screen")).toBeTruthy();
  });
});

Mocking

Mock Expo Modules

// __mocks__/expo-secure-store.ts
export const getItemAsync = jest.fn().mockResolvedValue(null);
export const setItemAsync = jest.fn().mockResolvedValue(undefined);
export const deleteItemAsync = jest.fn().mockResolvedValue(undefined);
// In test
jest.mock("expo-secure-store", () => ({
  getItemAsync: jest.fn().mockResolvedValue("mock-token"),
  setItemAsync: jest.fn(),
}));

Mock fetch / API calls

beforeEach(() => {
  global.fetch = jest.fn();
});

afterEach(() => {
  jest.restoreAllMocks();
});

it("handles API error", async () => {
  (global.fetch as jest.Mock).mockResolvedValue({
    ok: false,
    status: 500,
    json: () => Promise.resolve({ message: "Server error" }),
  });

  render(<UserProfile userId="123" />);

  await waitFor(() => {
    expect(screen.getByText("Server error")).toBeTruthy();
  });
});

Mock react-native modules

// jest.setup.ts
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");

jest.mock("@react-native-community/netinfo", () => ({
  addEventListener: jest.fn(() => jest.fn()),
  fetch: jest.fn(() => Promise.resolve({ isConnected: true })),
}));

Unit Tests (Non-UI Logic)

// utils/__tests__/format.test.ts
import { formatCurrency, formatDate } from "../format";

describe("formatCurrency", () => {
  it("formats USD", () => expect(formatCurrency(1234.5, "USD")).toBe("$1,234.50"));
  it("handles zero", () => expect(formatCurrency(0, "USD")).toBe("$0.00"));
  it("handles negative", () => expect(formatCurrency(-50, "USD")).toBe("-$50.00"));
});
// stores/__tests__/cart-store.test.ts
import { useCartStore } from "../cart-store";

beforeEach(() => useCartStore.setState({ items: [] }));

describe("CartStore", () => {
  it("adds item", () => {
    useCartStore.getState().add(mockProduct);
    expect(useCartStore.getState().items).toHaveLength(1);
  });

  it("calculates total", () => {
    useCartStore.getState().add({ ...mockProduct, price: 10 });
    expect(useCartStore.getState().total()).toBe(10);
  });
});

E2E Testing (Maestro)

Maestro is the recommended E2E tool for Expo — no build configuration needed.

# Install
curl -Ls "https://get.maestro.mobile.dev" | bash

# Run flow
maestro test flows/login.yaml
# flows/login.yaml
appId: com.example.myapp
---
- launchApp
- tapOn:
    text: "Sign In"
- inputText:
    id: "email-input"
    text: "user@example.com"
- inputText:
    id: "password-input"
    text: "password123"
- tapOn:
    text: "Login"
- assertVisible:
    text: "Welcome back"
- takeScreenshot: login-success
# flows/create-post.yaml
appId: com.example.myapp
---
- launchApp
- runFlow: ./login.yaml
- tapOn:
    id: "new-post-button"
- inputText:
    id: "post-title"
    text: "My Test Post"
- tapOn:
    text: "Publish"
- assertVisible:
    text: "My Test Post"

Testing Checklist

Layer What to Test
Unit Business logic, stores, utility functions, hooks
Component Renders correctly, user interactions, loading/error states
Integration Component + store/query working together
E2E Critical user flows (login, checkout, core feature)

Common Mistakes

Wrong Right
getByTestId everywhere Use accessible queries (getByRole, getByLabelText)
Testing implementation details Test behavior the user sees
No waitFor on async operations waitFor or findBy* for async
Real network calls in tests Mock fetch or use MSW
Testing every line Focus on behavior, not coverage %