Skip to main content

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