The Right Way to Write Tests: Unit, Integration, and E2E Tests
Have you heard the phrase "this code is untestable"? Or worse — have you said it yourself? That sentence is usually a problem with how the code was designed, not the code itself.
Writing tests is not a chore — it is a design tool. Code that is hard to test is usually poorly designed. Writing good tests is learnable — but first you need to understand what you are testing and why.
The Test Pyramid
/\
/ \
/ E2E\ Few, slow, expensive
/------\
/ \
/Integration\ Medium count, medium speed
/------------\
/ \
/ Unit Tests \ Many, fast, cheap
/------------------\
Unit tests (70%): Tests a single function or class. Dependencies are mocked. Runs in milliseconds.
Integration tests (20%): Tests multiple components working together. Real database, real services.
E2E tests (10%): Tests the application like a real user. Validates flows through a browser.
Unit Test: Fast and Isolated
describe("OrderService", () => { let orderService: OrderService; let mockOrderRepo: jest.Mocked<OrderRepository>; let mockEmailService: jest.Mocked<EmailService>; let mockInventoryService: jest.Mocked<InventoryService>; beforeEach(() => { mockOrderRepo = { create: jest.fn(), findById: jest.fn() } as any; mockEmailService = { sendConfirmation: jest.fn().mockResolvedValue(undefined) } as any; mockInventoryService = { checkStock: jest.fn() } as any; orderService = new OrderService( mockOrderRepo, mockEmailService, mockInventoryService ); }); it("should create order when stock is sufficient", async () => { // Arrange const userId = "user-123"; const items = [{ productId: "prod-1", quantity: 2, price: 50 }]; const expectedOrder = { id: "ord-1", userId, items, total: 100 }; mockInventoryService.checkStock.mockResolvedValue(10); mockOrderRepo.create.mockResolvedValue(expectedOrder); // Act const result = await orderService.createOrder(userId, items); // Assert expect(result).toEqual(expectedOrder); expect(mockOrderRepo.create).toHaveBeenCalledWith({ userId, items, total: 100 }); expect(mockEmailService.sendConfirmation).toHaveBeenCalledWith(userId, expectedOrder); }); it("should throw when stock is insufficient", async () => { mockInventoryService.checkStock.mockResolvedValue(1); const items = [{ productId: "prod-1", quantity: 5, price: 50 }]; await expect( orderService.createOrder("user-123", items) ).rejects.toThrow("Insufficient stock for product prod-1"); expect(mockOrderRepo.create).not.toHaveBeenCalled(); }); });
AAA Pattern: Arrange → Act → Assert. Every test should clearly contain these three phases.
Integration Test: With Real Connections
import request from "supertest"; import { app } from "../app"; import { db } from "../database"; describe("POST /api/orders", () => { beforeEach(async () => { await db.migrate.latest(); await db.seed.run(); }); afterEach(async () => { await db.migrate.rollback(); }); it("should create order and return 201", async () => { const response = await request(app) .post("/api/orders") .set("Authorization", "Bearer valid-test-token") .send({ items: [{ productId: "prod-1", quantity: 2 }] }) .expect(201); expect(response.body).toMatchObject({ id: expect.any(String), status: "pending", total: expect.any(Number) }); const order = await db("orders").where({ id: response.body.id }).first(); expect(order).toBeDefined(); }); it("should return 400 when items are missing", async () => { await request(app) .post("/api/orders") .set("Authorization", "Bearer valid-test-token") .send({}) .expect(400); }); });
E2E Test: Testing Like a User
import { test, expect } from "@playwright/test"; test("user can complete purchase", async ({ page }) => { await page.goto("/login"); await page.fill("input[name=email]", "test@example.com"); await page.fill("input[name=password]", "password123"); await page.click("button[type=submit]"); await page.goto("/products/laptop-pro"); await page.click("button[data-testid=add-to-cart]"); await page.goto("/cart"); await page.click("button[data-testid=checkout]"); await page.fill("input[name=card-number]", "4242424242424242"); await page.fill("input[name=expiry]", "12/26"); await page.fill("input[name=cvv]", "123"); await page.click("button[data-testid=pay]"); await expect(page).toHaveURL(/\/orders\/\w+\/success/); await expect(page.locator("h1")).toContainText("Order Confirmed"); });
TDD: Test-First Approach
Red: Write a failing test. No code yet.
Green: Write the minimum code to pass.
Refactor: Clean up without breaking the test.
// 1. RED: Test written, function does not exist yet it("should convert USD to EUR", () => { const result = convertCurrency(100, "USD", "EUR", 0.92); expect(result).toBe(92); }); // 2. GREEN: Minimum implementation function convertCurrency(amount: number, from: string, to: string, rate: number) { return amount * rate; } // 3. REFACTOR: Clean up, test still passes function convertCurrency( amount: number, fromCurrency: string, toCurrency: string, exchangeRate: number ): number { if (amount < 0) throw new Error("Amount cannot be negative"); return Math.round(amount * exchangeRate * 100) / 100; }
Test Coverage: A Misleading Metric
Targeting 100% coverage is usually the wrong focus. Coverage measures "was the code executed?" not "does it work correctly?"
// 100% coverage but tests nothing meaningful function add(a: number, b: number) { return a + b; } test("add function", () => { add(1, 2); // Called but result not checked expect(true).toBe(true); });
Coverage is a signal, not a safety net. Low coverage says "tests are missing here." High coverage does not say "this is safe."
What to focus on: is critical business logic fully tested, are edge cases covered, are tests actually verifying something?
Writing tests slows you down at first. But during maintenance, while refactoring, while adding new features — you get that investment back. A growing codebase without tests turns into a swamp where everything you build sinks.