Testing JavaScript: Practical Guide to Confident Code (2026)
Testing isn't about catching bugs — it's about having the confidence to change code without fear. Here's how to write tests that actually matter.
The Testing Pyramid
╱╲
╱ E2E╲ ← Few, slow, expensive
╱──────╲
╱ Integration╲ ← Some, medium speed
╱──────────────╲
╱ Unit Tests ╲ ← Many, fast, cheap
╱──────────────────╲
Rule of thumb:
- 70% Unit tests (fast, isolated)
- 20% Integration tests (test component interaction)
- 10% E2E tests (critical user flows only)
Unit test: Does this function work? (ms per test)
Integration: Do these modules work together? (seconds per test)
E2E: Can a user complete a purchase? (minutes per test)
Unit Testing with Vitest/Jest
// Setup (vitest.config.js or jest.config.js):
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.js'],
exclude: ['src/**/*.test.js', 'src/config/**'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
});
// Basic unit test:
import { describe, it, expect, beforeEach } from 'vitest';
import { Calculator } from '../calculator.js';
describe('Calculator', () => {
let calc;
beforeEach(() => {
calc = new Calculator();
});
describe('add()', () => {
it('should add two positive numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
it('should handle negative numbers', () => {
expect(calc.add(-1, -1)).toBe(-2);
});
it('should handle decimal numbers', () => {
expect(calc.add(0.1, 0.2)).toBeCloseTo(0.3); // Floating point!
});
it('should throw on non-number input', () => {
expect(() => calc.add('a', 2)).toThrow('Numbers only');
});
});
});
// Async testing:
describe('API Service', () => {
it('should fetch user data', async () => {
const user = await userService.findById('abc123');
expect(user).toBeDefined();
expect(user.id).toBe('abc123');
});
it('should throw on not found', async () => {
await expect(userService.findById('nonexistent'))
.rejects.toThrow('User not found');
});
// Mocking external dependencies:
it('should call API with correct params', async () => {
const mockFetch = vi.fn().mockResolvedValue({
json: async () => ({ id: '123', name: 'Test' })
});
global.fetch = mockFetch;
const result = await apiClient.getUser('123');
expect(mockFetch).toHaveBeenCalledWith('/api/users/123');
expect(result.name).toBe('Test');
});
});
Test Doubles: Mocks, Stubs & Spies
// Understanding when to use each:
// STUB: Replace real implementation with a fixed response
// Use when: You need to control dependencies' behavior deterministically
const dbStub = {
findById: vi.fn()
.mockResolvedValueOnce({ id: '1', name: 'Alice' })
.mockResolvedValueOnce({ id: '2', name: 'Bob' })
.mockRejectedValueOnce(new Error('DB error')),
};
// MOCK: Verify behavior (was it called? with what args?)
// Use when: You need to assert HOW your code uses dependencies
const emailServiceMock = {
sendWelcomeEmail: vi.fn(),
};
await registerNewUser({ email: 'test@example.com' });
expect(emailServiceMock.sendWelcomeEmail).toHaveBeenCalledTimes(1);
expect(emailServiceMock.sendWelcomeEmail).toHaveBeenCalledWith(
'test@example.com',
'Welcome!'
);
// SPY: Wrap real function to track calls without replacing behavior
// Use when: You want to keep original behavior but verify usage
const realLogger = console.log;
const logSpy = vi.spyOn(console, 'log');
processData();
expect(logSpy).toHaveBeenCalledWith('Processing started');
// Best practices for mocking:
// ✅ Mock at boundaries (database, file system, network, time)
// ✅ Keep mocks close to the code they mock (same folder or __mocks__/)
// ❌ Don't mock everything — if you mock everything, you're testing nothing
// ❌ Don't mock implementation details — test behavior, not internals
// Time mocking (essential for timers/deadlines):
vi.useFakeTimers();
const callback = vi.fn();
setTimeout(callback, 5000);
// Fast-forward 5 seconds instantly!
vi.advanceTimersByTime(5000);
expect(callback).toHaveBeenCalled();
// Or advance tick by tick:
vi.advanceTimersByTime(1000);
expect(callback).not.toHaveBeenCalled(); // Not yet!
vi.advanceTimersByTime(4000);
expect(callback).toHaveBeenCalled(); // Now!
vi.useRealTimers(); // Always restore!
Integration Testing
// Test that components work together correctly:
describe('Auth Flow Integration', () => {
// Use a test database (not production!)
beforeAll(async () => {
await testDb.migrate();
await testDb.seed(['users', 'sessions']);
});
afterAll(async () => {
await testDb.close();
});
it('should complete full login flow', async () => {
// Step 1: Register
const regRes = await request(app)
.post('/api/auth/register')
.send({ email: 'test@example.com', password: 'Password123!' })
.expect(201);
expect(regRes.body.data.email).toBe('test@example.com');
expect(regRes.body.data.token).toBeDefined();
// Step 2: Login with same credentials
const loginRes = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'Password123!' })
.expect(200);
// Step 3: Access protected endpoint with token
const profileRes = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${loginRes.body.token}`)
.expect(200);
expect(profileRes.body.data.email).toBe('test@example.com');
});
it('should reject expired token', async () => {
const expiredToken = jwt.sign({ sub: 'user1' }, secret, { expiresIn: '-1h' });
await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
});
});
// Database integration with test containers:
// Using testcontainers-js for real DB in tests:
import { PostgreSqlContainer } from '@testcontainers/postgresql';
let container;
let pool;
beforeAll(async () => {
container = await new PostgreSqlContainer().start();
pool = new Pool({ connectionString: container.getConnectionUri() });
await pool.query(`CREATE TABLE users (id UUID PRIMARY KEY, name TEXT)`);
});
afterAll(async () => {
await pool.end();
await container.stop();
});
What to Test (and What NOT to Test)
// ✅ TEST THIS:
// - Business logic and calculations
// - Authentication and authorization rules
// - Input validation and sanitization
// - Error handling paths (what happens when things go wrong?)
// - Critical user flows (login, checkout, data export)
// - Edge cases: empty arrays, null values, boundary numbers, unicode strings
// - Performance-sensitive operations (are they within acceptable time?)
// ❌ DON'T TEST THIS:
// - Third-party libraries (they have their own tests)
// - Framework internals (Express routing works, don't test it)
// - Implementation details (private methods, internal state)
// - Trivial getters/setters (one-liners)
// - Things you can't control (external APIs without mocking)
// The "useful test" litmus test:
// If this test fails, will it tell me exactly what's wrong?
// If I refactor the code (without changing behavior), will this test still pass?
// If the answer to both is YES → good test!
// If either is NO → reconsider the test.
// Test naming convention that tells a story:
it('should deny access when token has expired'); // Clear!
it('auth-001'); // Unclear — what does this test?
it('returns 401 for expired tokens'); // Better but vague
it('POST /api/protected → 401 when JWT exp < now'); // BEST — specific + actionable
What's your testing philosophy? TDD first, or test after? How much coverage do you aim for?
Follow @armorbreak for more practical developer guides.
Top comments (0)