Testing JavaScript: A Practical Guide to TDD with Jest (2026)
Testing isn't optional anymore. Here's how to actually do it — with real examples you can copy.
Why Test?
Without tests:
→ You're scared to refactor (what if I break something?)
→ Bugs get reintroduced ("regressions")
→ Code reviews take forever (reviewers have to mentally trace logic)
→ Onboarding is slow (no tests document expected behavior)
→ Deployments are stressful (did I break production?)
With tests:
→ Refactor confidently (tests tell you if something broke)
→ Catches regressions immediately
→ Code reviews focus on design, not correctness
→ Tests ARE documentation (executable specs)
→ Deployment is boring (in a good way) — just another day
Setup
# Install Jest
npm install --save-dev jest
# Or with TypeScript support:
npm install --save-dev jest ts-jest @types/jest
# Add to package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
# Run tests:
npm test # Run once
npm run test:watch # Watch mode (re-runs on file changes)
npm run test:coverage # With coverage report
// jest.config.js (or in package.json under "jest")
module.exports = {
// Where to find tests
testMatch: ['**/__tests__/**/*.js', '**/*.test.js', '**/*.spec.js'],
// Transform files (TypeScript, JSX, etc.)
transform: {
'^.+\\.jsx?$': 'babel-jest', // or 'ts-jest' for TypeScript
},
// Coverage settings
collectCoverageFrom: [
'src/**/*.{js,ts}',
'!src/**/*.test.{js,ts}',
'!src/index.ts', // Entry point usually doesn't need coverage
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
// Test environment (jsdom for DOM tests, node for pure JS)
testEnvironment: 'node',
// Setup file (runs before each test)
setupFilesAfterEnv: ['./tests/setup.js'],
};
The Three Types of Tests You Need
Unit Tests — Test One Thing in Isolation
// utils/price.js
export function calculateTotal(items, taxRate = 0.1) {
if (!items || items.length === 0) return 0;
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 + taxRate);
}
export function formatPrice(cents) {
const dollars = cents / 100;
return `$${dollars.toFixed(2)}`;
}
export function applyDiscount(total, discountCode) {
const discounts = { SAVE10: 0.1, SAVE20: 0.2, WELCOME: 0.25 };
if (!discounts[discountCode]) return total; // Invalid code = no discount
return total * (1 - discounts[discountCode]);
}
// __tests__/utils/price.test.js
const { calculateTotal, formatPrice, applyDiscount } = require('../../utils/price');
describe('calculateTotal', () => {
it('should return 0 for empty array', () => {
expect(calculateTotal([])).toBe(0);
});
it('should return 0 for null/undefined', () => {
expect(calculateTotal(null)).toBe(0);
expect(calculateTotal(undefined)).toBe(0);
});
it('should calculate subtotal with default tax', () => {
const items = [{ price: 100, quantity: 2 }];
expect(calculateTotal(items)).toBe(220); // 200 + 10% tax
});
it('should handle multiple items correctly', () => {
const items = [
{ price: 1000, quantity: 1 },
{ price: 500, quantity: 3 },
{ price: 50, quantity: 2 },
];
// Subtotal: 1000 + 1500 + 100 = 2600
// With 10% tax: 2860
expect(calculateTotal(items)).toBe(2860);
});
it('should use custom tax rate when provided', () => {
const items = [{ price: 100, quantity: 1 }];
expect(calculateTotal(items, 0.2)).toBe(120); // 100 + 20% tax
});
});
describe('formatPrice', () => {
it('should format cents to dollars', () => {
expect(formatPrice(1999)).toBe('$19.99');
expect(formatPrice(100)).toBe('$1.00');
expect(formatPrice(0)).toBe('$0.00');
});
it('should round to 2 decimal places', () => {
expect(formatPrice(333)).toBe('$3.33'); // 3.33 not 3.333...
});
});
describe('applyDiscount', () => {
it('should apply SAVE10 discount', () => {
expect(applyDiscount(100, 'SAVE10')).toBe(90);
});
it('should apply SAVE20 discount', () => {
expect(applyDiscount(100, 'SAVE20')).toBe(80);
});
it('should apply WELCOME discount (best deal)', () => {
expect(applyDiscount(100, 'WELCOME')).toBe(75);
});
it('should return original total for invalid code', () => {
expect(applyDiscount(100, 'INVALID')).toBe(100);
expect(applyDiscount(100, '')).toBe(100);
expect(applyDiscount(100, null)).toBe(100);
});
});
Integration Tests — Test Components Working Together
// services/orderService.js
const db = require('./db');
const { calculateTotal, applyDiscount } = require('../utils/price');
async function createOrder(userId, items, discountCode) {
const user = await db.users.findById(userId);
if (!user) throw new Error('User not found');
let total = calculateTotal(items);
if (discountCode) {
total = applyDiscount(total, discountCode);
}
const order = await db.orders.create({
userId,
items,
total,
status: 'pending',
});
return order;
}
// __tests__/services/orderService.test.js
const { createOrder } = require('../../services/orderService');
// Mock the database layer
jest.mock('../../services/db');
describe('createOrder', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should create order successfully', async () => {
const mockUser = { id: 'user_1', name: 'Test User' };
const mockOrder = { id: 'order_1', total: 110, status: 'pending' };
require('../../services/db').users.findById.mockResolvedValue(mockUser);
require('../../services/db').orders.create.mockResolvedValue(mockOrder);
const result = await createOrder(
'user_1',
[{ price: 100, quantity: 1 }]
);
expect(result).toEqual(mockOrder);
expect(require('../../services/db').orders.create).toHaveBeenCalledWith(
expect.objectContaining({ total: 110, status: 'pending' })
);
});
it('should throw error for non-existent user', async () => {
require('../../services/db').users.findById.mockResolvedValue(null);
await expect(createOrder('bad_id', []))
.rejects.toThrow('User not found');
});
it('should apply discount code when provided', async () => {
require('../../services/db').users.findById.mockResolvedValue({ id: 'u1' });
require('../../services/db').orders.create.mockImplementation(o => Promise.resolve({...o, id: 'o1'}));
const result = await createOrder(
'u1',
[{ price: 100, quantity: 1 }],
'SAVE10'
);
expect(result.total).toBe(90); // $100 - 10%
});
});
E2E / API Tests — Test Real HTTP Endpoints
// __tests__/api/orders.e2e.test.js
const request = require('supertest');
const app = require('../../app');
describe('POST /api/orders', () => {
it('should create an order and return 201', async () => {
const res = await request(app)
.post('/api/orders')
.send({
userId: 'user_1',
items: [{ productId: 'prod_1', quantity: 2 }],
discountCode: 'SAVE10',
})
.expect('Content-Type', /json/)
.expect(201);
expect(res.body).toMatchObject({
status: 'pending',
items: expect.arrayContaining([
expect.objectContaining({ quantity: 2 }),
]),
});
expect(res.body.total).toBeGreaterThan(0);
expect(res.body.id).toBeDefined();
});
it('should return 400 for invalid input', async () => {
const res = await request(app)
.post('/api/orders')
.send({}) // Missing required fields
.expect(400);
expect(res.body.error).toBeDefined();
});
it('should return 404 for non-existent user', async () => {
const res = await request(app)
.post('/api/orders')
.send({
userId: 'nonexistent_user',
items: [{ productId: 'p1', quantity: 1 }],
})
.expect(404);
});
});
TDD Workflow (Test-Driven Development)
RED → GREEN → REFACTOR (repeat)
1. RED: Write a failing test for the behavior you want
2. GREEN: Write the MINIMUM code to make the test pass
3. REFACTOR: Improve the code while keeping tests green
4. Repeat for next feature
// Example: Building a password validator via TDD
// Step 1: RED — Write failing test
describe('PasswordValidator', () => {
it('should reject passwords shorter than 8 characters', () => {
expect(validatePassword('abc123')).toBe(false);
});
it('should accept valid 8+ character passwords', () => {
expect(validatePassword('Abc123!@')).toBe(true);
});
});
// Run: FAILS (validatePassword doesn't exist yet)
// Step 2: GREEN — Minimum implementation
function validatePassword(password) {
return password.length >= 8;
}
// Run: PASSES ✅
// Step 3: Add more tests (back to RED)
it('should require at least one uppercase letter', () => {
expect(validatePassword('abcdefg1!')).toBe(false);
});
it('should require at least one number', () => {
expect(validatePassword('Abcdefg!')).toBe(false);
});
// Run: FAILS ❌
// Step 4: GREEN again
function validatePassword(password) {
if (password.length < 8) return false;
if (!/[A-Z]/.test(password)) return false;
if (!/[0-9]/.test(password)) return false;
return true;
}
// Run: PASSES ✅
// Step 5: REFACTOR (make cleaner without breaking tests)
function validatePassword(password) {
const rules = [
{ test: p => p.length >= 8, error: 'min 8 chars' },
{ test: p => /[A-Z]/.test(p), error: 'uppercase required' },
{ test: p => /[0-9]/.test(p), error: 'number required' },
];
return rules.every(rule => rule.test(password));
}
// Run: Still PASSES ✅ (refactoring didn't break anything!)
Common Patterns & Matchers
// Object shape matching (great for API responses)
expect(response).toMatchObject({
status: 'success',
data: expect.any(Array),
pagination: expect.objectContaining({ page: 1 }),
});
// Async testing
it('fetches data asynchronously', async () => {
const data = await fetchData('/api/users');
expect(data).toHaveLength(5);
});
// Error handling
it('throws with specific message', () => {
expect(() => riskyOperation()).toThrow('specific error text');
});
await expect(asyncFunction()).rejects.toThrow(ErrorType);
// Callback-style
it('calls callback with data', (done) => {
asyncWithCallback((err, data) => {
expect(err).toBeNull();
expect(data).toBeDefined();
done(); // Must call done()!
});
});
// Timers
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('debounces function calls', () => {
const fn = jest.fn();
const debouncedFn = debounce(fn, 500);
debouncedFn(); // Call 1
debouncedFn(); // Call 2
debouncedFn(); // Call 3
expect(fn).not.toHaveBeenCalled(); // Not called yet!
jest.advanceTimersByTime(500);
expect(fn).toHaveBeenCalledTimes(1); // Called once after delay!
});
// Spies
it('should call logger on error', () => {
const loggerSpy = jest.spyOn(console, 'error');
processWithError();
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('failed'));
loggerSpy.mockRestore(); // Always restore!
});
Testing Best Practices
✅ DO:
- Test behavior, not implementation (refactor-friendly)
- Use descriptive test names (reads like a sentence)
- One assertion per concept (use describe blocks)
- Test edge cases: null, empty, boundary values
- Keep tests fast (< 100ms per test ideally)
- Use meaningful test data (not always "foo", "bar")
- Setup/tear down in beforeEach/afterEach
❌ DON'T:
- Test private methods (test public behavior instead)
- Make tests depend on each other (isolation!)
- Ignore flaky tests (fix or delete them)
- Put logic in tests (if/else in tests = smell)
- Mock everything (integration tests need real connections)
- Skip writing tests because "it's simple"
- Use random data in tests (non-deterministic = flaky)
📏 Structure each test file:
describe('Module/Component', () => {
describe('functionName()', () => {
it('should do X when Y', () => {});
it('should throw when Z', () => {});
});
});
What's your testing philosophy? TDD purist or test-after?
Follow @armorbreak for more practical developer guides.
Top comments (0)