Back to Blog

React Native Testing in 2026: RNTL, Detox E2E, and CI for Mobile

Build a comprehensive React Native test suite: React Native Testing Library unit tests, Detox E2E automation, mocking native modules, and GitHub Actions CI for iOS and Android.

Viprasol Tech Team
August 6, 2026
13 min read

React Native Testing in 2026: RNTL, Detox E2E, and CI for Mobile

Testing React Native is harder than testing a web app. You have to mock native modules that only exist on device, deal with animations that don't run in Jest, and run E2E tests against actual simulators in CI. Most teams either skip testing entirely (bad) or spend weeks fighting infrastructure (also bad).

This post gives you a practical testing stack: React Native Testing Library for unit and integration tests, Detox for E2E, and a working GitHub Actions CI workflow that runs on iOS Simulator and Android Emulator.


Testing Pyramid for React Native

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚         E2E (Detox)             โ”‚  5% of tests, slow, high confidence
โ”‚   Full user flows on device     โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚   Integration (RNTL)            โ”‚  25% of tests, medium speed
โ”‚   Component trees + navigation  โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚   Unit (Jest)                   โ”‚  70% of tests, fast
โ”‚   Business logic, utils, hooks  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Rule: Never test implementation details. Test behavior from the user's perspective.


Jest and RNTL Setup

npx expo install jest-expo @testing-library/react-native @testing-library/jest-native
// jest.config.js
module.exports = {
  preset: 'jest-expo',
  setupFilesAfterFramework: [
    '@testing-library/jest-native/extend-expect',
    './jest.setup.ts',
  ],
  transformIgnorePatterns: [
    'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)',
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
  ],
  coverageThreshold: {
    global: { branches: 70, functions: 80, lines: 80, statements: 80 },
  },
};
// jest.setup.ts
import '@testing-library/jest-native/extend-expect';

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

// Mock react-native-reanimated (animations don't run in Jest)
jest.mock('react-native-reanimated', () => {
  const Reanimated = require('react-native-reanimated/mock');
  Reanimated.default.call = () => {};
  return Reanimated;
});

// Mock Expo modules
jest.mock('expo-secure-store', () => ({
  setItemAsync: jest.fn(),
  getItemAsync: jest.fn(),
  deleteItemAsync: jest.fn(),
}));

jest.mock('expo-haptics', () => ({
  impactAsync: jest.fn(),
  ImpactFeedbackStyle: { Light: 'light', Medium: 'medium', Heavy: 'heavy' },
}));

// Silence console.error for expected errors in tests
const originalConsoleError = console.error;
beforeAll(() => {
  console.error = (...args: unknown[]) => {
    if (typeof args[0] === 'string' && args[0].includes('Warning:')) return;
    originalConsoleError(...args);
  };
});
afterAll(() => { console.error = originalConsoleError; });

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Unit Tests: Business Logic and Custom Hooks

// src/hooks/__tests__/useCart.test.ts
import { renderHook, act } from '@testing-library/react-native';
import { useCart } from '../useCart';

const mockProduct = {
  id: 'prod-1',
  name: 'Test Product',
  price: 999,  // cents
  sku: 'SKU-001',
};

describe('useCart', () => {
  it('starts with an empty cart', () => {
    const { result } = renderHook(() => useCart());
    expect(result.current.items).toHaveLength(0);
    expect(result.current.totalCents).toBe(0);
  });

  it('adds an item to the cart', () => {
    const { result } = renderHook(() => useCart());

    act(() => {
      result.current.addItem(mockProduct, 2);
    });

    expect(result.current.items).toHaveLength(1);
    expect(result.current.items[0].quantity).toBe(2);
    expect(result.current.totalCents).toBe(1998);
  });

  it('increments quantity if item already in cart', () => {
    const { result } = renderHook(() => useCart());

    act(() => { result.current.addItem(mockProduct, 1); });
    act(() => { result.current.addItem(mockProduct, 1); });

    expect(result.current.items).toHaveLength(1);
    expect(result.current.items[0].quantity).toBe(2);
  });

  it('removes an item from the cart', () => {
    const { result } = renderHook(() => useCart());

    act(() => { result.current.addItem(mockProduct, 1); });
    act(() => { result.current.removeItem('prod-1'); });

    expect(result.current.items).toHaveLength(0);
  });

  it('calculates correct total with multiple items', () => {
    const { result } = renderHook(() => useCart());
    const product2 = { ...mockProduct, id: 'prod-2', price: 500 };

    act(() => {
      result.current.addItem(mockProduct, 3);  // 2997
      result.current.addItem(product2, 2);     // 1000
    });

    expect(result.current.totalCents).toBe(3997);
  });
});

Integration Tests: Component Trees with RNTL

// src/screens/__tests__/LoginScreen.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { LoginScreen } from '../LoginScreen';
import { AuthProvider } from '@/context/AuthContext';
import * as authService from '@/services/auth';

// Mock the auth service
jest.mock('@/services/auth');
const mockLogin = jest.mocked(authService.login);

// Wrapper with all required providers
const wrapper = ({ children }: { children: React.ReactNode }) => (
  <AuthProvider>{children}</AuthProvider>
);

describe('LoginScreen', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('renders email and password fields', () => {
    render(<LoginScreen />, { wrapper });

    expect(screen.getByPlaceholderText('Email')).toBeOnTheScreen();
    expect(screen.getByPlaceholderText('Password')).toBeOnTheScreen();
    expect(screen.getByRole('button', { name: /sign in/i })).toBeOnTheScreen();
  });

  it('shows validation error when email is empty', async () => {
    render(<LoginScreen />, { wrapper });

    fireEvent.press(screen.getByRole('button', { name: /sign in/i }));

    await waitFor(() => {
      expect(screen.getByText('Email is required')).toBeOnTheScreen();
    });

    expect(mockLogin).not.toHaveBeenCalled();
  });

  it('submits with valid credentials', async () => {
    mockLogin.mockResolvedValueOnce({ token: 'test-token', user: { id: '1', email: 'test@example.com' } });

    render(<LoginScreen />, { wrapper });

    fireEvent.changeText(screen.getByPlaceholderText('Email'), 'test@example.com');
    fireEvent.changeText(screen.getByPlaceholderText('Password'), 'password123');
    fireEvent.press(screen.getByRole('button', { name: /sign in/i }));

    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
      });
    });
  });

  it('shows error message on invalid credentials', async () => {
    mockLogin.mockRejectedValueOnce(new Error('Invalid credentials'));

    render(<LoginScreen />, { wrapper });

    fireEvent.changeText(screen.getByPlaceholderText('Email'), 'wrong@example.com');
    fireEvent.changeText(screen.getByPlaceholderText('Password'), 'wrongpass');
    fireEvent.press(screen.getByRole('button', { name: /sign in/i }));

    await waitFor(() => {
      expect(screen.getByText('Invalid credentials')).toBeOnTheScreen();
    });
  });

  it('disables button while loading', async () => {
    let resolveLogin: () => void;
    mockLogin.mockReturnValueOnce(new Promise((res) => { resolveLogin = () => res({} as any); }));

    render(<LoginScreen />, { wrapper });

    fireEvent.changeText(screen.getByPlaceholderText('Email'), 'test@example.com');
    fireEvent.changeText(screen.getByPlaceholderText('Password'), 'password123');
    fireEvent.press(screen.getByRole('button', { name: /sign in/i }));

    expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled();

    act(() => { resolveLogin!(); });
  });
});

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Mocking Native Modules

// src/__mocks__/react-native-camera.ts
// Mock a native module that requires device hardware
export const RNCamera = {
  capture: jest.fn().mockResolvedValue({ uri: 'file:///mock/photo.jpg' }),
  stopCapture: jest.fn(),
  Constants: {
    FlashMode: { auto: 'auto', on: 'on', off: 'off' },
    CameraType: { front: 'front', back: 'back' },
  },
};

// src/__mocks__/@stripe/stripe-react-native.ts
export const useStripe = jest.fn(() => ({
  initPaymentSheet: jest.fn().mockResolvedValue({ error: undefined }),
  presentPaymentSheet: jest.fn().mockResolvedValue({ error: undefined }),
}));

export const StripeProvider = ({ children }: { children: React.ReactNode }) => children;

// jest.config.js โ€” add module mapper
// moduleNameMapper: { 'react-native-camera': '<rootDir>/src/__mocks__/react-native-camera.ts' }

Detox E2E Tests

npm install -D detox @types/detox detox-cli
npx detox init -r jest
// .detoxrc.js
module.exports = {
  testRunner: {
    args: { '$0': 'jest', config: 'e2e/jest.config.js' },
    jest: { setupTimeout: 120000 },
  },
  apps: {
    'ios.debug': {
      type: 'ios.app',
      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app',
      build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
    },
    'android.debug': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
      build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
    },
  },
  devices: {
    simulator: {
      type: 'ios.simulator',
      device: { type: 'iPhone 16' },
    },
    emulator: {
      type: 'android.emulator',
      device: { avdName: 'Pixel_7_API_35' },
    },
  },
  configurations: {
    'ios.sim.debug':     { device: 'simulator', app: 'ios.debug' },
    'android.emu.debug': { device: 'emulator',  app: 'android.debug' },
  },
};
// e2e/login.test.ts
import { device, element, by, expect as detoxExpect } from 'detox';

describe('Login Flow', () => {
  beforeAll(async () => {
    await device.launchApp({ newInstance: true });
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('should log in with valid credentials', async () => {
    // Fill email
    await element(by.id('email-input')).tap();
    await element(by.id('email-input')).typeText('test@viprasol.com');

    // Fill password
    await element(by.id('password-input')).tap();
    await element(by.id('password-input')).typeText('TestPass123!');

    // Submit
    await element(by.id('login-button')).tap();

    // Verify navigation to dashboard
    await detoxExpect(element(by.id('dashboard-screen'))).toBeVisible();
  });

  it('should show error for invalid credentials', async () => {
    await element(by.id('email-input')).typeText('wrong@example.com');
    await element(by.id('password-input')).typeText('wrongpass');
    await element(by.id('login-button')).tap();

    await detoxExpect(element(by.text('Invalid credentials'))).toBeVisible();
  });

  it('should complete checkout flow', async () => {
    // Login first
    await element(by.id('email-input')).typeText('test@viprasol.com');
    await element(by.id('password-input')).typeText('TestPass123!');
    await element(by.id('login-button')).tap();

    // Navigate to a product
    await element(by.id('product-list')).scroll(200, 'down');
    await element(by.id('product-item-0')).tap();
    await element(by.id('add-to-cart-button')).tap();

    // Go to cart
    await element(by.id('cart-tab')).tap();
    await detoxExpect(element(by.id('cart-item-0'))).toBeVisible();

    // Proceed to checkout
    await element(by.id('checkout-button')).tap();
    await detoxExpect(element(by.id('payment-sheet'))).toBeVisible();
  });
});

GitHub Actions CI: iOS + Android

# .github/workflows/mobile-test.yml
name: Mobile Tests

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

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npx jest --coverage --ci
      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  e2e-ios:
    runs-on: macos-15    # Apple Silicon runners available 2026
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci

      - name: Install Detox dependencies
        run: |
          brew tap wix/brew
          brew install applesimutils

      - uses: actions/cache@v4
        with:
          path: ios/build
          key: ios-build-${{ hashFiles('ios/Podfile.lock') }}

      - name: Build iOS app
        run: npx detox build --configuration ios.sim.debug

      - name: Run Detox E2E tests
        run: npx detox test --configuration ios.sim.debug --cleanup --headless

  e2e-android:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - uses: actions/setup-java@v4
        with: { distribution: 'temurin', java-version: '17' }
      - run: npm ci

      - name: Enable KVM (hardware acceleration)
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: Start Android emulator
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 35
          arch: x86_64
          profile: pixel_7
          emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
          script: |
            npx detox build --configuration android.emu.debug
            npx detox test --configuration android.emu.debug --cleanup --headless

Testing Checklist

React Native Testing Checklist

Unit Tests (Jest)

  • Custom hooks: state changes, side effects
  • Utility functions: edge cases, error handling
  • API service functions: mock fetch, test error paths
  • Data transformers: normalization, validation

Integration Tests (RNTL)

  • Form validation: required fields, format validation
  • User flows: login, signup, checkout
  • Navigation: screen transitions, deep links
  • Loading/error/empty states

E2E Tests (Detox)

  • Critical happy paths (login โ†’ key feature)
  • Payment flow (mocked)
  • Onboarding flow
  • Logout and session handling

CI Requirements

  • Unit tests run on every PR
  • E2E tests run on merge to main
  • Coverage report uploaded
  • Failed tests block merge

---

## Working With Viprasol

We set up testing infrastructure for React Native apps โ€” from Jest configuration and RNTL test suites through Detox E2E and GitHub Actions CI pipelines.

**What we deliver:**
- Jest + RNTL setup with native module mocks and coverage reporting
- Detox E2E configuration for iOS Simulator and Android Emulator
- GitHub Actions CI workflow for automated mobile testing
- Test strategy review and coverage gap analysis
- Onboarding existing codebases to testing standards

โ†’ [Discuss your mobile testing needs](/contact)
โ†’ [Mobile development services](/services/web-development/)

---
Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.