JavaScript Testing Guide: Tools and Practices
Testing is essential for maintaining JavaScript applications. This guide covers the testing ecosystem, from unit tests to integration tests, with practical patterns for modern JavaScript and TypeScript projects.
Testing Philosophy
The Testing Trophy
While the testing pyramid (unit → integration → e2e) is widely known, Kent C. Dodds’ testing trophy refines the concept:
- Static analysis (TypeScript, ESLint) — catch type errors and anti-patterns
- Unit tests — isolated function/component behavior
- Integration tests — module interactions, API calls, database operations
- End-to-end tests — user workflows across the full stack
Focus most effort on integration tests — they provide the best ROI by testing how pieces work together without the fragility of full e2e tests.
What to Test
- Public API of functions, modules, and components
- Edge cases: empty states, error states, boundary values
- Business logic and data transformations
- User-visible behavior (not implementation details)
- Critical user flows
Testing Tools
Vitest (Recommended for Modern Projects)
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom', // for DOM testing
setupFiles: './test/setup.js',
coverage: {
provider: 'v8',
include: ['src/**/*.{js,ts}'],
exclude: ['src/**/*.test.*'],
},
},
---);// math.test.js
import { describe, it, expect } from 'vitest';
import { add, subtract } from './math';
describe('math utilities', () => {
it('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(add(-1, -1)).toBe(-2);
});
---);Jest (Mature Ecosystem)
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterSetup: ['./jest.setup.js'],
moduleNameMapper: {
'\\.(css|less)$': 'identity-obj-proxy',
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'],
---;Writing Unit Tests
Basic Patterns
// Pure function testing (most valuable)
function calculateTotal(items, taxRate) {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
return subtotal * (1 + taxRate);
---
describe('calculateTotal', () => {
it('calculates total with tax', () => {
const items = [{ price: 10 }, { price: 20 }];
expect(calculateTotal(items, 0.1)).toBe(33);
});
it('handles empty cart', () => {
expect(calculateTotal([], 0.1)).toBe(0);
});
it('handles zero tax', () => {
const items = [{ price: 50 }];
expect(calculateTotal(items, 0)).toBe(50);
});
---);Edge Cases
describe('formatDate', () => {
it('formats a valid date', () => {
expect(formatDate(new Date('2024-01-15'))).toBe('Jan 15, 2024');
});
it('throws for invalid date', () => {
expect(() => formatDate(new Date('invalid'))).toThrow('Invalid date');
});
it('handles null input', () => {
expect(formatDate(null)).toBe('');
});
---);Mocking
// Mocking modules
vi.mock('./api', () => ({
fetchUser: vi.fn(),
fetchPosts: vi.fn(),
---));
import { fetchUser } from './api';
describe('UserService', () => {
beforeEach(() => {
vi.clearAllMocks(); // reset between tests
});
it('fetches user successfully', async () => {
vi.mocked(fetchUser).mockResolvedValue({
id: 1,
name: 'Alice',
});
const user = await getUser(1);
expect(user.name).toBe('Alice');
expect(fetchUser).toHaveBeenCalledWith(1);
});
it('handles fetch failure', async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error('Network error'));
await expect(getUser(1)).rejects.toThrow('Network error');
});
---);Mocking fetch
// Global fetch mock
global.fetch = vi.fn();
function createMockResponse(data, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
};
---
describe('api client', () => {
it('handles successful response', async () => {
vi.mocked(fetch).mockResolvedValue(
createMockResponse({ id: 1, name: 'Alice' })
);
const result = await fetchUser(1);
expect(result.name).toBe('Alice');
});
it('handles HTTP error', async () => {
vi.mocked(fetch).mockResolvedValue(
createMockResponse({ error: 'Not found' }, 404)
);
await expect(fetchUser(999)).rejects.toThrow('HTTP 404');
});
---);Integration Testing
// Testing async operations end-to-end
describe('User signup flow', () => {
it('creates user, sends welcome email, and redirects', async () => {
const userData = {
email: 'test@example.com',
password: 'securePassword123',
name: 'Test User',
};
const result = await signup(userData);
// User was created
const user = await db.findUserByEmail(userData.email);
expect(user).toBeTruthy();
expect(user.name).toBe(userData.name);
// Welcome email was sent
expect(mailer.send).toHaveBeenCalledWith(
userData.email,
expect.stringContaining('Welcome')
);
// Session was created
expect(result.sessionToken).toBeTruthy();
});
---);Code Coverage
# Vitest
npx vitest run --coverage
# Jest
npx jest --coverageCoverage metrics to track:
- Line coverage — percentage of lines executed
- Branch coverage — percentage of control flow branches tested (if/else, switch)
- Function coverage — percentage of functions called
Set coverage thresholds in your config:
// vitest.config.js
test: {
coverage: {
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
---Best Practices
- Test behavior, not implementation — refactoring should not break tests
- One assertion concept per test — multiple assertions about the same behavior are fine
- Avoid testing private methods — test through the public API
- Use realistic data — fake data generators (faker.js) for integration tests
- Keep tests fast — slow tests discourage running them
- Write tests before fixing bugs — reproduce the bug in a test, then fix
- Run tests on CI — fail the build on test failures
Snapshot Testing
Snapshot tests capture the output of a function and compare it on subsequent runs:
import { describe, it, expect } from 'vitest';
function renderUserProfile(user) {
return {
displayName: `${user.firstName} ${user.lastName}`,
initials: `${user.firstName[0]}${user.lastName[0]}`,
isAdmin: user.roles.includes('admin'),
formattedDate: new Date(user.createdAt).toLocaleDateString(),
};
---
describe('renderUserProfile', () => {
it('matches snapshot', () => {
const user = {
firstName: 'Alice',
lastName: 'Smith',
roles: ['user', 'admin'],
createdAt: '2024-01-15T10:00:00Z',
};
expect(renderUserProfile(user)).toMatchSnapshot();
});
---);Test Coverage and Mutation Testing
Mutation testing evaluates test quality by introducing bugs:
# Stryker for JavaScript mutation testing
npx stryker runComponent Testing with Testing Library
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('submits form with valid data', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText('Email'), 'user@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'secure123');
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'secure123',
});
---);Real-World Implementation Tips
Production Considerations
When moving from development to production, several factors become critical. Error handling should be comprehensive — every external call (database, API, file system) should have proper error checking, logging, and retry logic where appropriate. Performance monitoring through metrics and structured logging helps identify bottlenecks before they affect users.
Testing Strategy
A thorough testing approach combines multiple levels:
- Unit tests verify individual functions and methods in isolation
- Integration tests validate that components work together correctly
- Edge case tests cover boundary conditions, empty inputs, and error states
- Performance tests ensure the system meets latency and throughput requirements
Test data should be realistic but controlled. Mock external dependencies to make tests fast and deterministic. Aim for tests that are independent, repeatable, and fast enough to run on every commit.
Documentation
Good documentation is essential for maintainable code. Follow these principles:
- Document the “why” not just the “what” — explain design decisions
- Keep examples up to date with the code
- Include usage examples for public APIs
- Document configuration options and their defaults
- Explain error conditions and recovery strategies
Security Best Practices
Security should be considered throughout development:
- Validate all inputs at system boundaries
- Use parameterized queries for database access
- Store secrets in environment variables or secret managers
- Keep dependencies updated to patch vulnerabilities
- Apply the principle of least privilege
Performance Optimization
Optimize based on measured data, not assumptions:
- Profile before optimizing — identify actual bottlenecks
- Measure the impact of each change
- Consider the trade-off between speed and readability
- Cache expensive operations with appropriate invalidation
- Use connection pooling for database and network resources
Monitoring and Observability
Production systems need visibility:
- Structured logging with correlation IDs for request tracking
- Metrics for latency, throughput, error rates, and resource usage
- Health check endpoints for load balancers and orchestration
- Distributed tracing for request flows across services
- Alerts for anomaly detection based on baselines
These patterns apply across all programming languages and frameworks. The specific implementation varies, but the principles remain consistent.
FAQ
Q: Should I use Jest or Vitest? A: Vitest is the modern choice — faster, native ESM support, compatible with Vite config, same API as Jest. Jest is more mature with larger ecosystem.
Q: What should I test in a React component? A: Test user interactions and rendered output, not internal state or lifecycle methods. Use React Testing Library for DOM-centric tests.
Q: How do I test async code?
A: Return the promise from your test, use async/await with expect, and use vi.useFakeTimers() for timer-dependent code.
Q: What is the difference between mock and stub? A: Mocks verify behavior (was this method called with these arguments?). Stubs provide predefined responses (return this value when called). Vitest and Jest use “mock” for both concepts.
Q: How do I test code that uses environment variables?
A: Set process.env values in beforeEach/afterEach: vi.stubEnv('API_KEY', 'test-key'). Restore with vi.unstubAllEnvs().
Q: Should I test 100% coverage? A: 100% coverage is rarely worth the effort. Focus on covering logic, edge cases, and error paths. Uncovered boilerplate (getters, simple props) is usually acceptable.
For a comprehensive overview, read our article on Javascript Arrays Guide.
For a comprehensive overview, read our article on Javascript Async Await.