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
1// === UNIT TESTING: Custom Hooks ===23// hooks/useDebounce.ts4function useDebounce<T>(value: T, delay: number): T {5 const [debouncedValue, setDebouncedValue] = useState(value);67 useEffect(() => {8 const timer = setTimeout(() => setDebouncedValue(value), delay);9 return () => clearTimeout(timer);10 }, [value, delay]);1112 return debouncedValue;13}1415// hooks/__tests__/useDebounce.test.ts16import { renderHook, act } from '@testing-library/react-hooks';1718describe('useDebounce', () => {19 beforeEach(() => jest.useFakeTimers());20 afterEach(() => jest.useRealTimers());2122 it('returns initial value immediately', () => {23 const { result } = renderHook(() => useDebounce('hello', 500));24 expect(result.current).toBe('hello');25 });2627 it('debounces value changes', () => {28 const { result, rerender } = renderHook(29 ({ value, delay }) => useDebounce(value, delay),30 { initialProps: { value: 'hello', delay: 500 } }31 );3233 // Update value34 rerender({ value: 'world', delay: 500 });3536 // Value hasn't changed yet37 expect(result.current).toBe('hello');3839 // Fast-forward timer40 act(() => jest.advanceTimersByTime(500));4142 // Now it's updated43 expect(result.current).toBe('world');44 });4546 it('cancels previous timer on rapid changes', () => {47 const { result, rerender } = renderHook(48 ({ value }) => useDebounce(value, 500),49 { initialProps: { value: 'a' } }50 );5152 rerender({ value: 'ab' });53 act(() => jest.advanceTimersByTime(200));5455 rerender({ value: 'abc' }); // Resets the timer56 act(() => jest.advanceTimersByTime(200));5758 expect(result.current).toBe('a'); // Still initial5960 act(() => jest.advanceTimersByTime(300)); // 500ms since 'abc'61 expect(result.current).toBe('abc');62 });63});6465// === INTEGRATION TESTING: Component with API ===6667import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';68import { rest } from 'msw';69import { setupServer } from 'msw/node';7071const 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);7980beforeAll(() => server.listen());81afterEach(() => server.resetHandlers());82afterAll(() => server.close());8384describe('ProductList', () => {85 it('renders products from API', async () => {86 render(<ProductList />);8788 // Shows loading state89 expect(screen.getByTestId('loading-spinner')).toBeTruthy();9091 // Shows products after loading92 await waitFor(() => {93 expect(screen.getByText('Widget')).toBeTruthy();94 expect(screen.getByText('Gadget')).toBeTruthy();95 });96 });9798 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 );104105 render(<ProductList />);106107 await waitFor(() => {108 expect(screen.getByText(/something went wrong/i)).toBeTruthy();109 expect(screen.getByText(/retry/i)).toBeTruthy();110 });111 });112113 it('navigates to product detail on tap', async () => {114 const mockNavigate = jest.fn();115116 render(117 <NavigationContext.Provider value={{ navigate: mockNavigate }}>118 <ProductList />119 </NavigationContext.Provider>120 );121122 await waitFor(() => screen.getByText('Widget'));123124 fireEvent.press(screen.getByText('Widget'));125126 expect(mockNavigate).toHaveBeenCalledWith('ProductDetail', { id: '1' });127 });128});129130// === E2E TESTING WITH DETOX ===131132// e2e/login.spec.ts133describe('Login Flow', () => {134 beforeAll(async () => {135 await device.launchApp();136 });137138 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();142143 await waitFor(element(by.id('home-screen')))144 .toBeVisible()145 .withTimeout(5000);146147 await expect(element(by.text('Welcome back'))).toBeVisible();148 });149150 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();154155 await waitFor(element(by.text('Invalid credentials')))156 .toBeVisible()157 .withTimeout(3000);158 });159});
🏋️ Practice Exercise
Testing Exercises:
- Write unit tests for a custom
useAsynchook covering loading, success, error, and cancellation states - Write integration tests for a login form — mock the API, test success and failure flows
- Set up MSW (Mock Service Worker) for API mocking in tests
- Write a Detox E2E test for a critical user journey (signup → onboarding → first action)
- Mock a native module (e.g., react-native-camera) in Jest tests
- 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.