Skip to content
Bitloops - Give your AI agents high-signal context in milliseconds.
HomeAbout us
DocsBlog
ResourcesSoftware DesignTest-Driven Development: Writing Tests First, Code Second

Test-Driven Development: Writing Tests First, Code Second

TDD isn't a testing strategy—it's a design technique that forces you to think about how your code will be used before you write it. The red-green-refactor loop changes how you design, making testability and clarity natural outcomes.

9 min readUpdated March 4, 2026Software Design

What TDD Actually Is

Test-Driven Development is a discipline where you write a failing test before writing code. You write the minimum code to make it pass. You refactor. Then you repeat.

It's often misunderstood as "testing." It's not. Testing is a quality gate at the end. TDD is a design technique that happens before code exists. Its close relative, Behavior-Driven Development, takes the same idea and expresses tests in business language.

// TDD workflow: red-green-refactor

// RED: Write a test that fails (code doesn't exist yet)
test('calculates order total with tax', () => {
  const order = new Order();
  order.addLineItem(product1, 2); // $10 each
  order.addLineItem(product2, 1); // $20

  const total = order.calculateTotal(0.10); // 10% tax
  expect(total).toBe(42); // (20 + 20) * 1.10
});

// GREEN: Write minimal code to make it pass
class Order {
  constructor() {
    this.items = [];
  }

  addLineItem(product, quantity) {
    this.items.push({ product, quantity });
  }

  calculateTotal(taxRate) {
    const subtotal = this.items.reduce(
      (sum, item) => sum + (item.product.price * item.quantity),
      0
    );
    return subtotal * (1 + taxRate);
  }
}

// REFACTOR: Improve without changing behavior
class Order {
  constructor() {
    this.lineItems = [];
  }

  addLineItem(product, quantity) {
    this.lineItems.push(new LineItem(product, quantity));
  }

  calculateTotal(taxRate) {
    const subtotal = this.getSubtotal();
    const tax = this.calculateTax(subtotal, taxRate);
    return subtotal + tax;
  }

  getSubtotal() {
    return this.lineItems.reduce((sum, item) => sum + item.getTotal(), 0);
  }

  calculateTax(subtotal, rate) {
    return subtotal * rate;
  }
}

class LineItem {
  constructor(product, quantity) {
    this.product = product;
    this.quantity = quantity;
  }

  getTotal() {
    return this.product.price * this.quantity;
  }
}
javascript

The cycle is:

  1. Write a test that describes desired behavior (it fails)
  2. Write code to make it pass (minimally)
  3. Refactor to improve quality
  4. Repeat for next behavior

Why This Works

Tests Drive Design

When you write tests first, you're forced to think about the interface before implementation. "What would I want this object to look like?" You don't write awkward APIs because you'd have to use them in tests. This feedback loop naturally pushes you toward SOLID principles — especially single responsibility and dependency inversion.

// Bad design (written without tests in mind)
class ReportGenerator {
  generateReport(dataSource, templatePath, outputPath, emailList, includeCharts, chartType, fontSize) {
    // Too many parameters, unclear relationships
  }
}

// TDD forces better design
// Start by writing what you want to use:
test('generates report from data source', () => {
  const generator = new ReportGenerator(dataSource);
  const report = generator.generate(template);

  expect(report.text).toBeDefined();
});

// This drives you to:
class ReportGenerator {
  constructor(dataSource) {
    this.dataSource = dataSource;
  }

  generate(template) {
    // Simpler, clearer API
  }
}
javascript

Regression Safety

You have a regression test suite. Every change is tested. You can refactor fearlessly. You can optimize without fear of breaking things.

// After months of development, someone needs to optimize the payment calculation
// Instead of being scared:
// "Will this break something?"

// They run tests:
// "Oh, all tests pass? Safe to merge."

// Without tests, they'd have to manually check everything
// Humans are bad at this. Tests are perfect.
javascript

Living Documentation

Tests show how to use the code. They're examples that stay current (or tests fail).

// This test documents behavior better than any comment
test('order total includes tax', () => {
  const order = new Order();
  order.addLineItem(product, 1); // Product costs $100
  const total = order.calculateTotal(0.10);
  expect(total).toBe(110); // 100 + 10 tax
});

// Anyone reading this test learns:
// 1. How to create an Order
// 2. How to add line items
// 3. What calculateTotal expects
// 4. What it should return
javascript

Reduces Bugs at the Source

Bugs often come from unclear requirements. When you write tests, you're forced to clarify. "What exactly should happen when X?" Questions get answered before coding.

The TDD Cycle in Detail

Red: Write a Failing Test

Start with what you want to exist. Be specific.

test('customer can place order with valid items', () => {
  const customer = new Customer('Alice');
  const product = new Product('Widget', 10);

  const order = customer.createOrder();
  order.addItem(product, 2);

  expect(order.total).toBe(20);
  expect(order.status).toBe('created');
});
javascript

This test fails because Customer, createOrder, Product, etc. don't exist. Good. That's the point.

Green: Write Minimal Code

The absolute least code to make the test pass.

class Customer {
  constructor(name) {
    this.name = name;
  }

  createOrder() {
    return new Order();
  }
}

class Order {
  constructor() {
    this.items = [];
    this.status = 'created';
  }

  addItem(product, quantity) {
    this.items.push({ product, quantity });
  }

  get total() {
    return this.items.reduce((sum, item) => sum + (item.product.price * item.quantity), 0);
  }
}

class Product {
  constructor(name, price) {
    this.name = name;
    this.price = price;
  }
}
javascript

This is minimal. It doesn't handle edge cases, doesn't optimize, doesn't validate. It just makes the test pass.

Refactor: Improve Quality

Now improve: extract methods, eliminate duplication, clarify names, strengthen design.

class Order {
  constructor() {
    this.items = [];
    this.status = 'created';
  }

  addItem(product, quantity) {
    if (quantity <= 0) {
      throw new Error('Quantity must be positive');
    }
    this.items.push(new OrderItem(product, quantity));
  }

  get total() {
    return this.items.reduce((sum, item) => sum + item.getTotal(), 0);
  }
}

class OrderItem {
  constructor(product, quantity) {
    this.product = product;
    this.quantity = quantity;
  }

  getTotal() {
    return this.product.price * this.quantity;
  }
}
javascript

Notice we added validation and extracted OrderItem. Tests still pass. We've improved without breaking anything.

Repeat

Next test:

test('cannot add invalid quantity to order', () => {
  const order = new Order();
  const product = new Product('Widget', 10);

  expect(() => order.addItem(product, 0)).toThrow();
  expect(() => order.addItem(product, -5)).toThrow();
});
javascript

Guess what? This test already passes because we added validation in the refactor phase. That's fine. Some tests pass immediately. Some drive new code.

TDD vs. Traditional Testing

Traditional Testing: Test After

// Write code first (however you want)
class OrderCalculator {
  getTotal(items, taxRate, discountPercent) {
    let total = 0;
    for (let i = 0; i < items.length; i++) {
      total += items[i].price * items[i].qty;
    }
    total = total * (1 - discountPercent);
    total = total * (1 + taxRate);
    return total;
  }
}

// Then write tests (trying to cover everything)
test('basic calculation', () => {
  const items = [{ price: 10, qty: 2 }];
  expect(new OrderCalculator().getTotal(items, 0.1, 0)).toBe(22);
});
javascript

Problem: The design is already fixed. You're testing an awkward API because you had no reason to think about it while writing it. You're writing tests after the fact, just trying to pass them.

TDD: Test First

// Start with what you want to use
test('calculates order total with discount and tax', () => {
  const order = new Order();
  order.addItem(new Product(10), 2);
  order.setDiscount(0.1); // 10% discount
  order.setTaxRate(0.1); // 10% tax

  expect(order.getTotal()).toBe(21.6); // (20 - 2) * 1.1
});

// This drives a cleaner design
class Order {
  constructor() {
    this.items = [];
    this.discountRate = 0;
    this.taxRate = 0;
  }

  addItem(product, quantity) {
    this.items.push({ product, quantity });
  }

  setDiscount(rate) {
    this.discountRate = rate;
  }

  setTaxRate(rate) {
    this.taxRate = rate;
  }

  getTotal() {
    const subtotal = this.items.reduce(
      (sum, item) => sum + (item.product.price * item.quantity),
      0
    );
    const discounted = subtotal * (1 - this.discountRate);
    return discounted * (1 + this.taxRate);
  }
}
javascript

The API is clearer because you thought about using it before writing it.

Common TDD Mistakes

Testing Implementation, Not Behavior

// Bad: Testing the how
test('order stores items in array', () => {
  const order = new Order();
  order.addItem(product, 2);
  expect(order.items[0].product).toBe(product);
  expect(order.items.length).toBe(1);
});

// Good: Testing the what
test('order total includes all items', () => {
  const order = new Order();
  order.addItem(product1, 2);
  order.addItem(product2, 1);
  expect(order.getTotal()).toBe(expectedTotal);
});
javascript

The second test is better. It doesn't care how items are stored. If you refactor to use a Set or Map instead of an array, the test still passes.

Testing Too Much at Once

// Bad: One giant test
test('complex workflow', () => {
  const customer = new Customer('Alice');
  const product = new Product('Widget', 10);
  const order = customer.createOrder();
  order.addItem(product, 2);
  order.applyDiscount(0.1);
  order.pay(paymentCard);
  expect(order.status).toBe('paid');
  // Plus 10 more assertions...
});

// Good: One test, one behavior
test('customer can create order', () => {
  const customer = new Customer('Alice');
  const order = customer.createOrder();
  expect(order).toBeDefined();
});

test('order total includes discount', () => {
  const order = new Order();
  order.addItem(product, 2); // $20
  order.applyDiscount(0.1); // 10% off = $18
  expect(order.getTotal()).toBe(18);
});

test('order can be paid with valid card', () => {
  const order = new Order();
  order.addItem(product, 1);
  order.pay(validCard);
  expect(order.status).toBe('paid');
});
javascript

Multiple focused tests. Each one tests one behavior. When one fails, you know exactly what broke.

Writing Tests After the Code is Already Written

This defeats the entire purpose. TDD is about writing tests first.

When TDD Doesn't Fit

Spike/Exploration Code

When you're experimenting, figuring out how something works, don't write tests first. Spike it out. Once you understand, go back and write it with TDD.

UI Code

Testing UI is hard. You can test business logic with TDD. UI tests are often brittle. Test the logic; verify the UI manually or with integration tests. For a broader view of how different test levels fit together, see testing strategies for large systems.

Third-Party Libraries

You can't write tests first for code you don't own. But you can write tests for how you use it.

FAQ

Doesn't TDD take longer?

Initially, it feels slower. But you spend less time debugging and refactoring later. For complex domains, TDD saves time overall.

What if I don't know the design beforehand?

TDD forces you to think about it. Write a test describing what you want. That thinking guides your design.

Should every line of code be tested?

No. Don't test getters/setters or trivial code. Test business logic, complex behavior, and edge cases.

Can I use TDD with legacy code?

It's harder but possible. Write tests around the code you're about to change. Make the change. This builds safety.

How much should I test?

Enough that you're confident the code works. Enough that refactoring doesn't terrify you. More than that is diminishing returns.

Primary Sources

  • Kent Beck's foundational guide to test-driven development and red-green-refactor cycle. TDD by Example
  • Steve Freeman and Nat Pryce's practical approach to designing systems with tests. Growing OO Software
  • Robert Martin's handbook on writing clean code and developing with tests. Clean Code
  • Eric Evans' domain-driven design foundational text for behavioral specifications. Domain-Driven Design
  • Practical guide for implementing domain-driven design with test-driven development. Implementing DDD
  • Michael Feathers' guide to writing tests for legacy systems effectively. Working with Legacy Code

Get Started with Bitloops.

Apply what you learn in these hubs to real AI-assisted delivery workflows with shared context, traceable reasoning, and architecture-aware engineering practices.

curl -sSL https://bitloops.com/install.sh | bash