Web & Mobile
Comprehensive guide to unit testing for web and mobile applications.
Overview
Unit testing ensures individual components and functions work correctly in isolation. This guide covers testing strategies, frameworks, and best practices for both web and mobile applications.
Why Unit Testing?
- Catch Bugs Early: Identify issues before they reach production
- Code Quality: Encourages better code structure and modularity
- Refactoring Confidence: Make changes without fear of breaking functionality
- Documentation: Tests serve as living documentation
- Faster Development: Reduce time spent debugging
Testing Frameworks
Jest
Most popular JavaScript testing framework.
Installation:
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
Configuration (jest.config.js):
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts', '!src/index.js'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
Setup File (jest.setup.js):
import '@testing-library/jest-dom';
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
React Testing Library
Testing library focused on testing React components from the user's perspective.
Basic Component Test:
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button Component', () => {
test('renders button with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick handler when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('applies disabled state', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByText('Click me')).toBeDisabled();
});
});
Vitest
Fast unit test framework powered by Vite.
Installation:
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom
Configuration (vitest.config.js):
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './vitest.setup.js',
},
});
Testing React Components
Component Props Testing
import { render, screen } from '@testing-library/react';
import UserCard from './UserCard';
describe('UserCard', () => {
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
avatar: '/avatar.jpg',
};
test('renders user information', () => {
render(<UserCard user={mockUser} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
expect(screen.getByAltText('John Doe')).toHaveAttribute('src', '/avatar.jpg');
});
test('renders without avatar', () => {
const userWithoutAvatar = { ...mockUser, avatar: null };
render(<UserCard user={userWithoutAvatar} />);
expect(screen.getByText('JD')).toBeInTheDocument(); // Initials
});
});
Event Handling Tests
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import SearchBar from './SearchBar';
describe('SearchBar', () => {
test('calls onSearch with input value', async () => {
const handleSearch = jest.fn();
render(<SearchBar onSearch={handleSearch} />);
const input = screen.getByPlaceholderText('Search...');
fireEvent.change(input, { target: { value: 'test query' } });
fireEvent.click(screen.getByText('Search'));
expect(handleSearch).toHaveBeenCalledWith('test query');
});
test('debounces search input', async () => {
jest.useFakeTimers();
const handleSearch = jest.fn();
render(<SearchBar onSearch={handleSearch} debounce={500} />);
const input = screen.getByPlaceholderText('Search...');
fireEvent.change(input, { target: { value: 't' } });
fireEvent.change(input, { target: { value: 'te' } });
fireEvent.change(input, { target: { value: 'test' } });
expect(handleSearch).not.toHaveBeenCalled();
jest.advanceTimersByTime(500);
await waitFor(() => {
expect(handleSearch).toHaveBeenCalledWith('test');
expect(handleSearch).toHaveBeenCalledTimes(1);
});
jest.useRealTimers();
});
});
State Management Tests
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
describe('Counter', () => {
test('starts at zero', () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
test('increments count', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
test('decrements count', () => {
render(<Counter initialValue={5} />);
fireEvent.click(screen.getByText('Decrement'));
expect(screen.getByText('Count: 4')).toBeInTheDocument();
});
test('resets count', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
fireEvent.click(screen.getByText('Increment'));
fireEvent.click(screen.getByText('Reset'));
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
});
Async Component Tests
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
// Mock fetch
global.fetch = jest.fn();
describe('UserList', () => {
beforeEach(() => {
fetch.mockClear();
});
test('loads and displays users', async () => {
const mockUsers = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' },
];
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUsers,
});
render(<UserList />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
test('displays error message on fetch failure', async () => {
fetch.mockRejectedValueOnce(new Error('Failed to fetch'));
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
Testing Hooks
Custom Hook Testing
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
test('initial value is 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('accepts initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('respects max value', () => {
const { result } = renderHook(() => useCounter(0, { max: 5 }));
act(() => {
for (let i = 0; i < 10; i++) {
result.current.increment();
}
});
expect(result.current.count).toBe(5);
});
});
Async Hook Testing
import { renderHook, waitFor } from '@testing-library/react';
import useFetch from './useFetch';
describe('useFetch', () => {
test('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test' };
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => mockData,
});
const { result } = renderHook(() => useFetch('/api/test'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
});
});
Testing Context Providers
import { render, screen } from '@testing-library/react';
import { AuthProvider, useAuth } from './AuthContext';
const TestComponent = () => {
const { user, login, logout } = useAuth();
return (
<div>
{user ? (
<>
<p>Logged in as {user.name}</p>
<button onClick={logout}>Logout</button>
</>
) : (
<button onClick={() => login({ name: 'John' })}>Login</button>
)}
</div>
);
};
describe('AuthContext', () => {
test('provides authentication state', () => {
render(
<AuthProvider>
<TestComponent />
</AuthProvider>,
);
expect(screen.getByText('Login')).toBeInTheDocument();
});
test('handles login', () => {
render(
<AuthProvider>
<TestComponent />
</AuthProvider>,
);
fireEvent.click(screen.getByText('Login'));
expect(screen.getByText('Logged in as John')).toBeInTheDocument();
});
test('handles logout', () => {
render(
<AuthProvider>
<TestComponent />
</AuthProvider>,
);
fireEvent.click(screen.getByText('Login'));
fireEvent.click(screen.getByText('Logout'));
expect(screen.getByText('Login')).toBeInTheDocument();
});
});
Mobile-Specific Testing
React Native Testing
Installation:
npm install --save-dev @testing-library/react-native
Component Test:
import { render, fireEvent } from '@testing-library/react-native';
import MobileButton from './MobileButton';
describe('MobileButton', () => {
test('renders correctly', () => {
const { getByText } = render(<MobileButton title="Press me" />);
expect(getByText('Press me')).toBeTruthy();
});
test('handles press events', () => {
const onPress = jest.fn();
const { getByText } = render(<MobileButton title="Press me" onPress={onPress} />);
fireEvent.press(getByText('Press me'));
expect(onPress).toHaveBeenCalled();
});
test('applies disabled style', () => {
const { getByText } = render(<MobileButton title="Press me" disabled />);
const button = getByText('Press me');
expect(button.props.style).toMatchObject({
opacity: 0.5,
});
});
});
Testing Native Modules
import { NativeModules } from 'react-native';
// Mock native module
jest.mock('react-native', () => ({
NativeModules: {
CameraModule: {
takePicture: jest.fn(),
},
},
}));
describe('Camera functionality', () => {
test('takes picture', async () => {
const mockImage = { uri: 'file://photo.jpg' };
NativeModules.CameraModule.takePicture.mockResolvedValue(mockImage);
const result = await takePicture();
expect(NativeModules.CameraModule.takePicture).toHaveBeenCalled();
expect(result).toEqual(mockImage);
});
});
Testing Gestures
import { render, fireEvent } from '@testing-library/react-native';
import SwipeableItem from './SwipeableItem';
describe('SwipeableItem', () => {
test('handles swipe left', () => {
const onSwipeLeft = jest.fn();
const { getByTestId } = render(<SwipeableItem onSwipeLeft={onSwipeLeft} testID="swipeable" />);
const item = getByTestId('swipeable');
fireEvent(item, 'onSwipeableLeftOpen');
expect(onSwipeLeft).toHaveBeenCalled();
});
});
Mocking
Mocking API Calls
import axios from 'axios';
import { render, screen, waitFor } from '@testing-library/react';
import DataComponent from './DataComponent';
jest.mock('axios');
describe('DataComponent', () => {
test('fetches and displays data', async () => {
const mockData = { id: 1, title: 'Test' };
axios.get.mockResolvedValue({ data: mockData });
render(<DataComponent />);
await waitFor(() => {
expect(screen.getByText('Test')).toBeInTheDocument();
});
expect(axios.get).toHaveBeenCalledWith('/api/data');
});
});
Mocking Modules
// Mock entire module
jest.mock('./utils/helpers', () => ({
formatDate: jest.fn(() => '2025-01-15'),
calculateTotal: jest.fn(() => 100),
}));
// Partial mock
jest.mock('./utils/helpers', () => ({
...jest.requireActual('./utils/helpers'),
formatDate: jest.fn(() => '2025-01-15'),
}));
Mocking LocalStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
global.localStorage = localStorageMock;
describe('Storage tests', () => {
beforeEach(() => {
localStorage.getItem.mockClear();
localStorage.setItem.mockClear();
});
test('saves data to localStorage', () => {
saveUserPreferences({ theme: 'dark' });
expect(localStorage.setItem).toHaveBeenCalledWith(
'preferences',
JSON.stringify({ theme: 'dark' }),
);
});
});
Snapshot Testing
import { render } from '@testing-library/react';
import ProfileCard from './ProfileCard';
describe('ProfileCard', () => {
test('matches snapshot', () => {
const user = {
name: 'John Doe',
email: 'john@example.com',
avatar: '/avatar.jpg',
};
const { container } = render(<ProfileCard user={user} />);
expect(container).toMatchSnapshot();
});
});
Coverage Reports
Run tests with coverage:
npm test -- --coverage
Coverage configuration:
// jest.config.js
module.exports = {
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.test.{js,jsx,ts,tsx}',
'!src/index.js',
'!src/reportWebVitals.js',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
coverageReporters: ['text', 'lcov', 'html'],
};
Best Practices
1. Test Behavior, Not Implementation
// ✅ Good - Tests behavior
test('displays user name', () => {
render(<UserProfile user={{ name: 'John' }} />);
expect(screen.getByText('John')).toBeInTheDocument();
});
// ❌ Bad - Tests implementation
test('sets state correctly', () => {
const wrapper = shallow(<UserProfile />);
wrapper.instance().setState({ name: 'John' });
expect(wrapper.state('name')).toBe('John');
});
2. Use Data-TestId Sparingly
// ✅ Good - Use accessible queries
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email/i);
screen.getByText(/welcome/i);
// ⚠️ OK - When no accessible query works
screen.getByTestId('custom-component');
3. Avoid Testing Library Implementation Details
// ✅ Good - Test user-facing behavior
expect(screen.getByText('5 items')).toBeInTheDocument();
// ❌ Bad - Test internal state
expect(component.state.count).toBe(5);
4. Clean Up After Tests
afterEach(() => {
jest.clearAllMocks();
cleanup();
});
5. Use Descriptive Test Names
// ✅ Good
test('displays error message when email is invalid', () => {});
// ❌ Bad
test('test 1', () => {});
Running Tests
# Run all tests
npm test
# Run tests in watch mode
npm test -- --watch
# Run specific test file
npm test -- UserCard.test.js
# Run tests with coverage
npm test -- --coverage
# Run tests matching pattern
npm test -- --testNamePattern="login"
Continuous Integration
GitHub Actions (.github/workflows/test.yml):
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v2
Related Documentation
- UI Testing Web
- UI Testing Mobile
- WavePulse – WaveMaker debugging tool
- Debugging Overview – All debugging tools and methods