JavaScript Testing Strategies: A Complete Guide for 2026
A comprehensive guide to testing JavaScript applications covering unit, integration, and e2e testing with modern tools.
The Testing Pyramid in 2026
The traditional testing pyramid (many unit tests, fewer integration tests, fewest e2e tests) still holds, but the tools and best practices have evolved significantly. Modern testing emphasizes testing behavior over implementation, uses native ESM, and leverages built-in test runners.
Unit Testing with Vitest
Vitest has become the default choice for JavaScript unit testing — it's fast, ESM-native, and compatible with the Jest API.
``javascript
// math.js
export function calculateDiscount(price, percentage) {
if (price < 0 || percentage < 0 || percentage > 100) {
throw new RangeError('Invalid input');
}
return price * (1 - percentage / 100);
}
// math.test.js import { describe, it, expect } from 'vitest'; import { calculateDiscount } from './math.js';
describe('calculateDiscount', () => { it('calculates percentage discount correctly', () => { expect(calculateDiscount(100, 20)).toBe(80); expect(calculateDiscount(50, 10)).toBe(45); });
it('handles edge cases', () => { expect(calculateDiscount(100, 0)).toBe(100); expect(calculateDiscount(100, 100)).toBe(0); expect(calculateDiscount(0, 50)).toBe(0); });
it('rejects invalid inputs', () => {
expect(() => calculateDiscount(-10, 20)).toThrow(RangeError);
expect(() => calculateDiscount(100, 150)).toThrow(RangeError);
});
});
`
Integration Testing: Testing Modules Together
Integration tests verify that multiple units work together correctly. Focus on the boundaries between modules.
`javascript
// userService.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { UserService } from './userService.js';
import { InMemoryDatabase } from './testUtils.js';
describe('UserService integration', () => { let service; let db;
beforeEach(() => { db = new InMemoryDatabase(); service = new UserService(db); });
it('creates user and retrieves by email', async () => { await service.createUser({ name: 'Alice', email: 'alice@example.com' });
const user = await service.findByEmail('alice@example.com'); expect(user).toMatchObject({ name: 'Alice', email: 'alice@example.com' }); expect(user.id).toBeDefined(); expect(user.createdAt).toBeInstanceOf(Date); });
it('prevents duplicate emails', async () => {
await service.createUser({ name: 'A', email: 'a@test.com' });
await expect(
service.createUser({ name: 'B', email: 'a@test.com' })
).rejects.toThrow('Email already exists');
});
});
`
Testing Async Code
`javascript
import { describe, it, expect, vi } from 'vitest';
// Mock fetch for API tests global.fetch = vi.fn();
describe('API client', () => { it('retries on failure', async () => { fetch .mockRejectedValueOnce(new Error('Network error')) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: 'success' }) });
const result = await fetchWithRetry('/api/data', { retries: 2 }); expect(result.data).toBe('success'); expect(fetch).toHaveBeenCalledTimes(2); });
it('throws after max retries', async () => { fetch.mockRejectedValue(new Error('Network error'));
await expect( fetchWithRetry('/api/data', { retries: 3 }) ).rejects.toThrow('Network error'); expect(fetch).toHaveBeenCalledTimes(4); // initial + 3 retries }); }); ``
Key Testing Principles
Test behavior, not implementation: Test what a function does, not how it does it. If you refactor internals, tests should still pass.
Use realistic data: Don't test with trivial inputs. Use data that resembles production scenarios.
Keep tests independent: Each test should set up its own state and not depend on the order of execution.
Aim for confidence, not coverage: 100% line coverage doesn't mean your app works. Focus on testing the critical paths and edge cases that matter.