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.
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;
}
}The cycle is:
- Write a test that describes desired behavior (it fails)
- Write code to make it pass (minimally)
- Refactor to improve quality
- 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
}
}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.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 returnReduces 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');
});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;
}
}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;
}
}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();
});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);
});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);
}
}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);
});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');
});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
More in this hub
Test-Driven Development: Writing Tests First, Code Second
6 / 10Previous
Article 5
Event Sourcing: State from History
Next
Article 7
Behavior-Driven Development: Bridging the Communication Gap
Also in this hub
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