Module 10: Testing and Quality Assurance
Testing Fundamentals
Why we test, testing strategies, and setting up your testing environment
π― Learning Objectives
- Understand why testing is essential for mobile development
- Learn the different types of tests and when to use each
- Set up Jest and React Native Testing Library
- Write your first tests for React Native components
- Understand the testing pyramid and coverage strategies
- Configure testing for Expo projects
Why Testing Matters
Testing is not just about finding bugsβit's about building confidence in your code and enabling sustainable development. For mobile apps, testing is especially critical because:
The Cost of Bugs
The later a bug is found, the more expensive it is to fix:
π° Bug Fix Cost by Stage
| Stage | Relative Cost | Example |
|---|---|---|
| During coding | 1x | Test catches typo immediately |
| Code review | 5x | Colleague spots issue in PR |
| QA testing | 10x | Manual tester finds bug |
| Production | 100x+ | User reports crash, hotfix needed |
π± Mobile-Specific Testing Challenges
- App Store delays: Fixes take days to reach users
- Device fragmentation: Many screen sizes and OS versions
- Native bridges: JavaScript and native code must work together
- Offline scenarios: Apps must handle network issues gracefully
- User expectations: Mobile users expect polished experiences
Types of Tests
Different types of tests serve different purposes. Understanding when to use each type is crucial for an effective testing strategy.
Unit Tests
Unit tests verify that individual functions, hooks, or small pieces of logic work correctly in isolation.
// A simple utility function
function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
// Unit test for the function
describe('formatPrice', () => {
it('formats cents as dollars', () => {
expect(formatPrice(999)).toBe('$9.99');
expect(formatPrice(100)).toBe('$1.00');
expect(formatPrice(0)).toBe('$0.00');
});
});
β Unit Tests Are Best For:
- Utility functions and helpers
- Data transformations
- Business logic
- Custom hooks (logic portion)
- State reducers
Component Tests
Component tests verify that React components render correctly and respond to user interactions as expected.
import { render, screen, fireEvent } from '@testing-library/react-native';
import Counter from './Counter';
describe('Counter', () => {
it('displays initial count', () => {
render(<Counter initialValue={5} />);
expect(screen.getByText('5')).toBeTruthy();
});
it('increments when plus button is pressed', () => {
render(<Counter initialValue={0} />);
fireEvent.press(screen.getByText('+'));
expect(screen.getByText('1')).toBeTruthy();
});
});
π§© Component Tests Are Best For:
- Rendering logic
- User interactions (taps, inputs)
- Conditional rendering
- Props handling
- State changes
Integration Tests
Integration tests verify that multiple components or systems work together correctly.
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
import App from './App';
describe('Login Flow', () => {
it('navigates to home after successful login', async () => {
render(
<NavigationContainer>
<App />
</NavigationContainer>
);
fireEvent.changeText(screen.getByPlaceholderText('Email'), 'test@example.com');
fireEvent.changeText(screen.getByPlaceholderText('Password'), 'password123');
fireEvent.press(screen.getByText('Login'));
await waitFor(() => {
expect(screen.getByText('Welcome!')).toBeTruthy();
});
});
});
π Integration Tests Are Best For:
- Navigation flows
- Form submissions
- Data fetching and display
- Component interactions
- State management across components
End-to-End (E2E) Tests
E2E tests verify the entire application from the user's perspective, running on real or simulated devices.
# Using Maestro (declarative E2E testing)
# flow: login.yaml
appId: com.myapp
---
- launchApp
- tapOn: "Email"
- inputText: "test@example.com"
- tapOn: "Password"
- inputText: "password123"
- tapOn: "Login"
- assertVisible: "Welcome!"
π― E2E Tests Are Best For:
- Critical user journeys
- Smoke tests before release
- Cross-platform verification
- Real device behavior
- Native module integration
The Testing Pyramid
The testing pyramid is a guide for balancing different types of tests. The goal is to have many fast, cheap unit tests and fewer slow, expensive E2E tests.
βοΈ Balancing Your Test Suite
| Test Type | Speed | Maintenance | Confidence |
|---|---|---|---|
| Unit | β‘ Very fast (ms) | π’ Low | Function works |
| Component | β‘ Fast (ms-sec) | π‘ Medium | Component works |
| Integration | π’ Moderate (sec) | π‘ Medium | Features work together |
| E2E | π Slow (min) | π΄ High | App works for users |
Setting Up Testing
Expo projects come with Jest pre-configured, but we need to add React Native Testing Library for component testing.
Install Dependencies
# Core testing libraries
npm install --save-dev jest @testing-library/react-native @testing-library/jest-native
# Additional utilities
npm install --save-dev jest-expo
Configure Jest
// jest.config.js
module.exports = {
preset: 'jest-expo',
setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect'],
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)',
],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
collectCoverageFrom: [
'**/*.{ts,tsx}',
'!**/coverage/**',
'!**/node_modules/**',
'!**/babel.config.js',
'!**/jest.setup.js',
],
};
Setup File
// jest.setup.js
import '@testing-library/jest-native/extend-expect';
// Mock commonly used native modules
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
// Mock Expo modules that aren't available in tests
jest.mock('expo-font');
jest.mock('expo-asset');
// Silence React Native warnings in tests
jest.mock('react-native/Libraries/LogBox/LogBox', () => ({
ignoreLogs: jest.fn(),
ignoreAllLogs: jest.fn(),
}));
Update package.json
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"preset": "jest-expo",
"setupFilesAfterEnv": [
"./jest.setup.js"
]
}
}
Project Structure
my-app/
βββ src/
β βββ components/
β β βββ Button.tsx
β β βββ Button.test.tsx # Test file next to component
β βββ hooks/
β β βββ useCounter.ts
β β βββ useCounter.test.ts
β βββ utils/
β βββ format.ts
β βββ format.test.ts
βββ __tests__/ # Or separate test directory
β βββ integration/
β βββ LoginFlow.test.tsx
βββ jest.config.js
βββ jest.setup.js
π‘ Test File Naming Conventions
Component.test.tsx- Test file next to componentComponent.spec.tsx- Alternative naming__tests__/Component.tsx- Tests in dedicated folder
Jest finds all files matching *.test.{js,jsx,ts,tsx} or *.spec.{js,jsx,ts,tsx}
Writing Your First Tests
Let's write tests for a simple component to understand the basics.
The Component to Test
// components/Greeting.tsx
import { View, Text, StyleSheet } from 'react-native';
interface GreetingProps {
name: string;
showEmoji?: boolean;
}
export function Greeting({ name, showEmoji = true }: GreetingProps) {
return (
<View style={styles.container}>
<Text style={styles.text}>
Hello, {name}!{showEmoji ? ' π' : ''}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 16 },
text: { fontSize: 24 },
});
Writing the Test
// components/Greeting.test.tsx
import { render, screen } from '@testing-library/react-native';
import { Greeting } from './Greeting';
describe('Greeting', () => {
it('renders the name', () => {
render(<Greeting name="Alice" />);
expect(screen.getByText(/Hello, Alice!/)).toBeTruthy();
});
it('shows emoji by default', () => {
render(<Greeting name="Bob" />);
expect(screen.getByText(/π/)).toBeTruthy();
});
it('hides emoji when showEmoji is false', () => {
render(<Greeting name="Charlie" showEmoji={false} />);
expect(screen.getByText('Hello, Charlie!')).toBeTruthy();
expect(screen.queryByText(/π/)).toBeNull();
});
});
Testing User Interactions
// components/Counter.tsx
import { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
interface CounterProps {
initialValue?: number;
}
export function Counter({ initialValue = 0 }: CounterProps) {
const [count, setCount] = useState(initialValue);
return (
<View style={styles.container}>
<Text testID="count-display" style={styles.count}>{count}</Text>
<View style={styles.buttons}>
<Pressable
testID="decrement-button"
onPress={() => setCount(c => c - 1)}
style={styles.button}
>
<Text style={styles.buttonText}>-</Text>
</Pressable>
<Pressable
testID="increment-button"
onPress={() => setCount(c => c + 1)}
style={styles.button}
>
<Text style={styles.buttonText}>+</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { alignItems: 'center', padding: 20 },
count: { fontSize: 48, fontWeight: 'bold' },
buttons: { flexDirection: 'row', marginTop: 20 },
button: { backgroundColor: '#667eea', padding: 20, marginHorizontal: 10, borderRadius: 8 },
buttonText: { color: 'white', fontSize: 24 },
});
// components/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react-native';
import { Counter } from './Counter';
describe('Counter', () => {
it('displays initial value', () => {
render(<Counter initialValue={5} />);
expect(screen.getByTestId('count-display')).toHaveTextContent('5');
});
it('defaults to 0', () => {
render(<Counter />);
expect(screen.getByTestId('count-display')).toHaveTextContent('0');
});
it('increments when + is pressed', () => {
render(<Counter initialValue={0} />);
fireEvent.press(screen.getByTestId('increment-button'));
expect(screen.getByTestId('count-display')).toHaveTextContent('1');
});
it('decrements when - is pressed', () => {
render(<Counter initialValue={5} />);
fireEvent.press(screen.getByTestId('decrement-button'));
expect(screen.getByTestId('count-display')).toHaveTextContent('4');
});
it('handles multiple interactions', () => {
render(<Counter initialValue={0} />);
fireEvent.press(screen.getByTestId('increment-button'));
fireEvent.press(screen.getByTestId('increment-button'));
fireEvent.press(screen.getByTestId('increment-button'));
fireEvent.press(screen.getByTestId('decrement-button'));
expect(screen.getByTestId('count-display')).toHaveTextContent('2');
});
});
Test Structure and Organization
Well-organized tests are easier to read, maintain, and debug. Follow these patterns for consistent, clear tests.
The AAA Pattern
Structure each test with three clear phases: Arrange, Act, Assert.
it('increments the counter when button is pressed', () => {
// Arrange - Set up the test
render(<Counter initialValue={0} />);
const button = screen.getByTestId('increment-button');
// Act - Perform the action
fireEvent.press(button);
// Assert - Verify the result
expect(screen.getByTestId('count-display')).toHaveTextContent('1');
});
Describe Blocks
Use describe blocks to group related tests and create clear hierarchies.
describe('LoginForm', () => {
describe('validation', () => {
it('shows error for invalid email', () => { /* ... */ });
it('shows error for short password', () => { /* ... */ });
it('shows error for empty fields', () => { /* ... */ });
});
describe('submission', () => {
it('calls onSubmit with form data', () => { /* ... */ });
it('disables button while submitting', () => { /* ... */ });
it('shows error message on failure', () => { /* ... */ });
});
describe('accessibility', () => {
it('has accessible labels', () => { /* ... */ });
});
});
Test Utilities
// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './contexts/ThemeContext';
function AllTheProviders({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<NavigationContainer>
{children}
</NavigationContainer>
</ThemeProvider>
</QueryClientProvider>
);
}
const customRender = (
ui: React.ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AllTheProviders, ...options });
export * from '@testing-library/react-native';
export { customRender as render };
Test Data Factories
// test-utils/factories.ts
interface User {
id: string;
name: string;
email: string;
avatar?: string;
}
export function createUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
avatar: undefined,
...overrides,
};
}
// Usage
const user = createUser({ name: 'Alice' });
const outOfStock = createProduct({ inStock: false });
Running Tests
Basic Commands
# Run all tests
npm test
# Run tests in watch mode (re-runs on file changes)
npm test -- --watch
# Run specific test file
npm test -- Counter.test.tsx
# Run tests matching a pattern
npm test -- --testNamePattern="increments"
# Run with coverage report
npm test -- --coverage
# Run only changed files
npm test -- --onlyChanged
Watch Mode Commands
β¨οΈ Watch Mode Shortcuts
| Key | Action |
|---|---|
a | Run all tests |
f | Run only failed tests |
p | Filter by filename pattern |
t | Filter by test name pattern |
o | Run only changed files |
q | Quit watch mode |
Enter | Re-run tests |
Code Coverage
# Generate coverage report
npm test -- --coverage
# Output:
--------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------|---------|----------|---------|---------|-------------------
All files | 85.71 | 66.67 | 100 | 85.71 |
Counter.tsx | 100 | 100 | 100 | 100 |
Greeting.tsx | 71.43 | 50 | 100 | 71.43 | 12-14
--------------------|---------|----------|---------|---------|-------------------
π Coverage Metrics
- Statements: % of code statements executed
- Branches: % of if/else branches tested
- Functions: % of functions called
- Lines: % of lines executed
Aim for 70-80% coverage on critical code. 100% coverage doesn't guarantee bug-free code!
Testing Best Practices
Test Behavior, Not Implementation
// β Bad - Tests implementation details
it('sets isLoading state to true', () => {
const { result } = renderHook(() => useData());
act(() => { result.current.fetchData(); });
expect(result.current.isLoading).toBe(true);
});
// β
Good - Tests observable behavior
it('shows loading indicator while fetching', async () => {
render(<DataComponent />);
fireEvent.press(screen.getByText('Load Data'));
expect(screen.getByTestId('loading-spinner')).toBeTruthy();
await waitFor(() => {
expect(screen.queryByTestId('loading-spinner')).toBeNull();
});
});
Use Accessible Queries
// Query priority (from best to worst)
// 1. Accessible queries (best)
screen.getByRole('button', { name: 'Submit' })
screen.getByLabelText('Email address')
screen.getByPlaceholderText('Enter your email')
screen.getByText('Welcome!')
// 2. Semantic queries
screen.getByDisplayValue('current value')
// 3. Test IDs (when needed)
screen.getByTestId('submit-button')
// β Avoid - Fragile selectors
container.querySelector('.btn-primary')
Keep Tests Independent
// β Bad - Tests depend on each other
let count = 0;
it('increments count', () => { count++; expect(count).toBe(1); });
it('count is now 1', () => { expect(count).toBe(1); }); // Fails if run alone!
// β
Good - Each test is independent
describe('Counter', () => {
it('starts at 0', () => {
render(<Counter />);
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
it('can increment', () => {
render(<Counter />);
fireEvent.press(screen.getByText('+'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
});
Write Descriptive Test Names
// β Bad - Vague names
it('works', () => { /* ... */ });
it('test 1', () => { /* ... */ });
// β
Good - Descriptive names
it('displays the user name after successful login', () => { /* ... */ });
it('shows validation error when email is invalid', () => { /* ... */ });
it('disables submit button while form is submitting', () => { /* ... */ });
β Testing Checklist
- β Tests are independent and can run in any order
- β Test names describe what is being tested
- β Tests verify behavior, not implementation
- β Edge cases and error states are covered
- β Async operations use proper waiting
- β Mocks are cleaned up between tests
- β Tests don't rely on network or external services
- β Accessibility queries are preferred
Hands-On Exercises
Exercise 1: Test a Utility Function
Write tests for a password validation utility function.
The Function:
// utils/validatePassword.ts
interface ValidationResult {
isValid: boolean;
errors: string[];
}
export function validatePassword(password: string): ValidationResult {
const errors: string[] = [];
if (password.length < 8) errors.push('Password must be at least 8 characters');
if (!/[A-Z]/.test(password)) errors.push('Password must contain an uppercase letter');
if (!/[a-z]/.test(password)) errors.push('Password must contain a lowercase letter');
if (!/[0-9]/.test(password)) errors.push('Password must contain a number');
return { isValid: errors.length === 0, errors };
}
Requirements:
- Test valid passwords return isValid: true
- Test each validation rule individually
- Test multiple failures at once
- Test edge cases (empty string, very long password)
Show Solution
describe('validatePassword', () => {
describe('valid passwords', () => {
it('accepts password meeting all requirements', () => {
const result = validatePassword('Password123');
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});
describe('length validation', () => {
it('rejects passwords shorter than 8 characters', () => {
const result = validatePassword('Pass1');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must be at least 8 characters');
});
});
describe('uppercase validation', () => {
it('rejects passwords without uppercase', () => {
const result = validatePassword('password123');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain an uppercase letter');
});
});
describe('edge cases', () => {
it('handles empty string', () => {
const result = validatePassword('');
expect(result.isValid).toBe(false);
});
});
});
Exercise 2: Test a Toggle Component
Write tests for a toggle switch component.
Requirements:
- Test the label renders correctly
- Test pressing calls onValueChange with opposite value
- Test disabled state prevents interaction
- Test accessibility properties
Show Solution
describe('Toggle', () => {
const defaultProps = {
label: 'Dark Mode',
value: false,
onValueChange: jest.fn(),
};
beforeEach(() => { jest.clearAllMocks(); });
it('renders the label', () => {
render(<Toggle {...defaultProps} />);
expect(screen.getByText('Dark Mode')).toBeTruthy();
});
it('calls onValueChange with true when toggling off to on', () => {
const onValueChange = jest.fn();
render(<Toggle {...defaultProps} value={false} onValueChange={onValueChange} />);
fireEvent.press(screen.getByTestId('toggle-button'));
expect(onValueChange).toHaveBeenCalledWith(true);
});
it('does not call onValueChange when disabled', () => {
const onValueChange = jest.fn();
render(<Toggle {...defaultProps} disabled onValueChange={onValueChange} />);
fireEvent.press(screen.getByTestId('toggle-button'));
expect(onValueChange).not.toHaveBeenCalled();
});
});
Summary
Testing is an investment that pays dividends throughout the life of your application. Start with unit tests, add component tests for UI logic, and use integration tests for critical flows.
π― Key Takeaways
- Testing pyramid: Many unit tests, fewer integration tests, even fewer E2E tests
- Jest + RTL: The standard testing stack for React Native
- Test behavior: Focus on what users see, not implementation details
- AAA pattern: Arrange, Act, Assert for clear test structure
- Independent tests: Each test should work in isolation
- Descriptive names: Test names should explain what's being verified
- Edge cases: Don't forget error states and boundary conditions
- Coverage: Aim for 70-80% on critical code paths
In the next lesson, we'll dive deeper into unit testing with Jest, exploring mocking, async testing, and testing hooks.