The most surprising thing about testing is that the same code can pass all three types of tests and still be fundamentally broken in production.
Let’s see how these tests play out in a real Jest setup. Imagine we’re building a simple e-commerce checkout service.
Here’s a basic package.json with Jest configured:
{
"name": "checkout-tests",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"jest": "^29.7.0"
}
}
Now, let’s create some code and tests.
Unit Tests: The Microscopic View
Unit tests focus on the smallest testable parts of your application, typically individual functions or methods. They should be fast, isolated, and have no external dependencies.
Consider a function that calculates the total price of items, including tax.
src/pricing.js
export function calculateItemPrice(quantity, unitPrice) {
if (quantity < 0 || unitPrice < 0) {
throw new Error("Quantity and unit price cannot be negative.");
}
return quantity * unitPrice;
}
export function calculateSubtotal(items) {
return items.reduce((total, item) => total + calculateItemPrice(item.quantity, item.unitPrice), 0);
}
export function calculateTax(amount, taxRate) {
if (taxRate < 0 || taxRate > 1) {
throw new Error("Tax rate must be between 0 and 1.");
}
return amount * taxRate;
}
export function calculateTotal(subtotal, tax) {
return subtotal + tax;
}
__tests__/pricing.unit.test.js
import { calculateItemPrice, calculateSubtotal, calculateTax, calculateTotal } from '../src/pricing';
describe('Pricing Unit Tests', () => {
// Test a single, isolated function
test('calculateItemPrice should correctly multiply quantity and unit price', () => {
expect(calculateItemPrice(2, 10.50)).toBe(21.00);
});
// Test another isolated function
test('calculateTax should correctly apply the tax rate', () => {
expect(calculateTax(100, 0.08)).toBe(8.00);
});
// Test a function that uses other functions internally
// We mock dependencies if they were external, but here they are internal helpers.
test('calculateSubtotal should sum prices of multiple items', () => {
const items = [
{ quantity: 2, unitPrice: 10.00 },
{ quantity: 1, unitPrice: 5.00 },
];
expect(calculateSubtotal(items)).toBe(25.00);
});
// Test edge cases and error handling
test('calculateItemPrice should throw error for negative quantity', () => {
expect(() => calculateItemPrice(-1, 10)).toThrow("Quantity and unit price cannot be negative.");
});
test('calculateTax should throw error for invalid tax rate', () => {
expect(() => calculateTax(100, 1.5)).toThrow("Tax rate must be between 0 and 1.");
});
});
To run these, you’d use npm test. Jest, by default, finds files ending in .test.js or .spec.js.
Integration Tests: The Collaboration
Integration tests verify that different modules or components of your system work together as expected. They test the interactions between units.
Let’s say we have a checkoutService that uses the pricing functions.
src/checkoutService.js
import { calculateSubtotal, calculateTax, calculateTotal } from './pricing';
export class CheckoutService {
constructor(taxRate) {
if (taxRate < 0 || taxRate > 1) {
throw new Error("Tax rate must be between 0 and 1.");
}
this.taxRate = taxRate;
}
calculateOrderTotal(items) {
const subtotal = calculateSubtotal(items);
const tax = calculateTax(subtotal, this.taxRate);
const total = calculateTotal(subtotal, tax);
return {
subtotal: parseFloat(subtotal.toFixed(2)),
tax: parseFloat(tax.toFixed(2)),
total: parseFloat(total.toFixed(2)),
};
}
}
__tests__/checkoutService.integration.test.js
import { CheckoutService } from '../src/checkoutService';
// We are NOT importing 'calculateSubtotal', 'calculateTax', 'calculateTotal' directly here
// because we want to test how CheckoutService *uses* them.
describe('CheckoutService Integration Tests', () => {
let checkoutService;
beforeEach(() => {
// Instantiate the service with a specific tax rate for this test suite
checkoutService = new CheckoutService(0.08); // 8% tax
});
test('calculateOrderTotal should correctly compute subtotal, tax, and total for multiple items', () => {
const items = [
{ quantity: 2, unitPrice: 10.00 }, // item 1: $20.00
{ quantity: 1, unitPrice: 5.00 }, // item 2: $5.00
];
// Expected subtotal: 20 + 5 = $25.00
// Expected tax: 25 * 0.08 = $2.00
// Expected total: 25 + 2 = $27.00
const result = checkoutService.calculateOrderTotal(items);
expect(result.subtotal).toBe(25.00);
expect(result.tax).toBe(2.00);
expect(result.total).toBe(27.00);
});
test('calculateOrderTotal should handle an empty cart correctly', () => {
const items = [];
// Expected subtotal: $0.00
// Expected tax: $0.00
// Expected total: $0.00
const result = checkoutService.calculateOrderTotal(items);
expect(result.subtotal).toBe(0.00);
expect(result.tax).toBe(0.00);
expect(result.total).toBe(0.00);
});
test('CheckoutService constructor should throw error for invalid tax rate', () => {
expect(() => new CheckoutService(1.5)).toThrow("Tax rate must be between 0 and 1.");
});
});
In integration tests, we’re less concerned with mocking the internal pricing functions. We want to ensure that when CheckoutService calls calculateSubtotal, calculateTax, and calculateTotal, they produce the correct combined result.
End-to-End (E2E) Tests: The User’s Journey
E2E tests simulate a real user’s interaction with your entire application, from the UI to the backend and any external services. They are the most comprehensive but also the slowest and most brittle.
For this example, let’s imagine a simple Express.js API endpoint that exposes the checkout service.
src/server.js
const express = require('express');
const { CheckoutService } = require('./checkoutService');
const app = express();
const port = 3000;
app.use(express.json()); // Middleware to parse JSON bodies
app.post('/checkout', (req, res) => {
const { items, taxRate } = req.body;
if (!items || !Array.isArray(items)) {
return res.status(400).json({ error: 'Invalid items format. Expected an array.' });
}
if (taxRate === undefined || typeof taxRate !== 'number') {
return res.status(400).json({ error: 'Invalid taxRate. Expected a number.' });
}
try {
const checkoutService = new CheckoutService(taxRate);
const result = checkoutService.calculateOrderTotal(items);
res.json(result);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// For E2E testing, we need to be able to start and stop the server.
let server;
const startServer = (portToStart = port) => {
return new Promise((resolve) => {
server = app.listen(portToStart, () => {
console.log(`Checkout server listening on port ${portToStart}`);
resolve();
});
});
};
const stopServer = () => {
return new Promise((resolve, reject) => {
if (server) {
server.close((err) => {
if (err) return reject(err);
resolve();
});
} else {
resolve(); // Server wasn't running
}
});
};
module.exports = { startServer, stopServer };
To run E2E tests, you’ll typically need a test runner like jest-puppeteer or supertest to interact with your server without a full browser. We’ll use supertest here.
First, install it:
npm install --save-dev supertest
__tests__/checkout.e2e.test.js
const request = require('supertest');
const { startServer, stopServer } = require('../src/server');
const API_URL = 'http://localhost:3001'; // Using a different port for tests
const TEST_PORT = 3001;
describe('Checkout API E2E Tests', () => {
beforeAll(async () => {
// Start the server before any tests run
await startServer(TEST_PORT);
});
afterAll(async () => {
// Stop the server after all tests are done
await stopServer();
});
test('POST /checkout should return correct total for a valid order', async () => {
const orderData = {
items: [
{ quantity: 2, unitPrice: 10.00 },
{ quantity: 1, unitPrice: 5.00 },
],
taxRate: 0.08,
};
const response = await request(API_URL)
.post('/checkout')
.send(orderData);
expect(response.status).toBe(200);
expect(response.body).toEqual({
subtotal: 25.00,
tax: 2.00,
total: 27.00,
});
});
test('POST /checkout should return error for invalid items', async () => {
const orderData = {
items: "not an array", // Invalid format
taxRate: 0.08,
};
const response = await request(API_URL)
.post('/checkout')
.send(orderData);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error', 'Invalid items format. Expected an array.');
});
test('POST /checkout should return error for invalid tax rate', async () => {
const orderData = {
items: [{ quantity: 1, unitPrice: 10 }],
taxRate: 1.5, // Invalid rate
};
const response = await request(API_URL)
.post('/checkout')
.send(orderData);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error', 'Tax rate must be between 0 and 1.');
});
});
Notice how E2E tests make actual HTTP requests to a running server. They test the entire flow from receiving a request to sending a response, encompassing all the underlying components and logic.
The mental model is a pyramid: Unit tests form the broad base (many, fast), integration tests are the middle layer (fewer, slower), and E2E tests are the narrow top (fewest, slowest). While unit tests verify individual pieces, E2E tests confirm the user’s experience.
The single most potent way to make E2E tests faster and more reliable is to avoid full browser automation when possible. For API-driven E2E tests, tools like supertest interact directly with your application’s HTTP layer, bypassing the browser’s overhead and making tests run orders of magnitude faster.
The next logical step is to explore contract testing for your APIs, ensuring that consumers and providers agree on the expected request/response structure.