Testing Pyramid for React Native

📖 Concept

The testing pyramid adapted for React Native has unique considerations because you're testing across two runtimes (JS and Native) and need to mock platform APIs.

Testing levels (bottom to top):

                ▲
               / \
              / E2E \          Few, slow, brittle, high confidence
             / (Detox) \
            /───────────\
           / Integration  \    Some, moderate speed
          /  (Component +  \
         /   State + Navigation)
        /─────────────────────\
       /     Unit Tests         \  Many, fast, isolated
      / (Hooks, Utils, Logic)    \
     /──────────────────────────── \
    /        Static Analysis         \  Always, instant
   / (TypeScript, ESLint, Prettier)   \
  /─────────────────────────────────────\

Recommended ratios:

  • Unit tests: 60-70% of test suite
  • Integration tests: 20-30%
  • E2E tests: 5-10% (critical user journeys only)

What to test at each level:

Level What to Test Tools
Static Type errors, style, patterns TypeScript, ESLint
Unit Hooks, utils, reducers, selectors, services Jest, React Testing Library
Integration Component + hook interaction, screen rendering, navigation flows React Testing Library, MSW
E2E Critical user journeys: login → feed → action → verify Detox, Maestro

Key principle: Test behavior, not implementation. Users don't care that you used useState vs useReducer. They care that tapping "Add to Cart" adds an item and shows the correct count.

💻 Code Example

codeTap to expand ⛶
1// === UNIT TESTING: Custom Hooks ===
2
3// hooks/useDebounce.ts
4function useDebounce<T>(value: T, delay: number): T {
5 const [debouncedValue, setDebouncedValue] = useState(value);
6
7 useEffect(() => {
8 const timer = setTimeout(() => setDebouncedValue(value), delay);
9 return () => clearTimeout(timer);
10 }, [value, delay]);
11
12 return debouncedValue;
13}
14
15// hooks/__tests__/useDebounce.test.ts
16import { renderHook, act } from '@testing-library/react-hooks';
17
18describe('useDebounce', () => {
19 beforeEach(() => jest.useFakeTimers());
20 afterEach(() => jest.useRealTimers());
21
22 it('returns initial value immediately', () => {
23 const { result } = renderHook(() => useDebounce('hello', 500));
24 expect(result.current).toBe('hello');
25 });
26
27 it('debounces value changes', () => {
28 const { result, rerender } = renderHook(
29 ({ value, delay }) => useDebounce(value, delay),
30 { initialProps: { value: 'hello', delay: 500 } }
31 );
32
33 // Update value
34 rerender({ value: 'world', delay: 500 });
35
36 // Value hasn't changed yet
37 expect(result.current).toBe('hello');
38
39 // Fast-forward timer
40 act(() => jest.advanceTimersByTime(500));
41
42 // Now it's updated
43 expect(result.current).toBe('world');
44 });
45
46 it('cancels previous timer on rapid changes', () => {
47 const { result, rerender } = renderHook(
48 ({ value }) => useDebounce(value, 500),
49 { initialProps: { value: 'a' } }
50 );
51
52 rerender({ value: 'ab' });
53 act(() => jest.advanceTimersByTime(200));
54
55 rerender({ value: 'abc' }); // Resets the timer
56 act(() => jest.advanceTimersByTime(200));
57
58 expect(result.current).toBe('a'); // Still initial
59
60 act(() => jest.advanceTimersByTime(300)); // 500ms since 'abc'
61 expect(result.current).toBe('abc');
62 });
63});
64
65// === INTEGRATION TESTING: Component with API ===
66
67import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
68import { rest } from 'msw';
69import { setupServer } from 'msw/node';
70
71const server = setupServer(
72 rest.get('/api/products', (req, res, ctx) => {
73 return res(ctx.json([
74 { id: '1', name: 'Widget', price: 9.99 },
75 { id: '2', name: 'Gadget', price: 19.99 },
76 ]));
77 })
78);
79
80beforeAll(() => server.listen());
81afterEach(() => server.resetHandlers());
82afterAll(() => server.close());
83
84describe('ProductList', () => {
85 it('renders products from API', async () => {
86 render(<ProductList />);
87
88 // Shows loading state
89 expect(screen.getByTestId('loading-spinner')).toBeTruthy();
90
91 // Shows products after loading
92 await waitFor(() => {
93 expect(screen.getByText('Widget')).toBeTruthy();
94 expect(screen.getByText('Gadget')).toBeTruthy();
95 });
96 });
97
98 it('shows error state on API failure', async () => {
99 server.use(
100 rest.get('/api/products', (req, res, ctx) => {
101 return res(ctx.status(500));
102 })
103 );
104
105 render(<ProductList />);
106
107 await waitFor(() => {
108 expect(screen.getByText(/something went wrong/i)).toBeTruthy();
109 expect(screen.getByText(/retry/i)).toBeTruthy();
110 });
111 });
112
113 it('navigates to product detail on tap', async () => {
114 const mockNavigate = jest.fn();
115
116 render(
117 <NavigationContext.Provider value={{ navigate: mockNavigate }}>
118 <ProductList />
119 </NavigationContext.Provider>
120 );
121
122 await waitFor(() => screen.getByText('Widget'));
123
124 fireEvent.press(screen.getByText('Widget'));
125
126 expect(mockNavigate).toHaveBeenCalledWith('ProductDetail', { id: '1' });
127 });
128});
129
130// === E2E TESTING WITH DETOX ===
131
132// e2e/login.spec.ts
133describe('Login Flow', () => {
134 beforeAll(async () => {
135 await device.launchApp();
136 });
137
138 it('should login with valid credentials', async () => {
139 await element(by.id('email-input')).typeText('user@example.com');
140 await element(by.id('password-input')).typeText('password123');
141 await element(by.id('login-button')).tap();
142
143 await waitFor(element(by.id('home-screen')))
144 .toBeVisible()
145 .withTimeout(5000);
146
147 await expect(element(by.text('Welcome back'))).toBeVisible();
148 });
149
150 it('should show error for invalid credentials', async () => {
151 await element(by.id('email-input')).typeText('wrong@email.com');
152 await element(by.id('password-input')).typeText('wrongpassword');
153 await element(by.id('login-button')).tap();
154
155 await waitFor(element(by.text('Invalid credentials')))
156 .toBeVisible()
157 .withTimeout(3000);
158 });
159});

🏋️ Practice Exercise

Testing Exercises:

  1. Write unit tests for a custom useAsync hook covering loading, success, error, and cancellation states
  2. Write integration tests for a login form — mock the API, test success and failure flows
  3. Set up MSW (Mock Service Worker) for API mocking in tests
  4. Write a Detox E2E test for a critical user journey (signup → onboarding → first action)
  5. Mock a native module (e.g., react-native-camera) in Jest tests
  6. Set up a CI pipeline that runs unit tests on every PR and E2E tests on merge to main

⚠️ Common Mistakes

  • Testing implementation details instead of behavior — checking setState was called instead of verifying the UI changed

  • Not mocking native modules — tests crash with 'NativeModule.X is undefined'; set up jest.setup.js with mocks

  • Writing too many E2E tests — they're slow and brittle; focus on 5-10 critical user journeys only

  • Not using MSW for API mocking — manually mocking fetch is fragile and doesn't validate request shapes

  • Skipping the testing of error states — most bugs in production are in error handling code, which goes untested

💼 Interview Questions

🎤 Mock Interview

Mock interview is powered by AI for Testing Pyramid for React Native. Login to unlock this feature.