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.
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/)
---
About the Author
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.
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
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.