Test-Driven Development: Write Tests First

Photo by Unsplash

Photo by Unsplash
Test-Driven Development (TDD) is a discipline where you write a failing test before you write a single line of production code. The Red-Green-Refactor cycle — write a failing test, make it pass with minimal code, then refactor without breaking tests — sounds counterintuitive at first, but it produces code that is genuinely testable by design, has dramatically lower defect rates, and is far easier to refactor safely. This post teaches TDD with real TypeScript and Jest examples you can follow along with.
TDD operates in three phases. Red: write a test that describes the behavior you want to add. The test must fail — if it passes without any new code, you've either written a poor test or the feature already exists. Green: write the minimum code necessary to make the test pass — no more. Resist the temptation to add extra logic. Refactor: clean up both the production code and the test with confidence, knowing your test suite will catch any regression.
Let's build a shopping cart with TDD. We start with the simplest possible test — can we add items and calculate a total? — and grow the implementation from there. Each test drives a new behavior. Notice how the final Cart class is naturally small and focused: TDD prevents over-engineering by forcing you to implement only what's tested.
// Step 1 — RED: write a failing test
// src/cart/cart.test.ts
import { describe, it, expect } from "vitest";
import { Cart } from "./cart";
describe("Cart", () => {
it("calculates total with quantity", () => {
const cart = new Cart();
cart.addItem({ id: "1", name: "Widget", price: 10, qty: 3 });
expect(cart.total()).toBe(30);
});
it("applies 10% discount when total exceeds 100", () => {
const cart = new Cart();
cart.addItem({ id: "1", name: "Widget", price: 50, qty: 3 });
expect(cart.total()).toBe(135); // 150 - 10%
});
});
// Step 2 — GREEN: write the minimal implementation
// src/cart/cart.ts
export class Cart {
private items: CartItem[] = [];
addItem(item: CartItem) { this.items.push(item); }
total(): number {
const subtotal = this.items.reduce((s, i) => s + i.price * i.qty, 0);
return subtotal > 100 ? subtotal * 0.9 : subtotal;
}
}
// Step 3 — REFACTOR: clean up without breaking tests
export class Cart {
private items: CartItem[] = [];
addItem(item: CartItem) { this.items.push(item); }
private subtotal(): number {
return this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
private applyBulkDiscount(amount: number): number {
const DISCOUNT_THRESHOLD = 100;
const DISCOUNT_RATE = 0.1;
return amount > DISCOUNT_THRESHOLD ? amount * (1 - DISCOUNT_RATE) : amount;
}
total(): number {
return this.applyBulkDiscount(this.subtotal());
}
}When following TDD, commit after each Green phase — before refactoring. This gives you a clean rollback point if a refactoring goes sideways, and it shows your team the incremental progress of TDD in the git history.
Real applications have external dependencies: databases, email services, payment gateways. TDD requires you to test units in isolation, which means replacing these dependencies with test doubles. Jest's vi.mock() (or jest.mock()) lets you replace modules with fakes that record their calls, allowing you to verify behavior without touching real infrastructure.
Mock external dependencies (HTTP calls, email services, payment processors) because they are slow, flaky, and expensive in tests. Use real implementations for pure business logic and data transformation. Use in-memory databases (like better-sqlite3 or a test Postgres instance) for tests that genuinely need to verify data persistence — mocking the database layer can give you false confidence if your queries are wrong.
Structure every test with three sections: Arrange (set up the preconditions — create instances, mock dependencies, seed data), Act (execute the single behavior under test), and Assert (verify the outcome). Keep each test focused on one behavior. When a test has multiple Act/Assert pairs, it's testing too many things and will give you poor failure diagnostics.
// Mocking external dependencies in Jest/Vitest
import { vi } from "vitest";
import { sendWelcomeEmail } from "./emailService";
import { UserService } from "./userService";
vi.mock("./emailService");
describe("UserService.register", () => {
it("sends a welcome email after successful registration", async () => {
const service = new UserService({ db: mockDb });
await service.register({ email: "test@example.com", name: "Alice" });
expect(sendWelcomeEmail).toHaveBeenCalledWith(
expect.objectContaining({ email: "test@example.com" })
);
});
it("throws when email already exists", async () => {
mockDb.findByEmail.mockResolvedValueOnce({ id: "existing" });
const service = new UserService({ db: mockDb });
await expect(service.register({ email: "dup@example.com", name: "Bob" }))
.rejects.toThrow("Email already registered");
});
});TDD is not universally applicable. Understanding where it provides the most value and where it introduces friction helps you apply it pragmatically rather than dogmatically.
TDD is most valuable for: business logic with complex rules (tax calculations, discount engines, validation logic), code that will be refactored frequently, bug fixes (write a test that reproduces the bug first, then fix it — the test ensures the bug never regresses), and APIs with well-defined contracts. In these contexts, the test suite becomes executable documentation that stays current with the code.
TDD works poorly for exploratory programming (when you don't yet know the right design), complex UI components where the 'right' output is visual and subjective, and one-off scripts that will never be maintained. Forcing TDD on these contexts creates tests that are tightly coupled to implementation details and break constantly with every change — the opposite of the stability TDD is meant to provide.
100% code coverage is not the goal of TDD. Coverage is a lagging indicator — it tells you what code is exercised, not whether the tests are meaningful. It's entirely possible to have 100% coverage with zero assertions. Instead, aim for high coverage on business-critical paths, and treat low coverage on edge cases as a risk signal worth investigating, not a number to game.
Setting up TDD in a TypeScript project requires choosing a test runner (Jest with ts-jest or the native Vitest), configuring path aliases to match your tsconfig, setting up code coverage reporting, and integrating your tests into your CI pipeline so every pull request is verified before merging.
Vitest is the modern choice for TypeScript projects, especially those using Vite or Next.js 15. It uses the same config as Vite, supports ESM natively, and has a significantly faster watch mode than Jest. For Jest, use ts-jest for direct TypeScript compilation or configure Jest with Babel and @babel/preset-typescript. Both support watch mode, coverage, and snapshot testing.
Co-locate unit tests with the code they test (cart.ts alongside cart.test.ts). Keep integration tests in a separate __tests__ directory or a dedicated /tests/integration folder. Use describe blocks to group related behaviors. Use it.todo() to document planned tests. Run unit tests on file save (--watch mode) and full integration tests in CI. This separation keeps fast feedback loops for development while ensuring the full suite runs on every merge.
Use Vitest's 'ui' mode (npx vitest --ui) for a browser-based test runner with real-time pass/fail visualization. It dramatically speeds up the TDD cycle when working on complex business logic.
TDD is not just a technical practice — it's a team culture. Start with a team agreement on minimum coverage thresholds (80% for new code is a common pragmatic floor). Require tests in pull requests. Celebrate test-driven bug fixes. Make tests a first-class citizen in code review, giving them the same scrutiny as production code. A good test suite is a team asset that saves hours of debugging every sprint.
Key testing concepts in this post include TDD, Red-Green-Refactor, mock, coverage, and AAA pattern.