TDD vs BDD: Comparing Test-Driven Development Strategies
"Doesn't writing tests first slow you down?" Every time I hear this, I remember when I first learned TDD. I thought the same thing. I'm barely keeping up with the code, and now I'm supposed to write tests first? It seemed backwards.
Then I actually tried it. Writing tests first forces you to clarify what you're building before you build it. That clarity ends up making you faster — and more correct.
This post compares TDD and BDD, two test-driven development strategies, with practical TypeScript examples showing when to reach for each.
What Is TDD?
TDD (Test-Driven Development) was formalized by Kent Beck in 1999 as part of Extreme Programming. The core is three steps:
Red → Green → Refactor
Red: Write a Failing Test First
// 1. Write a test for a function that doesn't exist yet
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './discount';
describe('calculateDiscount', () => {
it('applies 10% discount for purchases over 100,000 KRW', () => {
expect(calculateDiscount(100000)).toBe(90000);
});
it('no discount for purchases under 100,000 KRW', () => {
expect(calculateDiscount(50000)).toBe(50000);
});
it('returns 0 for 0 amount', () => {
expect(calculateDiscount(0)).toBe(0);
});
it('throws for negative amounts', () => {
expect(() => calculateDiscount(-1000)).toThrow('Amount must be 0 or greater');
});
});
Run this and it fails — calculateDiscount doesn't exist. That's the Red phase.
Green: Write the Minimum Code to Pass
// 2. Write just enough to make tests pass
export function calculateDiscount(amount: number): number {
if (amount < 0) {
throw new Error('Amount must be 0 or greater');
}
if (amount >= 100000) {
return amount * 0.9;
}
return amount;
}
Tests pass. Code doesn't have to be perfect — just green. That's the Green phase.
Refactor: Clean Up Without Breaking Tests
// 3. Improve code quality while keeping tests green
const DISCOUNT_THRESHOLD = 100_000;
const DISCOUNT_RATE = 0.1;
export function calculateDiscount(amount: number): number {
if (amount < 0) {
throw new Error('Amount must be 0 or greater');
}
const discountAmount = amount >= DISCOUNT_THRESHOLD
? amount * DISCOUNT_RATE
: 0;
return amount - discountAmount;
}
Magic numbers extracted to constants, logic made explicit. Tests still green. That's the Refactor phase.
Repeat this cycle in very short intervals — 5 to 15 minutes. That's TDD.
Why TDD Improves Design
TDD's real value isn't faster bug-catching. It's that it forces better design.
When a test feels hard to write, that's a signal the code design has a problem.
// Code that's hard to test
class OrderService {
async processOrder(orderId: string) {
// Direct DB access
const order = await db.query(`SELECT * FROM orders WHERE id = '${orderId}'`);
// Direct external API call
const payment = await fetch('https://payment.api/charge', {
method: 'POST',
body: JSON.stringify({ amount: order.total })
});
// Direct email sending
await sendEmail(order.userEmail, 'Order Confirmed', '...');
return { success: true };
}
}
Testing this requires a real DB, real payment API, and real email server. Nearly untestable.
If you had written this with TDD from the start, dependency injection would emerge naturally:
// The structure TDD naturally produces
interface OrderRepository {
findById(id: string): Promise<Order>;
}
interface PaymentGateway {
charge(amount: number): Promise<PaymentResult>;
}
interface EmailService {
send(to: string, subject: string, body: string): Promise<void>;
}
class OrderService {
constructor(
private orderRepo: OrderRepository,
private paymentGateway: PaymentGateway,
private emailService: EmailService
) {}
async processOrder(orderId: string) {
const order = await this.orderRepo.findById(orderId);
const payment = await this.paymentGateway.charge(order.total);
await this.emailService.send(order.userEmail, 'Order Confirmed', '...');
return { success: true };
}
}
Now each dependency is swappable with a mock:
describe('OrderService', () => {
it('charges payment then sends email on success', async () => {
// Arrange
const mockOrder = { id: '123', total: 50000, userEmail: 'test@example.com' };
const mockOrderRepo = { findById: vi.fn().mockResolvedValue(mockOrder) };
const mockPayment = { charge: vi.fn().mockResolvedValue({ success: true }) };
const mockEmail = { send: vi.fn().mockResolvedValue(undefined) };
const service = new OrderService(mockOrderRepo, mockPayment, mockEmail);
// Act
await service.processOrder('123');
// Assert
expect(mockPayment.charge).toHaveBeenCalledWith(50000);
expect(mockEmail.send).toHaveBeenCalledWith(
'test@example.com',
'Order Confirmed',
expect.any(String)
);
});
});
TDD is a design methodology that happens to produce tests. That's the key insight.
What Is BDD?
BDD (Behavior-Driven Development) was proposed by Dan North in 2006 as an evolution of TDD. It shifts focus from "how do we test?" to "what behavior do we test?"
The core idea: describe behavior in language that developers, QA, and business people all understand.
Gherkin Syntax
The most widely used notation in BDD is Gherkin:
Feature: Discount Calculation
Apply discount policy to purchase amounts
Scenario: Discount applied for purchases over 100,000 KRW
Given the purchase amount is 100,000 KRW
When the discount is calculated
Then the discounted amount should be 90,000 KRW
Scenario: No discount for purchases under 100,000 KRW
Given the purchase amount is 50,000 KRW
When the discount is calculated
Then the original amount 50,000 KRW is returned
Scenario: Negative amount throws error
Given the purchase amount is -1,000 KRW
When the discount is calculated
Then an error "Amount must be 0 or greater" is thrown
A product manager or client can read this and verify it matches requirements.
BDD-Style Tests in TypeScript
In practice, many teams write BDD-style tests using describe/it without a full Gherkin setup:
// BDD style without Gherkin
describe('Discount calculation', () => {
describe('when a user purchases 100,000 KRW or more', () => {
it('applies a 10% discount', () => {
// Given
const purchaseAmount = 100_000;
// When
const discountedPrice = calculateDiscount(purchaseAmount);
// Then
expect(discountedPrice).toBe(90_000);
});
});
describe('when a user purchases under 100,000 KRW', () => {
it('applies no discount', () => {
const purchaseAmount = 50_000;
const discountedPrice = calculateDiscount(purchaseAmount);
expect(discountedPrice).toBe(50_000);
});
});
});
Wiring Gherkin with Cucumber.js
For full BDD with Gherkin, Cucumber.js connects feature files to step definitions:
// features/step_definitions/discount.steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import { calculateDiscount } from '../../src/discount';
let purchaseAmount: number;
let result: number;
Given('the purchase amount is {int} KRW', (amount: number) => {
purchaseAmount = amount;
});
When('the discount is calculated', () => {
result = calculateDiscount(purchaseAmount);
});
Then('the discounted amount should be {int} KRW', (expected: number) => {
expect(result).to.equal(expected);
});
TDD vs BDD: Key Differences
| Aspect | TDD | BDD |
|---|---|---|
| Focus | How to implement | What behavior to specify |
| Language | Technical | Business |
| Audience | Developers | Developers + Business |
| Test unit | Function/method | Feature/scenario |
| Tooling | Jest, Vitest | Cucumber, Jest/Vitest |
| Entry barrier | Low | Medium |
| Team communication | Dev-centered | Cross-team |
When to Use Each
TDD Fits Best When:
// 1. Algorithms and utility functions
// Clear inputs/outputs, complex logic
describe('parsePhoneNumber', () => {
it('parses 010-1234-5678 format', () => {
expect(parsePhoneNumber('010-1234-5678')).toEqual({
countryCode: '+82',
number: '01012345678'
});
});
});
// 2. Data transformation logic
// 3. Business rule implementation
// 4. Bug fixes (write a failing test reproducing the bug first)
TDD excels at "does this function behave correctly?"
BDD Fits Best When:
# Complex workflows requiring business alignment
Feature: Subscription Management
Scenario: Auto-billing after trial ends with payment method
Given the user is in a 14-day free trial
And a payment method is registered
When the free trial period ends
Then automatic billing for the base plan is processed
And a payment confirmation email is sent to the user
Scenario: Trial ends without payment method
Given the user is in a 14-day free trial
And no payment method is registered
When the free trial period ends
Then the account is suspended
And a payment method registration notification is sent
BDD excels at scenarios where the business team needs to verify the behavior directly.
Common Misconceptions
Misconception 1: "TDD means writing more code"
True, upfront you write more. But factor in debugging time and bug-fix time later, and total development time often goes down. Research shows TDD increases initial development time by 15–35% but reduces defects by 40–80%. Fixing bugs is far more expensive than writing them.
Misconception 2: "100% coverage is the goal"
// Coverage with no real assertion
it('function runs', () => {
expect(() => processOrder('123')).not.toThrow();
// Tests that it runs, not that it does the right thing
});
Meaningful test cases beat 100% coverage every time.
Misconception 3: "BDD requires Cucumber"
BDD is a philosophy, not a tool. BDD-style describe/it works fine:
describe('Given a logged-in user', () => {
describe('When they access the admin page', () => {
it('Then a permission denied error is returned', () => {
// ...
});
});
});
Misconception 4: "You have to pick one"
Mixing them is natural:
- Internal implementation: TDD (unit tests)
- User-facing scenarios: BDD (integration/E2E tests)
Hands-On: Building a Shopping Cart with TDD
Let's build a simple cart from scratch:
// Cycle 1: Red
describe('ShoppingCart', () => {
it('starts empty', () => {
const cart = new ShoppingCart();
expect(cart.items).toHaveLength(0);
expect(cart.total).toBe(0);
});
});
// → Fails. ShoppingCart doesn't exist. Green:
class ShoppingCart {
items: CartItem[] = [];
get total() { return 0; }
}
// Cycle 2: Red
it('adds an item to the cart', () => {
const cart = new ShoppingCart();
cart.add({ id: '1', name: 'Laptop', price: 1_000_000, quantity: 1 });
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(1_000_000);
});
// → Fails. Green:
class ShoppingCart {
items: CartItem[] = [];
add(item: CartItem) {
this.items.push(item);
}
get total() {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
// Cycle 3: Red
it('increments quantity when adding duplicate item', () => {
const cart = new ShoppingCart();
const laptop = { id: '1', name: 'Laptop', price: 1_000_000, quantity: 1 };
cart.add(laptop);
cart.add(laptop);
expect(cart.items).toHaveLength(1); // not 2 items
expect(cart.items[0].quantity).toBe(2);
expect(cart.total).toBe(2_000_000);
});
// → Fails. Green + Refactor:
class ShoppingCart {
items: CartItem[] = [];
add(item: CartItem) {
const existing = this.items.find(i => i.id === item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
this.items.push({ ...item });
}
}
get total() {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
Short cycles. Code always works. Always test-protected.
Closing: Not Either/Or — Both
TDD and BDD aren't competing. TDD helps design at the implementation level. BDD clarifies requirements at the feature level.
If you're starting out, learn TDD first. Internalize the Red-Green-Refactor cycle in your hands. BDD layers naturally on top of that.
The tool matters less than the habit: writing "how should this behave?" as code before you implement it. Whether you call it TDD or BDD, that habit changes how you write software.