Learning by Doing in the Age of LLMs

2 weeks ago 1

# 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, '[email protected]') // 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: '[email protected]', 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'), '[email protected]') fireEvent.changeText(getByPlaceholderText('Password'), 'password123') fireEvent.press(getByText('Log In')) await waitFor(() => { expect(mockedLogin).toHaveBeenCalledWith({ email: '[email protected]', 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'), '[email protected]') 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."*

Read Entire Article