JavaScript Testing Guide: Jest vs Vitest, Unit Tests, and Integration Testing
Writing tests is an investment that pays dividends. This guide covers everything from setting up your testing infrastructure to writing effective tests that catch real bugs.
Jest vs Vitest: Which to Choose?
| Feature | Jest | Vitest |
|---|---|---|
| Startup speed | ~2-5s | ~200ms |
| Watch mode | Good | Excellent |
| Config required | Moderate | Minimal |
| Vite projects | Complex setup | Native |
| TypeScript | via Babel/ts-jest | Native |
| Snapshot testing | ✅ | ✅ |
| Coverage | ✅ | ✅ |
| Ecosystem | Massive | Growing fast |
Rule of thumb: Vite project → Vitest. Create React App / legacy → Jest.
Setting Up Vitest
npm install --save-dev vitest @vitest/coverage-v8 happy-dom
// vite.config.ts (or vitest.config.ts)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom', // jsdom-like browser env
globals: true, // No need to import describe/it/expect
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**'],
},
},
});
// src/test/setup.ts
import '@testing-library/jest-dom'; // Adds .toBeInTheDocument(), .toHaveClass(), etc.
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// Clean up after each test
afterEach(() => {
cleanup();
});
Setting Up Jest
npm install --save-dev jest @types/jest ts-jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterFramework: ['@testing-library/jest-dom'],
moduleNameMapper: {
'^@/(.*)#39;: '<rootDir>/src/$1', // Path aliases
'\.(css|scss)#39;: 'identity-obj-proxy', // CSS modules mock
'\.(jpg|png|svg)#39;: '<rootDir>/src/__mocks__/fileMock.js',
},
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.test.{ts,tsx}'],
};
Unit Testing Patterns
Testing Pure Functions
// utils/formatters.ts
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 3) + '...';
}
export function parseJWT(token: string): { header: object; payload: object } | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
return {
header: JSON.parse(atob(parts[0])),
payload: JSON.parse(atob(parts[1])),
};
} catch {
return null;
}
}
// utils/formatters.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency, truncateText, parseJWT } from './formatters';
describe('formatCurrency', () => {
it('formats USD amounts correctly', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56');
expect(formatCurrency(0)).toBe('$0.00');
expect(formatCurrency(1000000)).toBe('$1,000,000.00');
});
it('supports different currencies', () => {
expect(formatCurrency(100, 'EUR')).toBe('€100.00');
expect(formatCurrency(100, 'GBP')).toBe('£100.00');
});
});
describe('truncateText', () => {
it('returns text unchanged if within limit', () => {
expect(truncateText('Hello', 10)).toBe('Hello');
expect(truncateText('Hello', 5)).toBe('Hello');
});
it('truncates long text with ellipsis', () => {
expect(truncateText('Hello World', 8)).toBe('Hello...');
expect(truncateText('JavaScript is awesome', 15)).toBe('JavaScript is...');
});
});
describe('parseJWT', () => {
const validToken = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature';
it('parses valid JWT structure', () => {
const result = parseJWT(validToken);
expect(result).not.toBeNull();
expect(result?.payload).toEqual({ sub: '123' });
});
it('returns null for invalid tokens', () => {
expect(parseJWT('not-a-jwt')).toBeNull();
expect(parseJWT('')).toBeNull();
expect(parseJWT('only.two')).toBeNull();
});
});
Mocking
Mocking Modules
// api/userApi.ts
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// services/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getUserProfile } from './userService';
import * as userApi from '../api/userApi';
// Mock the entire module
vi.mock('../api/userApi');
describe('getUserProfile', () => {
beforeEach(() => {
vi.clearAllMocks(); // Reset mocks between tests
});
it('returns formatted user profile', async () => {
// Set up mock return value
vi.mocked(userApi.fetchUser).mockResolvedValueOnce({
id: '123',
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
});
const profile = await getUserProfile('123');
expect(profile).toEqual({
id: '123',
fullName: 'John Doe',
email: 'john@example.com',
});
expect(userApi.fetchUser).toHaveBeenCalledWith('123');
expect(userApi.fetchUser).toHaveBeenCalledTimes(1);
});
it('handles API errors', async () => {
vi.mocked(userApi.fetchUser).mockRejectedValueOnce(new Error('Network error'));
await expect(getUserProfile('123')).rejects.toThrow('Failed to fetch user profile');
});
});
Mocking fetch
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe('API calls', () => {
beforeEach(() => {
// Mock global fetch
global.fetch = vi.fn();
});
it('calls the correct endpoint', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: '1', name: 'Alice' }),
} as Response);
const user = await fetchUser('1');
expect(fetch).toHaveBeenCalledWith('/api/users/1');
expect(user.name).toBe('Alice');
});
it('throws on HTTP error', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as Response);
await expect(fetchUser('999')).rejects.toThrow('HTTP 404');
});
});
React Component Testing
npm install --save-dev @testing-library/react @testing-library/user-event
// components/LoginForm.tsx
export function LoginForm({ onSuccess }: { onSuccess: (user: User) => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
const user = await login(email, password);
onSuccess(user);
} catch (err) {
setError('Invalid email or password');
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required />
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required />
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Log In'}
</button>
</form>
);
}
// components/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { LoginForm } from './LoginForm';
import * as authApi from '../api/auth';
vi.mock('../api/auth');
describe('LoginForm', () => {
const user = userEvent.setup();
const mockOnSuccess = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders form elements', () => {
render(<LoginForm onSuccess={mockOnSuccess} />);
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log In' })).toBeInTheDocument();
});
it('submits with user credentials', async () => {
const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
vi.mocked(authApi.login).mockResolvedValueOnce(mockUser);
render(<LoginForm onSuccess={mockOnSuccess} />);
await user.type(screen.getByPlaceholderText('Email'), 'alice@example.com');
await user.type(screen.getByPlaceholderText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Log In' }));
await waitFor(() => {
expect(authApi.login).toHaveBeenCalledWith('alice@example.com', 'password123');
expect(mockOnSuccess).toHaveBeenCalledWith(mockUser);
});
});
it('shows error message on login failure', async () => {
vi.mocked(authApi.login).mockRejectedValueOnce(new Error('Unauthorized'));
render(<LoginForm onSuccess={mockOnSuccess} />);
await user.type(screen.getByPlaceholderText('Email'), 'alice@example.com');
await user.type(screen.getByPlaceholderText('Password'), 'wrongpassword');
await user.click(screen.getByRole('button', { name: 'Log In' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid email or password');
expect(mockOnSuccess).not.toHaveBeenCalled();
});
it('disables button while loading', async () => {
vi.mocked(authApi.login).mockImplementationOnce(
() => new Promise(resolve => setTimeout(resolve, 1000))
);
render(<LoginForm onSuccess={mockOnSuccess} />);
await user.type(screen.getByPlaceholderText('Email'), 'alice@example.com');
await user.type(screen.getByPlaceholderText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Log In' }));
expect(screen.getByRole('button', { name: 'Logging in...' })).toBeDisabled();
});
});
Integration Testing with MSW
npm install --save-dev msw
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice Johnson',
email: 'alice@example.com',
});
}),
http.post('/api/auth/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'alice@example.com' && body.password === 'correct') {
return HttpResponse.json({ token: 'jwt-token-here' });
}
return new HttpResponse('Unauthorized', { status: 401 });
}),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/test/setup.ts
import { server } from '../mocks/server';
import { beforeAll, afterAll, afterEach } from 'vitest';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Coverage Goals
# Run tests with coverage
npx vitest run --coverage
npx jest --coverage
# Coverage thresholds in config
// vitest.config.ts
test: {
coverage: {
thresholds: {
branches: 70,
functions: 80,
lines: 80,
statements: 80,
}
}
}
Coverage targets:
- 80%+ line/function coverage is a reasonable goal
- 100% coverage ≠ bug-free code
- Focus on critical paths: auth, payments, data mutations
Running Tests in CI
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run tests
run: npm test -- --coverage --reporter=verbose
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
Summary
| Test Type | Tool | When to Write |
|---|---|---|
| Unit tests | Vitest/Jest | Pure functions, utils, hooks |
| Component tests | RTL + Vitest | UI components with interactions |
| Integration tests | MSW + RTL | Feature flows, API interactions |
| E2E tests | Playwright/Cypress | Critical user journeys |
→ Measure performance of different implementations with Benchmark Builder.