From react-native-plugin
Testing React Native: Jest with jest-expo / react-native preset, React Testing Library Native, native module mocking, hook testing. Optional sections for Detox (native automation) and Maestro (declarative YAML e2e). Use this skill to: - Configure Jest preset based on workflow. - Write component tests with @testing-library/react-native. - Mock native modules (react-native-reanimated, expo-*, react-native-mmkv). - Test custom hooks via renderHook. - Set up Detox or Maestro for e2e (optional, requires native toolchain). Do NOT use this skill for: - General RN conventions (see rn-conventions). - Web-React testing (see react-plugin:react-testing — different jsdom setup). - Plain Node testing patterns (see nodejs-plugin equivalents).
How this skill is triggered — by the user, by Claude, or both
Slash command
/react-native-plugin:rn-testingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
| Layer | Framework |
| Layer | Framework |
|---|---|
| Component, hook, plain TS unit | Jest with jest-expo (Expo) or react-native preset (bare) |
| End-to-end (optional) | Detox (native automation) or Maestro (declarative YAML) |
Vitest is uncommon in RN — Jest's native module mocking and Metro integration is more battle-tested. If a project specifically uses Vitest in RN, follow it; otherwise default to Jest.
// jest.config.js
module.exports = {
preset: 'jest-expo',
setupFilesAfterEach: ['<rootDir>/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)/)',
],
};
Install: pnpm add -D jest jest-expo @testing-library/react-native @testing-library/jest-native.
// jest.config.js
module.exports = {
preset: 'react-native',
setupFilesAfterEach: ['<rootDir>/jest.setup.ts'],
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?|@react-navigation/.*|react-native-mmkv|react-native-reanimated)/)',
],
};
Install: pnpm add -D jest @react-native/babel-preset @testing-library/react-native @testing-library/jest-native.
jest.setup.tsimport '@testing-library/jest-native/extend-expect';
// Common mocks for native modules
jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);
// Silence common warnings
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
// src/components/UserCard.test.tsx
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react-native';
import { UserCard } from './UserCard';
describe('UserCard', () => {
it('renders user name and email', () => {
render(<UserCard user={{ id: '1', name: 'Alice', email: '[email protected]' }} />);
expect(screen.getByText('Alice')).toBeOnTheScreen();
expect(screen.getByText('[email protected]')).toBeOnTheScreen();
});
it('calls onPress when card pressed', () => {
const onPress = jest.fn();
render(<UserCard user={{ id: '1', name: 'Alice', email: '[email protected]' }} onPress={onPress} />);
fireEvent.press(screen.getByLabelText('User card for Alice'));
expect(onPress).toHaveBeenCalledWith('1');
});
});
getByLabelText — accessibility labels (most stable on RN).getByRole(role, { name }) — works for Pressable, TextInput, etc.; weaker than web due to less consistent role mapping.getByText — for <Text> content.getByPlaceholderText — TextInput placeholders.getByDisplayValue — TextInput current value.getByTestId — last resort (use testID prop on RN components).fireEvent.press is the RN equivalent of userEvent.click. For text input:
fireEvent.changeText(screen.getByLabelText('Email'), '[email protected]');
For scroll, swipe, gesture interactions: more complex setup, often easier in e2e.
// react-native-mmkv
jest.mock('react-native-mmkv', () => ({
MMKV: jest.fn().mockImplementation(() => ({
set: jest.fn(),
getString: jest.fn(),
delete: jest.fn(),
contains: jest.fn(),
})),
}));
// expo-secure-store
jest.mock('expo-secure-store', () => ({
getItemAsync: jest.fn().mockResolvedValue(null),
setItemAsync: jest.fn().mockResolvedValue(undefined),
deleteItemAsync: jest.fn().mockResolvedValue(undefined),
}));
// react-native-keychain
jest.mock('react-native-keychain', () => ({
setGenericPassword: jest.fn().mockResolvedValue(undefined),
getGenericPassword: jest.fn().mockResolvedValue(false),
resetGenericPassword: jest.fn().mockResolvedValue(undefined),
}));
// expo-file-system
jest.mock('expo-file-system', () => ({
documentDirectory: '/test/',
readAsStringAsync: jest.fn(),
writeAsStringAsync: jest.fn(),
}));
jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));
The official mock disables animations and provides synchronous values — usually what tests want.
jest.mock('react-native-gesture-handler', () => {
const View = require('react-native').View;
return {
Swipeable: View,
DrawerLayout: View,
State: {},
ScrollView: View,
PanGestureHandler: View,
BaseButton: View,
Directions: {},
};
});
Or import from react-native-gesture-handler/jestSetup if shipped.
const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: mockNavigate, goBack: jest.fn() }),
useRoute: () => ({ params: { userId: '123' } }),
}));
For Expo Router:
jest.mock('expo-router', () => ({
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), back: jest.fn() }),
useLocalSearchParams: () => ({ id: '123' }),
}));
// src/hooks/useDebounce.test.ts
import { renderHook, act } from '@testing-library/react-native';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
it('debounces value updates', () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
initialProps: { value: 'a' },
});
expect(result.current).toBe('a');
rerender({ value: 'b' });
expect(result.current).toBe('a');
act(() => { jest.advanceTimersByTime(300); });
expect(result.current).toBe('b');
jest.useRealTimers();
});
});
For RN < 0.71, use @testing-library/react-hooks instead.
msw works in RN tests via the jsdom polyfill in jest-expo:
// jest.setup.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example.com/users', () =>
HttpResponse.json([{ id: '1', name: 'Alice' }])
)
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
export { server };
Per-test override:
import { http, HttpResponse } from 'msw';
server.use(http.get('https://api.example.com/users', () => HttpResponse.error()));
import { render } from '@testing-library/react-native';
import { UserCard } from './UserCard';
it('matches snapshot', () => {
const { toJSON } = render(<UserCard user={{ id: '1', name: 'Alice', email: '[email protected]' }} />);
expect(toJSON()).toMatchSnapshot();
});
RN snapshot trees are LARGE — 500+ lines per component is normal. Review noise outweighs value for most components. Use only for stable design-system primitives.
Prefer toMatchInlineSnapshot() for small expected outputs — keeps them in test file for easy review.
Target ≥80% on:
lib/).Skip / lower bar:
navigation/*).App.tsx / root layout.// jest.config.js
module.exports = {
preset: 'jest-expo',
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/navigation/**',
'!src/**/*.types.ts',
'!src/App.tsx',
'!**/node_modules/**',
],
};
Native automation framework. Drives real iOS Simulator / Android Emulator. Higher fidelity, higher setup cost.
// package.json
"devDependencies": { "detox": "^20.x" }
"detox": { "configurations": {...} }
If detox not in deps, skip this section.
pnpm add -D detox @config-plugins/detox..detoxrc.js with iOS Simulator / Android Emulator targets.detox build --configuration ios.sim.debug.detox test --configuration ios.sim.debug.Requires:
import { device, element, by, expect } from 'detox';
describe('Login flow', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
it('user can log in and reach dashboard', async () => {
await element(by.id('login-email')).typeText('[email protected]');
await element(by.id('login-password')).typeText('longenough');
await element(by.id('login-submit')).tap();
await expect(element(by.id('dashboard-title'))).toBeVisible();
});
});
Async assertions are first-class: toBeVisible, toExist, toHaveText, toHaveLabel.
testID props on RN components map to Detox's by.id(). Use accessibility labels for by.label().
For most projects, Detox is overkill. Use it when:
Declarative YAML-based e2e. Simpler than Detox, less flexible.
If maestro CLI is installed (no npm package required) and .maestro/ folder exists with YAML flows.
# .maestro/login.yaml
appId: com.example.myapp
---
- launchApp
- tapOn:
id: "login-email"
- inputText: "[email protected]"
- tapOn:
id: "login-password"
- inputText: "longenough"
- tapOn:
id: "login-submit"
- assertVisible:
id: "dashboard-title"
Run: maestro test .maestro/login.yaml.
| Aspect | Maestro | Detox |
|---|---|---|
| Test format | YAML | JavaScript/TypeScript |
| Setup complexity | Low (CLI install) | High (native build config) |
| Flexibility | Limited (declarative) | High (full programmability) |
| Speed | Fast | Slow |
| Custom logic | Limited (JS expressions) | Full Node access |
| CI integration | Easy | Mac runners required for iOS |
Use Maestro for smoke flows; use Detox when you need programmability.
The qa-engineer agent has a hard 3-attempt cap on fixing failing tests. Mark genuinely flaky tests it.skip(...) with a comment after attempt #3 — RN tests are particularly prone to flakiness from native module mocks.
transformIgnorePatterns — RN packages ship ES modules, Jest barfs without transform.getByTestId everywhere instead of accessible queries.jest.useFakeTimers() for animation/debounce tests — real timers slow tests by 100x.beforeEach(() => jest.clearAllMocks()) — state leaks between tests.npx claudepluginhub aratkruglik/claude-sdlc --plugin react-native-pluginGuides Test-Driven Development for React Native using Jest (jest-expo) and @testing-library/react-native. Use before implementing features, bugfixes, or refactors.
Tests React Native apps with Jest, React Native Testing Library, and Detox for unit, integration, and E2E coverage. Covers mocking native modules, navigation, and async storage.
Guides test-driven development workflow for React Native using Jest, React Native Testing Library, and Detox in Red-Green-Refactor cycle. For new features, bug fixes, refactoring.