Testing React Native Apps in 2026: A Practical Guide to Jest, RNTL, and Maestro

A practical guide to testing React Native apps in 2026 using Jest 30, React Native Testing Library, and Maestro. Covers unit tests, component tests, E2E testing with real code examples, and CI/CD integration.

Why Testing Your React Native App Matters More Than Ever

React Native has matured significantly. With React Native 0.79's performance improvements, Expo Router's file-based navigation, and modern state management solutions like Zustand, building cross-platform mobile apps has never been more productive. But here's the thing — as your app grows in complexity, so does the risk of shipping broken features to millions of users.

Testing is the safety net that catches regressions before your users do. Yet in the React Native ecosystem, testing has historically been... confusing. Fragmented tooling, deprecated libraries, and platform-specific quirks made it hard to know where to even start. That's changed dramatically over the past year or so.

Today, the testing landscape for React Native is clear and well-defined. Jest 30 brings faster test execution and native TypeScript support. React Native Testing Library (RNTL) has matured into the definitive component testing solution, replacing the deprecated react-test-renderer. And Maestro has emerged as the go-to end-to-end testing framework, offering a refreshingly simple YAML-based approach that works reliably on both iOS and Android.

In this guide, we'll build a complete testing strategy from scratch. You'll learn how to set up each layer of testing, write effective tests that catch real bugs, and integrate everything into a CI/CD pipeline. Whether you're starting a new project or retrofitting tests into an existing app, this guide gives you a practical, modern approach that actually works.

Understanding the Testing Pyramid for React Native

Before diving into code, let's establish a clear mental model. The testing pyramid for React Native has three layers, and each one serves a distinct purpose.

Unit Tests (Base Layer)

Unit tests verify individual functions, hooks, and utility modules in isolation. They run entirely in Node.js using Jest — no simulator, no device, no React rendering required. These are your fastest tests, typically executing in milliseconds.

Examples include testing a formatCurrency() utility, a custom useAuth hook's state transitions, or a Redux reducer's response to specific actions. Aim for high coverage here because these tests are cheap to write and fast to run.

Component Tests (Middle Layer)

Component tests render React Native components using React Native Testing Library and verify they behave correctly from a user's perspective. They test interactions (taps, text input, scrolling), conditional rendering, and integration between parent and child components.

These tests run in Node.js with a simulated React Native environment, so they're still fast — typically under a second each. Honestly, they're the best balance of confidence vs. speed you'll find.

End-to-End Tests (Top Layer)

E2E tests run your actual app on a simulator or device and simulate real user flows. Maestro handles this layer, testing complete journeys like sign-up, purchase checkout, or navigation flows. These are slower (seconds to minutes) but provide the highest confidence that your app works as users will actually experience it.

A good ratio to target: roughly 70% unit tests, 20% component tests, and 10% E2E tests. The exact numbers will vary by project, but the principle holds — lean heavily on fast, focused tests and use E2E tests strategically for critical paths.

Setting Up Jest 30 for React Native

Jest 30, released in mid-2025, brought significant improvements that make React Native testing faster and more ergonomic. Let's set up a modern Jest configuration from scratch.

Installation

If you're using Expo, the setup is straightforward:

npx expo install jest-expo jest @types/jest

For bare React Native projects:

npm install --save-dev jest @react-native/jest-preset @types/jest

Configuration

One of Jest 30's standout features is native TypeScript config support. You can now write your Jest configuration in TypeScript without any extra tooling, which is a nice quality-of-life improvement:

// jest.config.ts
import type { Config } from "jest";

const config: Config = {
  // For Expo projects:
  preset: "jest-expo",
  // For bare RN projects, use: preset: "@react-native/jest-preset"

  setupFilesAfterSetup: ["./jest.setup.ts"],

  transformIgnorePatterns: [
    "node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)/)",
  ],

  collectCoverageFrom: [
    "src/**/*.{ts,tsx}",
    "!src/**/*.d.ts",
    "!src/**/*.stories.{ts,tsx}",
    "!src/**/index.{ts,tsx}",
  ],

  coverageThresholds: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },

  testMatch: ["**/__tests__/**/*.test.{ts,tsx}", "**/*.test.{ts,tsx}"],
};

export default config;

The Setup File

Create a jest.setup.ts file to configure the testing environment and add common matchers:

// jest.setup.ts
import "@testing-library/react-native/extend-expect";

// Silence the warning: Animated: `useNativeDriver` is not supported
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");

// Mock AsyncStorage
jest.mock("@react-native-async-storage/async-storage", () =>
  require("@react-native-async-storage/async-storage/jest/async-storage-mock")
);

// Mock expo-router if you use it
jest.mock("expo-router", () => ({
  useRouter: () => ({
    push: jest.fn(),
    replace: jest.fn(),
    back: jest.fn(),
  }),
  useLocalSearchParams: () => ({}),
  useSegments: () => [],
  Link: "Link",
}));

Jest 30 Performance Improvements

Jest 30 brings some meaningful performance wins for React Native projects. If your project uses Node's native TypeScript type stripping (available in Node 22+), Jest automatically skips loading the TypeScript transformer for stripping types, resulting in faster test startup. Combined with improved module resolution caching, medium-to-large React Native projects should see roughly 15-30% faster test suite execution compared to Jest 29. That's not a trivial difference when you're running tests dozens of times a day.

Writing Unit Tests: Hooks, Utils, and Business Logic

Unit tests are where most of your test count should live. They're fast, focused, and easy to debug when they fail.

Testing Utility Functions

Start with pure functions — they're the simplest to test:

// src/utils/currency.ts
export function formatCurrency(amount: number, currency: string = "USD"): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
  }).format(amount);
}

export function calculateDiscount(price: number, percentage: number): number {
  if (percentage < 0 || percentage > 100) {
    throw new Error("Discount percentage must be between 0 and 100");
  }
  return price * (1 - percentage / 100);
}
// src/utils/__tests__/currency.test.ts
import { formatCurrency, calculateDiscount } from "../currency";

describe("formatCurrency", () => {
  it("formats USD by default", () => {
    expect(formatCurrency(19.99)).toBe("$19.99");
  });

  it("formats zero correctly", () => {
    expect(formatCurrency(0)).toBe("$0.00");
  });

  it("handles large numbers with commas", () => {
    expect(formatCurrency(1234567.89)).toBe("$1,234,567.89");
  });

  it("supports other currencies", () => {
    expect(formatCurrency(42.5, "EUR")).toContain("42.50");
  });
});

describe("calculateDiscount", () => {
  it("calculates percentage discount", () => {
    expect(calculateDiscount(100, 25)).toBe(75);
  });

  it("returns full price for 0% discount", () => {
    expect(calculateDiscount(49.99, 0)).toBe(49.99);
  });

  it("returns 0 for 100% discount", () => {
    expect(calculateDiscount(49.99, 100)).toBe(0);
  });

  it("throws for invalid discount percentage", () => {
    expect(() => calculateDiscount(100, -5)).toThrow(
      "Discount percentage must be between 0 and 100"
    );
    expect(() => calculateDiscount(100, 150)).toThrow();
  });
});

Testing Custom Hooks

Custom hooks require renderHook from React Native Testing Library. Here's how to test an async data-fetching hook:

// src/hooks/useProducts.ts
import { useState, useEffect } from "react";

interface Product {
  id: string;
  name: string;
  price: number;
}

export function useProducts(categoryId: string) {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchProducts() {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch(
          `https://api.example.com/products?category=${categoryId}`
        );
        if (!response.ok) throw new Error("Failed to fetch products");
        const data = await response.json();
        if (!cancelled) setProducts(data);
      } catch (err) {
        if (!cancelled) setError(err instanceof Error ? err.message : "Unknown error");
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    fetchProducts();
    return () => { cancelled = true; };
  }, [categoryId]);

  return { products, loading, error };
}
// src/hooks/__tests__/useProducts.test.ts
import { renderHook, waitFor } from "@testing-library/react-native";
import { useProducts } from "../useProducts";

const mockProducts = [
  { id: "1", name: "Widget", price: 9.99 },
  { id: "2", name: "Gadget", price: 19.99 },
];

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

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

it("fetches and returns products", async () => {
  (global.fetch as jest.Mock).mockResolvedValueOnce({
    ok: true,
    json: async () => mockProducts,
  });

  const { result } = renderHook(() => useProducts("electronics"));

  // Initially loading
  expect(result.current.loading).toBe(true);
  expect(result.current.products).toEqual([]);

  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  expect(result.current.products).toEqual(mockProducts);
  expect(result.current.error).toBeNull();
});

it("handles fetch errors", async () => {
  (global.fetch as jest.Mock).mockResolvedValueOnce({
    ok: false,
  });

  const { result } = renderHook(() => useProducts("electronics"));

  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  expect(result.current.error).toBe("Failed to fetch products");
  expect(result.current.products).toEqual([]);
});

it("refetches when categoryId changes", async () => {
  (global.fetch as jest.Mock).mockResolvedValue({
    ok: true,
    json: async () => mockProducts,
  });

  const { result, rerender } = renderHook(
    ({ categoryId }) => useProducts(categoryId),
    { initialProps: { categoryId: "electronics" } }
  );

  await waitFor(() => expect(result.current.loading).toBe(false));

  rerender({ categoryId: "clothing" });

  await waitFor(() => expect(result.current.loading).toBe(false));

  expect(global.fetch).toHaveBeenCalledTimes(2);
});

Component Testing with React Native Testing Library

React Native Testing Library (RNTL) is now the standard for component testing in React Native. It replaced the deprecated react-test-renderer, which doesn't support React 19. RNTL's philosophy is simple: test your components the way your users interact with them.

Installation

npm install --save-dev @testing-library/react-native

Make sure your jest.setup.ts includes:

import "@testing-library/react-native/extend-expect";

This adds custom matchers like toBeOnTheScreen(), toHaveTextContent(), and toBeVisible() to Jest.

The Screen Object and Queries

The modern RNTL approach uses the screen object for all queries. This is the recommended pattern over destructuring queries from the render return value:

import { render, screen } from "@testing-library/react-native";
import { ProductCard } from "../ProductCard";

it("displays product information", () => {
  render(
    
  );

  // Use screen.getByText, screen.getByRole, etc.
  expect(screen.getByText("Wireless Earbuds")).toBeOnTheScreen();
  expect(screen.getByText("$79.99")).toBeOnTheScreen();
  expect(screen.getByText("4.5")).toBeOnTheScreen();
  expect(screen.getByText("In Stock")).toBeOnTheScreen();
});

User Event API: Realistic Interaction Simulation

RNTL's User Event API is the recommended way to simulate user interactions. It's more realistic than the older fireEvent API because it triggers the complete sequence of events that React Native dispatches during real interactions:

import { render, screen } from "@testing-library/react-native";
import { userEvent } from "@testing-library/react-native";
import { LoginForm } from "../LoginForm";

it("submits login credentials", async () => {
  const onSubmit = jest.fn();
  const user = userEvent.setup();

  render();

  // Type into inputs using User Event (more realistic than fireEvent)
  await user.type(screen.getByPlaceholderText("Email"), "[email protected]");
  await user.type(screen.getByPlaceholderText("Password"), "securePass123");

  // Press the login button
  await user.press(screen.getByText("Log In"));

  expect(onSubmit).toHaveBeenCalledWith({
    email: "[email protected]",
    password: "securePass123",
  });
});

it("shows validation error for empty email", async () => {
  const user = userEvent.setup();

  render();

  // Submit without entering email
  await user.press(screen.getByText("Log In"));

  expect(screen.getByText("Email is required")).toBeOnTheScreen();
});

Testing Components with Navigation

Testing components that use Expo Router or React Navigation requires wrapping them in the appropriate context. Here's a practical pattern I've found works well across projects:

// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react-native";
import { NavigationContainer } from "@react-navigation/native";
import { ReactElement } from "react";

function AllProviders({ children }: { children: React.ReactNode }) {
  return (
    
      {children}
    
  );
}

export function renderWithProviders(
  ui: ReactElement,
  options?: Omit
) {
  return render(ui, { wrapper: AllProviders, ...options });
}

export * from "@testing-library/react-native";
export { renderWithProviders as render };

Testing Async Components

Many React Native components load data asynchronously. Use findBy queries (which return promises) or waitFor to handle async state changes:

import { render, screen, waitFor } from "@testing-library/react-native";
import { ProductList } from "../ProductList";

// Mock the API module
jest.mock("../../api/products", () => ({
  fetchProducts: jest.fn(),
}));

import { fetchProducts } from "../../api/products";

it("shows loading indicator then products", async () => {
  (fetchProducts as jest.Mock).mockResolvedValueOnce([
    { id: "1", name: "Widget", price: 9.99 },
    { id: "2", name: "Gadget", price: 19.99 },
  ]);

  render();

  // Loading state should appear first
  expect(screen.getByTestId("loading-spinner")).toBeOnTheScreen();

  // Products should appear after loading
  expect(await screen.findByText("Widget")).toBeOnTheScreen();
  expect(screen.getByText("Gadget")).toBeOnTheScreen();

  // Loading spinner should be gone
  expect(screen.queryByTestId("loading-spinner")).not.toBeOnTheScreen();
});

it("shows error message on fetch failure", async () => {
  (fetchProducts as jest.Mock).mockRejectedValueOnce(new Error("Network error"));

  render();

  expect(await screen.findByText(/something went wrong/i)).toBeOnTheScreen();
  expect(screen.getByText("Retry")).toBeOnTheScreen();
});

Query Priority: What to Use and When

RNTL provides multiple query types. Here's the order of preference I'd recommend:

  1. getByRole — Best for accessibility. Works with both built-in and custom roles: screen.getByRole("button", { name: "Submit" })
  2. getByText — Finds elements by their visible text content. This is how users identify elements, so it's a natural choice.
  3. getByPlaceholderText — Good for text inputs when they have placeholder text.
  4. getByDisplayValue — Useful for testing controlled inputs with pre-filled values.
  5. getByTestId — Last resort. Use when elements don't have accessible text or roles. Always prefer semantic queries first.

Each query type comes in three variants:

  • getBy — Throws immediately if not found. Use when the element should definitely exist.
  • queryBy — Returns null if not found. Use when asserting an element is absent.
  • findBy — Returns a Promise. Use when the element will appear after an async operation.

End-to-End Testing with Maestro

Maestro has quickly become the most popular E2E testing framework for React Native, and for good reason. It uses a declarative YAML-based syntax, handles automatic waiting and synchronization, and works cross-platform without needing separate scripts for iOS and Android. If you've ever struggled with Detox configuration or flaky Appium tests, Maestro feels like a breath of fresh air.

Installing Maestro

On macOS:

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

# Verify installation
maestro --version

On other platforms, follow the installation guide at the Maestro documentation site. Maestro requires either Android Studio (for Android emulators) or Xcode (for iOS simulators) to be installed.

Preparing Your React Native App for Maestro

Maestro can find elements by text, accessibility labels, or testID. Adding testID props to key interactive elements makes your tests way more resilient to text changes:

// LoginScreen.tsx
import { View, Text, TextInput, TouchableOpacity } from "react-native";

export function LoginScreen() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  return (
    
      Welcome Back

      

      

      
        Log In
      

      
        Forgot Password?
      
    
  );
}

Writing Your First Maestro Flow

Create a .maestro directory at your project root and add your first flow:

# .maestro/login-flow.yaml
appId: com.yourapp.id
---
- launchApp

# Wait for the login screen to appear
- assertVisible:
    id: "login-screen"

# Enter email
- tapOn:
    id: "email-input"
- inputText: "[email protected]"

# Enter password
- tapOn:
    id: "password-input"
- inputText: "SecurePass123"

# Tap login button
- tapOn:
    id: "login-button"

# Verify we reach the home screen
- assertVisible: "Welcome back"

Run it with:

maestro test .maestro/login-flow.yaml

That's it. No complicated driver setup, no page object boilerplate. Just a clean YAML file that reads almost like a user story.

Advanced Maestro Patterns

Real-world E2E tests need more than simple taps and assertions. Here are some patterns you'll reach for often:

Conditional Flows and Branching

# .maestro/onboarding-flow.yaml
appId: com.yourapp.id
---
- launchApp

# Handle notification permission prompt (may or may not appear)
- runFlow:
    when:
      visible: "Allow Notifications"
    commands:
      - tapOn: "Allow"

# Handle location permission
- runFlow:
    when:
      visible: "Allow .* to access your location"
    commands:
      - tapOn: "Allow While Using App"

# Continue with onboarding
- assertVisible: "Get Started"
- tapOn: "Get Started"

Scrolling and List Interactions

# .maestro/product-browse-flow.yaml
appId: com.yourapp.id
---
- launchApp

# Navigate to product catalog
- tapOn: "Shop"

# Scroll down to find a specific product
- scrollUntilVisible:
    element: "Premium Headphones"
    direction: DOWN
    timeout: 10000

# Tap on the product
- tapOn: "Premium Headphones"

# Verify product detail screen
- assertVisible: "Add to Cart"
- assertVisible: "$299.99"

Reusable Sub-Flows

Extract common sequences into reusable flows to keep your tests DRY:

# .maestro/helpers/login.yaml
appId: com.yourapp.id
---
- tapOn:
    id: "email-input"
- inputText: "${EMAIL}"
- tapOn:
    id: "password-input"
- inputText: "${PASSWORD}"
- tapOn:
    id: "login-button"
- assertVisible: "Welcome back"
# .maestro/checkout-flow.yaml
appId: com.yourapp.id
---
- launchApp

# Reuse the login helper
- runFlow:
    file: helpers/login.yaml
    env:
      EMAIL: "[email protected]"
      PASSWORD: "BuyerPass123"

# Now proceed to checkout
- tapOn: "Cart"
- tapOn: "Checkout"
- assertVisible: "Order Summary"

Maestro's Continuous Mode for Development

During development, Maestro's continuous mode is incredibly useful. It watches your flow files for changes and reruns tests automatically:

maestro test --continuous .maestro/

This gives you a rapid feedback loop — edit a flow, save, and see the results immediately on the simulator. It's similar to Jest's watch mode but for E2E tests. I've found it makes writing E2E tests feel almost as fast as writing unit tests.

Testing with Expo: Special Considerations

If you're using Expo (and in 2026, most new React Native projects probably should be), there are some specific things to keep in mind for testing.

The jest-expo Preset

The jest-expo preset handles most Expo-specific configuration automatically. It mocks native modules that would otherwise fail in a Node.js environment and sets up the correct transform pipeline for JSX and TypeScript:

// package.json
{
  "jest": {
    "preset": "jest-expo"
  }
}

If you need platform-specific test configurations, jest-expo provides sub-presets:

// jest.config.ts — platform-specific testing
import type { Config } from "jest";

const config: Config = {
  projects: [
    { preset: "jest-expo/ios", displayName: "iOS" },
    { preset: "jest-expo/android", displayName: "Android" },
  ],
};

export default config;

Mocking Expo Modules

Expo modules that access native APIs need mocking in tests. Here are the patterns you'll use most often:

// jest.setup.ts — Additional Expo mocks

// Mock expo-camera
jest.mock("expo-camera", () => ({
  Camera: "Camera",
  CameraType: { back: "back", front: "front" },
  useCameraPermissions: () => [
    { granted: true, status: "granted" },
    jest.fn(),
  ],
}));

// Mock expo-location
jest.mock("expo-location", () => ({
  requestForegroundPermissionsAsync: jest.fn().mockResolvedValue({
    status: "granted",
  }),
  getCurrentPositionAsync: jest.fn().mockResolvedValue({
    coords: { latitude: 37.7749, longitude: -122.4194 },
  }),
}));

// Mock expo-secure-store
jest.mock("expo-secure-store", () => {
  const store = new Map();
  return {
    setItemAsync: jest.fn((key: string, value: string) => {
      store.set(key, value);
      return Promise.resolve();
    }),
    getItemAsync: jest.fn((key: string) => {
      return Promise.resolve(store.get(key) ?? null);
    }),
    deleteItemAsync: jest.fn((key: string) => {
      store.delete(key);
      return Promise.resolve();
    }),
  };
});

Testing Expo Router Components

Testing components that rely on Expo Router requires mocking the router hooks. Here's a robust pattern for testing a screen with dynamic route params:

import { render, screen } from "@testing-library/react-native";
import { ProductDetailScreen } from "../ProductDetailScreen";

// Mock expo-router at the top of the file
jest.mock("expo-router", () => ({
  useLocalSearchParams: () => ({ id: "product-123" }),
  useRouter: () => ({
    push: jest.fn(),
    back: jest.fn(),
  }),
  Stack: {
    Screen: ({ options }: any) => null,
  },
}));

it("renders product details from route params", async () => {
  render();

  expect(await screen.findByText("Widget Pro")).toBeOnTheScreen();
  expect(screen.getByText("$49.99")).toBeOnTheScreen();
});

Mocking Strategies: When and How

Good mocking is essential for reliable React Native tests. But it's also one of the easiest things to get wrong. Here's a practical guide to the most common scenarios.

API Mocking with MSW

Mock Service Worker (MSW) intercepts network requests at the network level, making it the cleanest way to mock API calls in component tests:

// src/mocks/handlers.ts
import { http, HttpResponse } from "msw";

export const handlers = [
  http.get("https://api.example.com/products", () => {
    return HttpResponse.json([
      { id: "1", name: "Widget", price: 9.99 },
      { id: "2", name: "Gadget", price: 19.99 },
    ]);
  }),

  http.post("https://api.example.com/auth/login", async ({ request }) => {
    const body = await request.json() as { email: string; password: string };

    if (body.email === "[email protected]" && body.password === "password") {
      return HttpResponse.json({ token: "mock-jwt-token", user: { id: "1" } });
    }

    return HttpResponse.json(
      { message: "Invalid credentials" },
      { status: 401 }
    );
  }),
];
// src/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);
// jest.setup.ts — add MSW server lifecycle
import { server } from "./src/mocks/server";

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Module Mocking Best Practices

When mocking modules, here are the guidelines that have saved me the most headaches:

  • Mock at the boundary, not the internals. Mock the API client, not every internal function. Mock the storage layer, not individual read/write calls within your component.
  • Use jest.spyOn when you only need to observe. If you just want to verify a function was called without changing its behavior, spy on it rather than replacing it entirely.
  • Reset mocks between tests. Use afterEach(() => jest.restoreAllMocks()) to prevent mock state from leaking between tests. Trust me, this one will save you hours of debugging.
  • Prefer inline mocks for one-off overrides. If only one test needs a different mock value, use server.use() (for MSW) or .mockReturnValueOnce() inline rather than setting up a global mock.

Integrating Tests into CI/CD

Tests are only as valuable as how consistently they run. Let's set up a CI pipeline that runs all three layers automatically.

GitHub Actions Workflow

# .github/workflows/test.yml
name: Test Suite

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  unit-and-component-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"

      - run: npm ci

      - name: Run Jest tests
        run: npx jest --ci --coverage --maxWorkers=2

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

  e2e-tests:
    runs-on: macos-latest
    needs: unit-and-component-tests
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"

      - run: npm ci

      - name: Install Maestro
        run: |
          curl -Ls "https://get.maestro.mobile.dev" | bash
          echo "$HOME/.maestro/bin" >> $GITHUB_PATH

      - name: Build iOS app
        run: npx expo run:ios --configuration Release

      - name: Run Maestro E2E tests
        run: maestro test .maestro/

Expo EAS Workflows for E2E

If you're using Expo and EAS, you can run Maestro tests as part of your EAS Build or Workflows pipeline. Expo's documentation provides built-in support for running Maestro flows as part of EAS Workflows, which handles simulator management and build artifact caching for you. It's worth checking out if you're already in the Expo ecosystem.

Debugging Failing Tests

When tests fail (and they will), you need to diagnose the issue quickly. Here are the most effective debugging techniques for each layer.

Jest: Debugging Component Tests

RNTL provides a debug() function that prints the rendered component tree. This is invaluable when a query doesn't find the element you expect:

it("debugging example", () => {
  render();

  // Print the entire component tree
  screen.debug();

  // Print a specific subtree
  screen.debug(screen.getByTestId("container"));
});

You can also use the --verbose flag when running Jest to see individual test names and timing:

npx jest --verbose --detectOpenHandles

Maestro: Debugging E2E Flows

Maestro provides a built-in studio for interactive debugging:

# Launch Maestro Studio — interactive element inspector
maestro studio

Maestro Studio opens a web interface where you can visually inspect the current screen, see all identifiable elements with their IDs, text content, and accessibility labels, and interactively build test commands by clicking elements. It's far more productive than guessing at selectors.

You can also add - takeScreenshot commands in your YAML flows at any point to capture the screen state for debugging:

- tapOn: "Submit"
- takeScreenshot: "after-submit"
- assertVisible: "Success"

Common Pitfalls and How to Avoid Them

After working on testing across many React Native projects, certain mistakes come up again and again. Here's how to sidestep them:

1. Testing Implementation Details

Wrong: Checking that setState was called with specific arguments, or that an internal method was invoked. These tests break when you refactor without changing behavior.

Right: Test what the user sees. If clicking a button should show a success message, assert on the message text — not on the state variable that controls it.

2. Over-Mocking

Wrong: Mocking every import and dependency, then testing that mocks were called correctly. At that point, you're basically testing your mocks, not your code.

Right: Mock at the boundaries — network requests, native modules, and storage. Let everything in between run as real code.

3. Flaky Async Tests

Wrong: Using arbitrary setTimeout or fixed delays to wait for async operations.

Right: Use findBy queries or waitFor from RNTL, which poll until the condition is met or the timeout expires. In Maestro, the automatic waiting handles this for you.

4. Not Testing Error States

Wrong: Only testing the happy path — when everything works perfectly.

Right: Explicitly test error scenarios: network failures, validation errors, permission denials, empty data states, and edge cases. These are often where the worst user experiences hide.

5. Ignoring Accessibility in Tests

Wrong: Using getByTestId for everything because it's the easiest query.

Right: Prefer getByRole and getByText. If your component isn't queryable by these methods, it probably has accessibility issues that should be fixed anyway.

A Complete Testing Checklist

Before shipping any feature, run through this checklist:

  • Unit tests cover all utility functions, custom hooks, and business logic with edge cases
  • Component tests verify rendering, user interactions, loading states, error states, and empty states
  • E2E tests cover the critical user journey that this feature enables or modifies
  • All tests pass on both iOS and Android configurations
  • Coverage thresholds are met (aim for 80%+ on new code)
  • No flaky tests — run the suite a few times to make sure everything is stable
  • Mocks are realistic — mock data matches the shape and constraints of real API responses

Wrapping Up

Testing React Native apps in 2026 is more straightforward than it's ever been. The tooling has converged on a clear, well-supported stack: Jest 30 for test running, React Native Testing Library for component tests, and Maestro for end-to-end testing. Each tool does its job well, and they complement each other naturally.

The key insight is that testing isn't about achieving 100% coverage or writing tests for every single line of code. It's about building confidence that your app works correctly for real users. Focus your unit tests on business logic, your component tests on user-facing behavior, and your E2E tests on critical user journeys.

Start small if you need to — even a handful of well-written component tests and one or two Maestro flows for your most critical user paths will catch a surprising number of regressions. Then gradually expand your test suite as your app grows. The hardest part is getting started; once the infrastructure is in place and you see your first bug caught by a test before it reaches production, the value becomes immediately obvious.

About the Author Editorial Team

Our team of expert writers and editors.