test-driven mobile development: from theory to practice
test-driven development (tdd) isn't just a testing methodology—it's a design philosophy that fundamentally changes how you build mobile applications. by writing tests before implementation, developers create more maintainable code, reduce bugs, and build features with clearer intent. in this comprehensive guide, we'll explore practical approaches to implementing tdd in your react native projects, moving beyond theory to actionable techniques.
understanding the test-driven development cycle
the core of tdd follows the red-green-refactor cycle, a methodology pioneered by Kent Beck and further refined in Martin Fowler's detailed explanation. this disciplined approach consists of three distinct phases:
// Phase 1: Red - Write a failing test that defines the functionality you want describe('AuthService', () => { it('should authenticate valid user credentials', async () => { // Arrange const authService = new AuthService(apiClient) const validCredentials = { email: 'test@example.com', password: 'correct-password' } // Act const result = await authService.login(validCredentials.email, validCredentials.password) // Assert expect(result.success).toBe(true) expect(result.user).toBeDefined() expect(result.user.email).toBe(validCredentials.email) expect(result.token).toBeDefined() }) it('should reject invalid credentials', async () => { // Arrange const authService = new AuthService(apiClient) // Act const result = await authService.login('test@example.com', 'wrong-password') // Assert expect(result.success).toBe(false) expect(result.error).toBe('Invalid credentials') expect(result.user).toBeUndefined() }) }) // Phase 2: Green - Write minimal code to make the test pass class AuthService { constructor(private api) {} async login(email: string, password: string) { try { // Simplified implementation to make tests pass if (email === 'test@example.com' && password === 'correct-password') { return { success: true, user: { email }, token: 'fake-jwt-token' } } else { return { success: false, error: 'Invalid credentials' } } } catch (error) { return { success: false, error: 'Server error' } } } } // Phase 3: Refactor - Improve code without changing functionality class AuthService { constructor(private api) {} async login(email: string, password: string) { try { // Input validation first if (!this.isValidEmail(email)) { return { success: false, error: 'Invalid email format' } } // Real API call const response = await this.api.post('/auth/login', { email, password }) // Process successful response return { success: true, user: response.data.user, token: response.data.token } } catch (error) { // Handle different error types if (error.response && error.response.status === 401) { return { success: false, error: 'Invalid credentials' } } // Generic error handling return { success: false, error: error.message || 'Authentication failed' } } } private isValidEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) } }
according to research by Microsoft and IBM, tdd can reduce defect rates by 40-80% while only increasing development time by 15-35%, an investment that pays dividends throughout the application lifecycle.
structuring your testing strategy: the testing pyramid
the testing pyramid provides a framework for balancing different types of tests. first conceptualized by Mike Cohn and later expanded upon by Martin Fowler, this approach helps allocate testing efforts effectively across your mobile application.
1. unit tests: the foundation
unit tests form the base of your testing strategy, focusing on individual functions, methods, and classes. they should:
// Pure utility function test describe('formatCurrency', () => { it('should format USD correctly', () => { // Test multiple scenarios expect(formatCurrency(10.99, 'USD')).toBe('$10.99') expect(formatCurrency(1000, 'USD')).toBe('$1,000.00') expect(formatCurrency(0, 'USD')).toBe('$0.00') }) it('should format EUR correctly', () => { expect(formatCurrency(10.99, 'EUR')).toBe('€10.99') expect(formatCurrency(1000, 'EUR')).toBe('€1,000.00') }) it('should handle null and undefined values', () => { expect(formatCurrency(null, 'USD')).toBe('$0.00') expect(formatCurrency(undefined, 'EUR')).toBe('€0.00') }) it('should handle negative values', () => { expect(formatCurrency(-50.25, 'USD')).toBe('-$50.25') }) }) // Business logic test describe('CartManager', () => { let cartManager: CartManager beforeEach(() => { cartManager = new CartManager() }) it('should calculate correct total', () => { // Add items to cart cartManager.addItem({ id: '1', name: 'Product 1', price: 10.99, quantity: 1 }) cartManager.addItem({ id: '2', name: 'Product 2', price: 5.99, quantity: 2 }) // Verify calculations expect(cartManager.getSubtotal()).toBeCloseTo(22.97) expect(cartManager.getTaxAmount(0.08)).toBeCloseTo(1.84) expect(cartManager.getTotal(0.08)).toBeCloseTo(24.81) }) it('should update existing item quantity', () => { // Add item cartManager.addItem({ id: '1', name: 'Product 1', price: 10.99, quantity: 1 }) // Add same item again cartManager.addItem({ id: '1', name: 'Product 1', price: 10.99, quantity: 1 }) // Verify quantity updated instead of adding duplicate expect(cartManager.getItems()).toHaveLength(1) expect(cartManager.getItems()[0].quantity).toBe(2) expect(cartManager.getSubtotal()).toBeCloseTo(21.98) }) it('should remove items correctly', () => { // Setup cartManager.addItem({ id: '1', name: 'Product 1', price: 10.99, quantity: 1 }) // Act cartManager.removeItem('1') // Assert expect(cartManager.getItems()).toHaveLength(0) expect(cartManager.getSubtotal()).toBe(0) }) })
2. integration tests: connecting the dots
integration tests verify that components work together correctly. according to a study by Google, a balanced mixture of unit and integration tests provides the best return on investment. for react native, this typically means testing connected components and hooks:
// Testing a component that uses a custom hook import { render, fireEvent, waitFor } from '@testing-library/react-native' import { useCart } from '../hooks/useCart' import ProductScreen from '../screens/ProductScreen' // Mock the custom hook jest.mock('../hooks/useCart', () => ({ useCart: jest.fn() })) describe('ProductScreen', () => { // Setup mock implementation const mockAddToCart = jest.fn() beforeEach(() => { useCart.mockReturnValue({ items: [], addItem: mockAddToCart, totalItems: 0 }) }) it('should add product to cart with correct quantity', async () => { // Arrange const product = { id: '123', name: 'Test Product', price: 29.99, description: 'This is a test product', image: 'https://example.com/image.jpg' } const { getByText, getByTestId } = render( <ProductScreen product={product} /> ) // Act - increase quantity and add to cart fireEvent.press(getByTestId('increase-quantity')) fireEvent.press(getByTestId('increase-quantity')) fireEvent.press(getByText('Add to Cart')) // Assert - default quantity is 1, plus 2 increases = 3 expect(mockAddToCart).toHaveBeenCalledWith({ ...product, quantity: 3 }) }) it('should display error message when product is out of stock', async () => { // Arrange const product = { id: '123', name: 'Test Product', price: 29.99, description: 'This is a test product', image: 'https://example.com/image.jpg', inStock: false } const { getByText, queryByText } = render( <ProductScreen product={product} /> ) // Assert expect(getByText('Out of Stock')).toBeDefined() expect(queryByText('Add to Cart')).toBeNull() }) })
3. end-to-end tests: the user perspective
e2e tests, while fewer in number, provide critical validation of complete user journeys. for react native, Detox and Appium are industry standards, as recommended by the React Native Testing documentation:
// Using Detox for E2E testing describe('Shopping Cart Flow', () => { beforeAll(async () => { await device.launchApp({ newInstance: true, permissions: { notifications: 'YES' } }) }) beforeEach(async () => { await device.reloadReactNative() }) it('should complete the checkout process', async () => { // Navigate to products await element(by.text('Products')).tap() // Find and select a product await element(by.text('Wireless Headphones')).tap() // Add to cart await element(by.id('add-to-cart-button')).tap() // Verify cart badge updated await expect(element(by.id('cart-badge'))).toHaveText('1') // Go to cart await element(by.id('cart-icon')).tap() // Proceed to checkout await element(by.text('Proceed to Checkout')).tap() // Fill shipping information await element(by.id('name-input')).typeText('John Doe') await element(by.id('address-input')).typeText('123 Test St') await element(by.id('city-input')).typeText('Test City') await element(by.id('zip-input')).typeText('12345') await element(by.text('Continue')).tap() // Fill payment information (using test mode) await element(by.id('card-number')).typeText('4242424242424242') await element(by.id('card-expiry')).typeText('1225') await element(by.id('card-cvc')).typeText('123') await element(by.text('Complete Order')).tap() // Verify order confirmation await expect(element(by.text('Order Confirmed'))).toBeVisible() await expect(element(by.text('Thank you for your purchase'))).toBeVisible() // Verify return to shopping await element(by.text('Continue Shopping')).tap() await expect(element(by.text('Products'))).toBeVisible() }) })
configuring the ideal testing environment for react native
setting up a robust testing environment is crucial for efficient tdd in react native. according to State of JS 2023, jest remains the most widely used javascript testing framework, with react testing library providing specialized tools for component testing.
// jest.config.js module.exports = { preset: 'react-native', // Support TypeScript moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], // Transform setup transform: { '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', }, // Extensions for RNTL setupFilesAfterEnv: [ '@testing-library/jest-native/extend-expect', './jest.setup.js' ], // Handle dependencies that may cause issues transformIgnorePatterns: [ 'node_modules/(?!(react-native|@react-native|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|expo|@expo/.*|react-native-.*)/)' ], // Mock file imports moduleNameMapper: { '\\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/__mocks__/fileMock.js', '\\.(css|less)$': '<rootDir>/__mocks__/styleMock.js', }, // Coverage settings collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts', '!src/index.{js,jsx,ts,tsx}', '!src/serviceWorker.{js,jsx,ts,tsx}' ], coverageThreshold: { global: { branches: 70, functions: 70, lines: 70, statements: 70 } } } // jest.setup.js import 'react-native-gesture-handler/jestSetup' // Mock native modules jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper') jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter') // Mock AsyncStorage jest.mock('@react-native-async-storage/async-storage', () => require('@react-native-async-storage/async-storage/jest/async-storage-mock') ) // Silence warning logs during tests jest.spyOn(console, 'warn').mockImplementation(() => {})
advanced mocking strategies for external dependencies
effective mocking is essential for isolating your tests. the jest documentation provides comprehensive guidance, but here are some react native-specific patterns:
// 1. Mocking API services // api.ts export const api = { get: (url) => Promise.resolve({}), post: (url, data) => Promise.resolve({}), put: (url, data) => Promise.resolve({}), delete: (url) => Promise.resolve({}) } // In tests import { api } from '../api' jest.mock('../api', () => ({ api: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() } })) describe('ProductService', () => { beforeEach(() => { // Clear mocks between tests jest.clearAllMocks() }) it('should fetch products correctly', async () => { // Arrange const mockProducts = [{ id: '1', name: 'Test Product' }] api.get.mockResolvedValueOnce({ data: mockProducts }) const productService = new ProductService(api) // Act const result = await productService.getProducts() // Assert expect(api.get).toHaveBeenCalledWith('/products') expect(result).toEqual(mockProducts) }) }) // 2. Mocking React Navigation jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native') return { ...actualNav, useNavigation: () => ({ navigate: jest.fn(), goBack: jest.fn() }), useRoute: () => ({ params: { productId: '123' } }) } }) // 3. Mocking device features and native modules jest.mock('react-native-geolocation-service', () => ({ getCurrentPosition: jest.fn((successCallback) => { successCallback({ coords: { latitude: 37.7749, longitude: -122.4194, accuracy: 5 } }) }), watchPosition: jest.fn(), clearWatch: jest.fn(), stopObserving: jest.fn() })) // 4. Mocking global context providers const mockAuthContext = { user: { id: '123', email: 'test@example.com' }, isAuthenticated: true, login: jest.fn(), logout: jest.fn(), loading: false } jest.mock('../contexts/AuthContext', () => ({ useAuth: () => mockAuthContext, AuthProvider: ({ children }) => children })) // 5. Creating a reusable mock factory const createMockStore = (initialState = {}) => { // Default state const defaultState = { auth: { user: null, isAuthenticated: false, loading: false }, products: { items: [], loading: false, error: null }, cart: { items: [], total: 0 } } // Merge with provided state const state = { ...defaultState, ...initialState } // Create mock actions const actions = { login: jest.fn(() => Promise.resolve({ success: true })), logout: jest.fn(), fetchProducts: jest.fn(() => Promise.resolve([])), addToCart: jest.fn() } return { state, actions } } // Usage in tests const { state, actions } = createMockStore({ auth: { isAuthenticated: true } })
component testing strategies with react native testing library
the react native testing library (rntl) has become the standard for component testing, encouraging tests that mirror user behavior:
import { render, fireEvent, waitFor } from '@testing-library/react-native' import LoginScreen from '../screens/LoginScreen' describe('LoginScreen', () => { // 1. Testing form validation it('should display validation errors for empty inputs', async () => { // Arrange const mockLogin = jest.fn() const { getByText, queryByText } = render(<LoginScreen onLogin={mockLogin} />) // Act - submit without entering data fireEvent.press(getByText('Sign In')) // Assert - validation errors appear await waitFor(() => { expect(getByText('Email is required')).toBeDefined() expect(getByText('Password is required')).toBeDefined() }) expect(mockLogin).not.toHaveBeenCalled() }) // 2. Testing successful submission it('should call login function when form is valid', async () => { // Arrange const mockLogin = jest.fn().mockResolvedValue({ success: true }) const { getByText, getByPlaceholderText } = render(<LoginScreen onLogin={mockLogin} />) // Act - fill form and submit fireEvent.changeText(getByPlaceholderText('Email'), 'user@example.com') fireEvent.changeText(getByPlaceholderText('Password'), 'password123') fireEvent.press(getByText('Sign In')) // Assert - login called with correct values await waitFor(() => { expect(mockLogin).toHaveBeenCalledWith({ email: 'user@example.com', password: 'password123' }) }) }) // 3. Testing loading state it('should display loading indicator during authentication', async () => { // Arrange - create a promise that we can resolve manually let resolveLogin const loginPromise = new Promise(resolve => { resolveLogin = resolve }) const mockLogin = jest.fn().mockImplementation(() => loginPromise) const { getByText, getByPlaceholderText, getByTestId } = render( <LoginScreen onLogin={mockLogin} /> ) // Act - fill form and submit fireEvent.changeText(getByPlaceholderText('Email'), 'user@example.com') fireEvent.changeText(getByPlaceholderText('Password'), 'password123') fireEvent.press(getByText('Sign In')) // Assert - loading indicator appears expect(getByTestId('loading-indicator')).toBeDefined() // Resolve the login promise resolveLogin({ success: true }) // Assert - loading indicator disappears await waitFor(() => { expect(() => getByTestId('loading-indicator')).toThrow() }) }) // 4. Testing error handling it('should display error message when login fails', async () => { // Arrange const mockLogin = jest.fn().mockResolvedValue({ success: false, error: 'Invalid credentials' }) const { getByText, getByPlaceholderText } = render(<LoginScreen onLogin={mockLogin} />) // Act - fill form and submit fireEvent.changeText(getByPlaceholderText('Email'), 'user@example.com') fireEvent.changeText(getByPlaceholderText('Password'), 'wrong-password') fireEvent.press(getByText('Sign In')) // Assert - error message appears await waitFor(() => { expect(getByText('Invalid credentials')).toBeDefined() }) }) })
integrating tests into your ci/cd pipeline
modern software delivery relies on continuous integration. the circleci blog offers insights into optimizing ci/cd for mobile apps, while github actions has become increasingly popular for react native projects:
# .github/workflows/test.yml name: Test and Build on: push: branches: [ main, develop ] pull_request: branches: [ main, develop ] jobs: test: name: Run Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: '18' cache: 'yarn' - name: Install dependencies run: yarn install --frozen-lockfile - name: Lint code run: yarn lint - name: Type check run: yarn typescript - name: Run unit and integration tests run: yarn test --coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} build-android: name: Build Android App needs: test runs-on: ubuntu-latest if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') steps: - uses: actions/checkout@v3 - name: Set up JDK 11 uses: actions/setup-java@v3 with: distribution: 'zulu' java-version: '11' - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: '18' cache: 'yarn' - name: Install dependencies run: yarn install --frozen-lockfile - name: Cache Gradle Wrapper uses: actions/cache@v3 with: path: ~/.gradle/wrapper key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties') }} - name: Cache Gradle Dependencies uses: actions/cache@v3 with: path: ~/.gradle/caches key: ${{ runner.os }}-gradle-caches-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle-caches- - name: Build Android Release run: | cd android ./gradlew assembleRelease --no-daemon - name: Upload APK artifact uses: actions/upload-artifact@v3 with: name: app-release path: android/app/build/outputs/apk/release/app-release.apk
tdd best practices that scale with your application
as your app grows, maintaining an effective tdd workflow becomes more challenging. according to expert recommendations, these practices help scale your testing approach:
1. focus on test value, not just coverage
// BAD: Testing implementation details test('getDiscountedPrice calls calculateDiscount', () => { const spy = jest.spyOn(utils, 'calculateDiscount') getDiscountedPrice(100, 0.1) expect(spy).toHaveBeenCalled() }) // GOOD: Testing behavior and outcomes test('getDiscountedPrice applies correct discount', () => { expect(getDiscountedPrice(100, 0.1)).toBe(90) expect(getDiscountedPrice(100, 0.25)).toBe(75) })
2. use data providers for comprehensive testing
// Testing multiple scenarios efficiently describe('validatePassword', () => { const testCases = [ { password: 'abc', expected: false, scenario: 'too short' }, { password: 'abcdefgh', expected: false, scenario: 'no numbers' }, { password: 'abcd1234', expected: false, scenario: 'no special characters' }, { password: 'Abcd1234!', expected: true, scenario: 'valid password' } ] testCases.forEach(({ password, expected, scenario }) => { it(`should return ${expected} when password is ${scenario}`, () => { expect(validatePassword(password)).toBe(expected) }) }) })
3. create test utilities and factories
// user.factory.ts export const createUser = (overrides = {}) => ({ id: 'user-1', email: 'test@example.com', firstName: 'Test', lastName: 'User', createdAt: new Date('2023-01-01').toISOString(), ...overrides }) // order.factory.ts export const createOrder = (overrides = {}) => ({ id: 'order-1', userId: 'user-1', items: [createOrderItem()], total: 29.99, status: 'pending', createdAt: new Date('2023-01-01').toISOString(), ...overrides }) export const createOrderItem = (overrides = {}) => ({ id: 'item-1', productId: 'product-1', name: 'Test Product', price: 29.99, quantity: 1, ...overrides }) // In tests it('should calculate order total correctly', () => { const order = createOrder({ items: [ createOrderItem({ price: 10, quantity: 2 }), createOrderItem({ price: 15, quantity: 1 }) ] }) expect(calculateOrderTotal(order)).toBe(35) })
4. separate test concerns with hooks
describe('CartScreen', () => { // Common setup const mockNavigate = jest.fn() // Set up global mocks once beforeAll(() => { jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate }) })) }) // Reset mocks between tests beforeEach(() => { jest.clearAllMocks() }) // Clean up after all tests afterAll(() => { jest.restoreAllMocks() }) // Tests... })
key takeaways
- start with clear requirements: before writing tests, understand exactly what you're building
- embrace the red-green-refactor cycle: write failing tests first, implement minimal code, then refactor
- balance your testing pyramid: more unit tests at the base, fewer e2e tests at the top
- isolate tests with effective mocking: create a reliable testing environment with predictable dependencies
- test behavior, not implementation: focus on what your code does, not how it does it
- integrate tests into your workflow: use ci/cd to ensure tests are run consistently
- improve test quality over time: refactor tests alongside your application code
implementing test-driven development in your mobile projects requires initial discipline but pays dividends in code quality, confidence in changes, and reduced bug counts. as the state of js 2023 survey shows, testing remains a critical skill for modern javascript developers, with tdd practitioners reporting higher job satisfaction and productivity.