
TDD vs BDD: Comparing Test-Driven Development Strategies
TDD and BDD both share a 'tests first' philosophy but differ in focus and application. From the Red-Green-Refactor cycle to Gherkin syntax — a practical TypeScript comparison.

TDD and BDD both share a 'tests first' philosophy but differ in focus and application. From the Red-Green-Refactor cycle to Gherkin syntax — a practical TypeScript comparison.
Writing test BEFORE code. Red -> Green -> Refactor. The Double-Entry Bookkeeping of Programming.

'It works on my machine' is no longer an excuse. Robots test (CI) and deploy (CD) when you push code. Deploy 100 times a day with automation pipelines.

Frontend development blocked by unfinished APIs. MSW intercepts requests at the network level so you can build and test without a real backend.

Unit tests check the bricks; E2E tests check the building. Learn how to simulate real user journeys using modern tools like Cypress and Playwright. We cover best practices for writing resilient selectors, handling authentication, fighting test flakiness, and where E2E fits in the Testing Pyramid.

"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.
TDD (Test-Driven Development) was formalized by Kent Beck in 1999 as part of Extreme Programming. The core is three steps:
Red → Green → Refactor
// 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.
// 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.
// 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.
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.
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.
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.
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);
});
});
});
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);
});
| 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 |
// 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?"
# 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.
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.
// 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.
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', () => {
// ...
});
});
});
Mixing them is natural:
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.
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.