# Lesson 2: Comprehensive React Native Testing Strategy
**Concepts**: Test-Driven Development, Jest, React Native Testing Library, Mocking, Test Coverage, Integration Testing, Unit Testing, Context Testing, Hook Testing
**Complexity**: Medium to High - Testing React Native requires understanding of components, contexts, hooks, async operations, and mobile-specific APIs
**Key Takeaway**: Comprehensive testing enables confident refactoring, AI-assisted development, and maintainable codebases. Testing isn't just about catching bugsβit's about documenting behavior and enabling change.
---
## 1. The Problem
The Delightful mobile app had **minimal test coverage** (11 test suites, 94 tests). This created several critical problems:
### Technical Risks
- **Regression fears**: Developers hesitated to refactor code, fearing unexpected breakage
- **Slow review cycles**: PRs required extensive manual testing of edge cases
- **Integration bugs**: Components worked in isolation but broke when combined
- **Undocumented behavior**: New developers couldn't understand expected behavior from code alone
### AI Development Blockers
- AI agents can't verify their changes work correctly
- No safety net for automated refactoring
- Complex logic (like filters) is opaque to AI understanding
- Breaking changes go undetected until runtime
### Real-World Impact
- Authentication edge cases weren't tested (What happens when the token expires during form submission?)
- Filter logic complexity made debugging nearly impossible
- New features broke existing workflows
- Mobile-specific issues (keyboard handling, native events) weren't tested
---
## 2. Technical Concepts
### 2.1 Testing Philosophy
#### The Testing Pyramid
```
/\
/ \ E2E Tests (Few, Slow, Expensive)
/----\
/ \ Integration Tests (Some, Medium)
/--------\
/ \ Unit Tests (Many, Fast, Cheap)
/------------\
```
**Unit Tests**: Test individual functions in isolation
- Example: `capitalizeFirstLetter('john')` β `'John'`
- Fast, focused, easy to debug
**Integration Tests**: Test how components work together
- Example: SearchBar + SearchContext + API hooks
- Medium speed, broader coverage
**E2E Tests**: Test complete user flows
- Example: Sign up β Login β Create review β View profile
- Slow, fragile, but high confidence
- *Note: We focused on unit and integration tests for this project*
### 2.2 React Native Testing Library
Unlike traditional DOM testing, React Native uses a **native component tree**. React Native Testing Library provides:
#### Query Methods
```typescript
// Find by text content
getByText('Login')
// Find by placeholder
getByPlaceholderText('Email')
// Find by test ID
getByTestId('submit-button')
// Find by role/type (uses UNSAFE_root)
UNSAFE_root.findAllByType('TextInput')
```
#### User Event Simulation
```typescript
// Simulate user typing
fireEvent.changeText(input, 'test@example.com')
// Simulate button press
fireEvent.press(button)
// Wait for async operations
await waitFor(() => {
expect(mockFunction).toHaveBeenCalled()
})
```
[...]
---
## 3. The Solution
We implemented a **comprehensive testing strategy** across 7 categories:
### Phase 1: Foundation - Utils & Core Logic (Priority 1)
**Goal**: Test pure functions firstβthey're easiest and provide immediate value
β
**Created**:
- `utils/auth.test.ts` - Authentication functions (7 tests, 100% coverage)
- `utils/api.test.tsx` - API layer with token refresh (10 tests, 97.61% coverage)
- `utils/text.test.ts` - Text utilities (26 tests, 100% coverage)
- `utils/time.test.ts` - Time formatting (15 tests, 100% coverage)
**Key Patterns**:
```typescript
// Testing async functions
it('should create user with capitalized name', async () => {
mockedAxios.post.mockResolvedValueOnce({ data: mockUser })
const result = await createUser({
first_name: 'john',
last_name: 'doe',
email: 'test@example.com',
password: 'password123',
})
expect(result.data.first_name).toBe('John')
expect(result.data.last_name).toBe('Doe')
})
// Testing error handling
it('should handle 401 and refresh token', async () => {
// First call fails with 401
mockedRequest.mockRejectedValueOnce({
response: { status: 401 }
})
// Refresh succeeds
mockedRefresh.mockResolvedValueOnce({
data: { access: 'new-token', refresh: 'new-refresh' }
})
// Retry succeeds
mockedRequest.mockResolvedValueOnce({ data: 'success' })
await wrappedRequest()
expect(mockAuthenticate).toHaveBeenCalledWith('new-token', 'new-refresh')
})
```
### Phase 2: UI Components (Priority 3)
**Goal**: Test reusable components in isolation
β
**Created**:
- `components/ui/__tests__/NewButton.test.tsx` - Button variants, states (9 tests)
- `components/ui/__tests__/IconButton.test.tsx` - Icon buttons (5 tests)
- `components/ui/__tests__/FlatButton.test.tsx` - Flat button styles (4 tests)
- `components/ui/__tests__/Link.test.tsx` - Navigation links (3 tests)
- `components/ui/__tests__/Text.test.tsx` - Text component (1 test)
- `components/ui/__tests__/SectionHeader.test.tsx` - Section headers (2 tests)
- `components/ui/__tests__/LoadingOverlay.test.tsx` - Loading states (1 test)
**Key Patterns**:
```typescript
// Testing component rendering
it('should render with text', () => {
const { getByText } = render(
<NewButton onPress={() => {}}>Click Me</NewButton>
)
expect(getByText('Click Me')).toBeTruthy()
})
// Testing variants
it('should handle secondary variant', () => {
const { getByText } = render(
<NewButton variant="secondary" onPress={() => {}}>
Secondary
</NewButton>
)
const button = getByText('Secondary').parent
expect(button?.props.style).toContainEqual(
expect.objectContaining({ backgroundColor: COLORS.SECONDARY })
)
})
// Testing disabled state
it('should not call onPress when disabled', () => {
const mockPress = jest.fn()
const { getByText } = render(
<NewButton disabled onPress={mockPress}>Press</NewButton>
)
fireEvent.press(getByText('Press'))
expect(mockPress).not.toHaveBeenCalled()
})
```
### Phase 3: Contexts - State Management (Priority 4)
**Goal**: Test global state and complex state transitions
β
**Created**:
- `contexts/__tests__/AuthContext.test.tsx` - Auth state (30 tests, 86.27%)
- `contexts/__tests__/ProductFiltersContext.test.tsx` - Filters (85 tests, 41.5%)
- `contexts/__tests__/SearchContext.test.tsx` - Search (7 tests, 87.5%)
- `contexts/__tests__/ProductListBottomSheetContext.test.tsx` - Bottom sheet (32 tests, 81.25%)
**Key Patterns**:
```typescript
// Testing context providers
it('should initialize with default state', () => {
const TestComponent = () => {
const { isAuthenticated } = useAuthContext()
return <Text>{isAuthenticated ? 'Yes' : 'No'}</Text>
}
const { getByText } = render(
<AuthProvider>
<TestComponent />
</AuthProvider>
)
expect(getByText('No')).toBeTruthy()
})
// Testing state updates
it('should update authentication state', () => {
const TestComponent = () => {
const { authenticate, isAuthenticated } = useAuthContext()
return (
<View>
<Text>{isAuthenticated ? 'Authenticated' : 'Not Authenticated'}</Text>
<Button onPress={() => authenticate('token', 'refresh', false)}>
Login
</Button>
</View>
)
}
const { getByText } = render(
<AuthProvider>
<TestComponent />
</AuthProvider>
)
expect(getByText('Not Authenticated')).toBeTruthy()
fireEvent.press(getByText('Login'))
expect(getByText('Authenticated')).toBeTruthy()
})
// Testing AsyncStorage persistence
it('should persist tokens to AsyncStorage', async () => {
const { result } = renderAuthProvider()
await act(async () => {
result.authenticate('access-token', 'refresh-token', false)
})
await waitFor(() => {
expect(AsyncStorage.setItem).toHaveBeenCalledWith('accessToken', 'access-token')
expect(AsyncStorage.setItem).toHaveBeenCalledWith('refreshToken', 'refresh-token')
})
})
```
### Phase 4: Data Hooks (Priority 5)
**Goal**: Test API integration and data fetching logic
β
**Created**:
- `hooks/data/__tests__/useFlavors.test.tsx` - Flavor fetching (2 tests, 100%)
- `hooks/data/__tests__/useProductsSearch.test.tsx` - Product search (45 tests, 92.85%)
**Key Patterns**:
```typescript
// Testing React Query hooks
it('should fetch products with filters', async () => {
const mockProducts = [
{ id: 1, name: 'Product 1', category: 'BEER' },
{ id: 2, name: 'Product 2', category: 'BEER' },
]
mockedUseQueryApi.mockReturnValue({
data: { results: mockProducts },
isLoading: false,
error: null,
})
const TestComponent = () => {
const { products } = useProductsSearch({ category: 'BEER' })
return (
<View>
{products.map(p => <Text key={p.id}>{p.name}</Text>)}
</View>
)
}
const { getByText } = render(<TestComponent />)
expect(getByText('Product 1')).toBeTruthy()
expect(getByText('Product 2')).toBeTruthy()
})
// Testing filter application
it('should apply multiple filters', () => {
const filters = {
category: 'BEER',
subcategories: ['IPA', 'LAGER'],
minRating: 4.0,
flavors: ['Hoppy', 'Crisp'],
}
const result = useProductsSearch(filters)
expect(mockedUseQueryApi).toHaveBeenCalledWith(
expect.stringContaining('category=BEER'),
expect.objectContaining({
enabled: true,
})
)
})
```
### Phase 5: Screens - User Flows (Priority 2)
**Goal**: Test critical user journeys
β
**Created**:
- `screens/unauthenticated/__tests__/WelcomeScreen.test.tsx` - Welcome flow (5 tests, 100%)
- `screens/unauthenticated/__tests__/EmailAuthScreen.test.tsx` - Auth flow (9 tests, 90.38%)
**Key Patterns**:
```typescript
// Testing form submission
it('should handle successful login', async () => {
const mockTokens = { access: 'token', refresh: 'refresh' }
mockedLogin.mockResolvedValueOnce({ data: mockTokens })
const { getByPlaceholderText, getByText } = render(
<EmailAuthScreen navigation={mockNav} route={loginRoute} />,
{ wrapper: AuthWrapper }
)
fireEvent.changeText(getByPlaceholderText('Email'), 'test@example.com')
fireEvent.changeText(getByPlaceholderText('Password'), 'password123')
fireEvent.press(getByText('Log In'))
await waitFor(() => {
expect(mockedLogin).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
})
expect(mockAuthContext.authenticate).toHaveBeenCalledWith(
'token',
'refresh',
false
)
})
})
// Testing error handling
it('should display error on failed login', async () => {
mockedLogin.mockRejectedValueOnce(new Error('Invalid credentials'))
const { getByPlaceholderText, getByText } = render(
<EmailAuthScreen navigation={mockNav} route={loginRoute} />,
{ wrapper: AuthWrapper }
)
fireEvent.changeText(getByPlaceholderText('Email'), 'wrong@example.com')
fireEvent.changeText(getByPlaceholderText('Password'), 'wrong')
fireEvent.press(getByText('Log In'))
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(
'Authentication Failed!',
expect.stringContaining('check your credentials')
)
})
})
// Testing mode switching
it('should switch between login and signup modes', async () => {
const { getByText, getByPlaceholderText } = render(
<EmailAuthScreen navigation={mockNav} route={loginRoute} />
)
fireEvent.press(getByText('Or create an account'))
await waitFor(() => {
expect(getByPlaceholderText('First Name')).toBeTruthy()
expect(getByPlaceholderText('Last Name')).toBeTruthy()
})
})
```
### Phase 6: Complex Components (Priority 6)
**Goal**: Test feature-specific components
β
**Created**:
- `screens/.../components/Flavors/__tests__/FlavorChip.test.tsx` - Flavor chips (4 tests, 100%)
- `screens/.../components/ProductSearch/__tests__/SearchBar.test.tsx` - Search bar (14 tests, 93.75%)
- `screens/.../components/ProductSearch/__tests__/ProductSearchTile.test.tsx` - Product tiles (8 tests, 100%)
- `screens/.../components/ProductSearch/__tests__/SubcategoryFilters.test.tsx` - Subcategory filters (51 tests, 100%)
**Key Patterns**:
```typescript
// Testing with context dependencies
const mockSearchContext = {
searchQuery: '',
setSearchQuery: jest.fn(),
clearSearch: jest.fn(),
}
const renderSearchBar = (contextValue = mockSearchContext) => {
return render(
<SearchContext.Provider value={contextValue}>
<SearchBar placeholderText="Search products" />
</SearchContext.Provider>
)
}
// Testing input changes
it('should update search query on text change', () => {
const { getByPlaceholderText } = renderSearchBar()
fireEvent.changeText(getByPlaceholderText('Search products'), 'IPA')
expect(mockSearchContext.setSearchQuery).toHaveBeenCalledWith('IPA')
})
// Testing debouncing/timing
it('should clear search when clear button pressed', () => {
const { getByTestId } = renderSearchBar({
...mockSearchContext,
searchQuery: 'existing query',
})
fireEvent.press(getByTestId('clear-search'))
expect(mockSearchContext.clearSearch).toHaveBeenCalled()
})
```
### Phase 7: Hooks - Custom Business Logic
**Goal**: Test non-data hooks
β
**Created**:
- `hooks/__tests__/useDeepLink.test.tsx` - Deep link handling (4 tests, 94.11%)
---
## 4. Before & After
### Before: Minimal Coverage
```
Test Suites: 11 passed, 11 total
Tests: 94 passed, 94 total
Coverage: ~20% (estimated, not formally measured)
```
**Problems**:
- No screen tests
- No component tests
- Minimal context tests
- No integration tests
- Manual testing required for every change
### After: Comprehensive Coverage
```
Test Suites: 24 passed, 24 total
Tests: 313 passed, 313 total
Coverage: 78.42% statements, 79.36% branches
Breakdown:
- Utils: 99.06% β
- Data Hooks: 93.22% β
- Tested Screens: 91.22% β
- Product Components: 96.29% β
- Contexts: 59.66% β οΈ
- UI Components: 64.40% β οΈ
```
**Benefits**:
- Fast test execution (<3s)
- Automated regression detection
- Documentation through tests
- Confident refactoring
- AI-friendly codebase
---
## 5. Impact Analysis
### Development Velocity
**Before**: Fear of breaking changes slowed development
**After**: Tests provide safety net for rapid iteration
### Code Quality
**Before**: Undocumented edge cases, hidden assumptions
**After**: Tests document expected behavior explicitly
### Onboarding
**Before**: New developers struggled to understand component interactions
**After**: Tests serve as executable documentation
### AI Development Readiness
**Before**: AI agents couldn't verify changes
**After**: AI can run tests to validate modifications
| Capability | Before | After |
|------------|--------|-------|
| Refactor utils | β Risky | β
Safe (99% coverage) |
| Modify API layer | β Manual testing | β
Automated (97% coverage) |
| Update filters | β Very risky | β οΈ Needs improvement (41%) |
| Change screens | β No tests | β οΈ Partial (2 screens tested) |
### Performance
- Test suite runs in <3 seconds
- Coverage report in <5 seconds
- No impact on bundle size (dev dependency)
### Maintenance
- Established patterns for future tests
- Consistent mocking strategy
- Reusable test utilities
---
## 6. Testing Strategy
### 6.1 Test Organization
```
component/
βββ Component.tsx
βββ __tests__/
βββ Component.test.tsx
```
**Benefits**:
- Easy to find related tests
- Clear ownership
- Encourages testing
### 6.2 What to Test
β
**Do Test**:
- User interactions (button clicks, text input)
- Conditional rendering (loading, error, success states)
- Props variations (different button variants)
- State changes (filter updates, auth state)
- Error handling (API failures, validation)
- Integration between components and contexts
β **Don't Test**:
- Implementation details (internal state variables)
- Third-party libraries (React Navigation, React Query)
- Styling (unless it affects functionality)
- Exact text copy (use `getByText(/pattern/i)` for flexibility)
### 6.3 Mock Strategy
#### Level 1: Native Modules (jest-setup.js)
```typescript
// Mock once, use everywhere
jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter')
jest.mock('@expo/vector-icons')
jest.mock('expo-constants')
```
#### Level 2: External Dependencies (test file)
```typescript
// Mock per test file
jest.mock('@/utils/auth')
jest.mock('axios')
jest.mock('@react-navigation/native')
```
#### Level 3: Internal Mocks (test case)
```typescript
// Mock per test case
beforeEach(() => {
mockedLogin.mockResolvedValue({ data: mockTokens })
})
afterEach(() => {
jest.clearAllMocks()
})
```
### 6.4 Testing Checklist
For each component/function:
- [ ] Renders correctly with default props
- [ ] Handles all prop variations
- [ ] Responds to user interactions
- [ ] Shows loading states
- [ ] Shows error states
- [ ] Shows empty states
- [ ] Handles edge cases (null, undefined, empty arrays)
- [ ] Integrates with contexts correctly
- [ ] Makes correct API calls
- [ ] Navigates correctly
---
## 7. Key Takeaways
### Specific to This Project
1. **Start with utils** - Pure functions are easiest to test and provide immediate value
2. **Mock early, mock consistently** - Establish mocking patterns in `jest-setup.js`
3. **Test behavior, not implementation** - Focus on what users experience
4. **Context wrappers are essential** - Most components depend on global state
5. **Async testing requires patience** - Use `waitFor()` liberally
### General Engineering Principles
1. **Tests are documentation** - Future developers (and AI) learn from tests
2. **Coverage isn't everything** - 78% coverage beats 100% of the wrong things
3. **Test the critical path first** - Auth and core features before edge cases
4. **Integration > Unit** - Component + Context tests catch more bugs than isolated unit tests
5. **Refactor with confidence** - Good tests let you change code fearlessly
### Mobile-Specific Insights
1. **Native APIs need mocking** - Keyboard, AsyncStorage, Push Notifications
2. **Event emitters are tricky** - Mock `NativeEventEmitter` to avoid warnings
3. **Navigation is context** - Mock navigation props and contexts separately
4. **Async is everywhere** - API calls, storage, native modules all require `waitFor()`
---
## 8. Further Reading
### React Native Testing Library
- [Official Docs](https://callstack.github.io/react-native-testing-library/)
- [Common Mistakes](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library)
- [Testing Playground](https://testing-playground.com/)
### Jest
- [Jest Documentation](https://jestjs.io/docs/getting-started)
- [Mocking Guide](https://jestjs.io/docs/mock-functions)
- [Async Testing](https://jestjs.io/docs/asynchronous)
### Testing Philosophy
- [Testing Trophy](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications) - Why integration tests matter
- [Write tests. Not too many. Mostly integration.](https://kentcdodds.com/blog/write-tests) - Guillermo Rauch's famous quote
- [Testing Implementation Details](https://kentcdodds.com/blog/testing-implementation-details) - What not to test
---
*"Code without tests is broken by design." - Jacob Kaplan-Moss*
*"The best time to write tests was when you wrote the code. The second best time is now."*