Skip to content
All essays
CraftFebruary 20, 202514 min

Test-Driven Development Mastery: TDD Workflows and Best Practices

Master Test-Driven Development (TDD). Learn red-green-refactor cycle, testing strategies, and how to build quality software through TDD.

Ü
Ümit Uz
Mobile & Full Stack Developer

Introduction

Test-Driven Development (TDD) is a software development approach where tests are written before the implementation code. This practice leads to better design, fewer bugs, and more maintainable codebases. In this comprehensive guide, we'll explore TDD workflows, patterns, and best practices used by professional developers in 2025.

The TDD Cycle: Red-Green-Refactor

Understanding the Cycle

TDD follows a simple three-step cycle:

  1. 1Red: Write a failing test
  2. 2Green: Write just enough code to pass the test
  3. 3Refactor: Improve the code while keeping tests green
┌─────────────────────────────────────┐
│                                     │
│  ┌─────────┐    ┌─────────┐    ┌─────────┐  │
│  │   RED   │ -> │  GREEN  │ -> │ REFACTOR │  │
│  └─────────┘    └─────────┘    └─────────┘  │
│      ^              │               │       │
│      └──────────────┴───────────────┘       │
│                                             │
└─────────────────────────────────────────────┘

Example: Building a Calculator

Let's build a simple calculator using TDD:

Step 1: Red - Write a Failing Test

typescript
// calculator.test.ts
import { Calculator } from './calculator';

describe('Calculator', () => {
  test('should add two numbers', () => {
    const calculator = new Calculator();
    expect(calculator.add(2, 3)).toBe(5);
  });
});

Run the test - it fails because we haven't implemented anything yet.

Step 2: Green - Write Minimal Implementation

typescript
// calculator.ts
export class Calculator {
  add(a: number, b: number): number {
    return a + b;
  }
}

Run the test - it passes!

Step 3: Refactor - Improve Code

The code is already simple, so no refactoring needed. Let's add more functionality.

typescript
// calculator.test.ts
test('should subtract two numbers', () => {
  const calculator = new Calculator();
  expect(calculator.subtract(5, 3)).toBe(2);
});

test('should multiply two numbers', () => {
  const calculator = new Calculator();
  expect(calculator.multiply(3, 4)).toBe(12);
});

test('should divide two numbers', () => {
  const calculator = new Calculator();
  expect(calculator.divide(10, 2)).toBe(5);
});

test('should throw error when dividing by zero', () => {
  const calculator = new Calculator();
  expect(() => calculator.divide(10, 0)).toThrow('Division by zero');
});
typescript
// calculator.ts
export class Calculator {
  add(a: number, b: number): number {
    return a + b;
  }

  subtract(a: number, b: number): number {
    return a - b;
  }

  multiply(a: number, b: number): number {
    return a * b;
  }

  divide(a: number, b: number): number {
    if (b === 0) {
      throw new Error('Division by zero');
    }
    return a / b;
  }
}

TDD Patterns and Practices

Baby Steps

Work in small, manageable increments:

typescript
// Step 1: Test for empty string
test('should return 0 for empty string', () => {
  expect(add('')).toBe(0);
});

// Implementation
function add(numbers: string): number {
  return 0;
}

// Step 2: Test for single number
test('should return number for single number string', () => {
  expect(add('5')).toBe(5);
});

// Implementation
function add(numbers: string): number {
  return numbers ? parseInt(numbers) : 0;
}

// Step 3: Test for two numbers
test('should return sum of two numbers', () => {
  expect(add('1,2')).toBe(3);
});

// Implementation
function add(numbers: string): number {
  if (!numbers) return 0;
  const nums = numbers.split(',').map(n => parseInt(n));
  return nums.reduce((sum, n) => sum + n, 0);
}

Triangulation

Use multiple tests to drive general implementation:

typescript
// Test 1: Specific case
test('should format 1 as "1st"', () => {
  expect(formatOrdinal(1)).toBe('1st');
});

// Implementation
function formatOrdinal(n: number): string {
  return '1st';
}

// Test 2: Different case
test('should format 2 as "2nd"', () => {
  expect(formatOrdinal(2)).toBe('2nd');
});

// This forces us to generalize
function formatOrdinal(n: number): string {
  const suffixes = ['th', 'st', 'nd', 'rd', 'th'];
  const remainder = n % 100;
  const suffix = suffixes[remainder] || suffixes[0];
  return `${n}${suffix}`;
}

Fake It Till You Make It

Return constant values, then generalize:

typescript
// Test
test('should calculate price with discount', () => {
  expect(calculatePrice(100, 10)).toBe(90);
});

// First implementation - fake it
function calculatePrice(price: number, discount: number): number {
  return 90; // Just make it pass
}

// Then generalize
function calculatePrice(price: number, discount: number): number {
  return price - (price * discount / 100);
}

Testing Edge Cases

Boundary Testing

typescript
describe('Array utils', () => {
  describe('findMax', () => {
    test('should find maximum in array', () => {
      expect(findMax([1, 2, 3, 4, 5])).toBe(5);
    });

    test('should handle negative numbers', () => {
      expect(findMax([-1, -2, -3])).toBe(-1);
    });

    test('should handle single element', () => {
      expect(findMax([42])).toBe(42);
    });

    test('should throw on empty array', () => {
      expect(() => findMax([])).toThrow('Empty array');
    });
  });
});

Null and Undefined Handling

typescript
describe('User service', () => {
  test('should handle null input', () => {
    expect(() => createUser(null)).toThrow('Invalid input');
  });

  test('should handle undefined input', () => {
    expect(() => createUser(undefined)).toThrow('Invalid input');
  });

  test('should handle missing properties', () => {
    const user = createUser({ name: 'John' });
    expect(user.email).toBe('');
  });
});

TDD for Different Scenarios

TDD for API Development

typescript
// userController.test.ts
import request from 'supertest';
import app from './app';

describe('POST /api/users', () => {
  test('should create user with valid data', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({
        name: 'John Doe',
        email: 'john@example.com',
        password: 'SecurePass123!'
      })
      .expect(201);

    expect(response.body).toHaveProperty('id');
    expect(response.body.name).toBe('John Doe');
  });

  test('should reject invalid email', async () => {
    await request(app)
      .post('/api/users')
      .send({
        name: 'John Doe',
        email: 'invalid-email',
        password: 'SecurePass123!'
      })
      .expect(400);
  });

  test('should reject weak password', async () => {
    await request(app)
      .post('/api/users')
      .send({
        name: 'John Doe',
        email: 'john@example.com',
        password: '123'
      })
      .expect(400);
  });
});

TDD for React Components

typescript
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button Component', () => {
  test('should render button with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  test('should call onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  test('should be disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByText('Click me')).toBeDisabled();
  });

  test('should apply custom className', () => {
    render(<Button className="custom-class">Click me</Button>);
    expect(screen.getByText('Click me')).toHaveClass('custom-class');
  });
});

TDD for Database Operations

typescript
// userRepository.test.ts
import { UserRepository } from './userRepository';

describe('UserRepository', () => {
  let repo: UserRepository;

  beforeEach(() => {
    repo = new UserRepository();
  });

  test('should save user to database', async () => {
    const user = {
      name: 'John Doe',
      email: 'john@example.com'
    };

    const savedUser = await repo.save(user);

    expect(savedUser).toHaveProperty('id');
    expect(savedUser.name).toBe('John Doe');
  });

  test('should find user by id', async () => {
    const user = await repo.save({
      name: 'Jane Smith',
      email: 'jane@example.com'
    });

    const foundUser = await repo.findById(user.id);

    expect(foundUser).toEqual(user);
  });

  test('should return null for non-existent user', async () => {
    const user = await repo.findById('non-existent');
    expect(user).toBeNull();
  });

  test('should update user', async () => {
    const user = await repo.save({
      name: 'John Doe',
      email: 'john@example.com'
    });

    const updated = await repo.update(user.id, {
      name: 'John Updated'
    });

    expect(updated.name).toBe('John Updated');
  });
});

Refactoring with Confidence

Extracting Methods

typescript
// Before
function processUser(user: User) {
  const formattedName = user.name.trim().toLowerCase();
  const formattedEmail = user.email.trim().toLowerCase();
  // ... more code
}

// Test still passes after refactoring
function processUser(user: User) {
  const formattedName = formatName(user.name);
  const formattedEmail = formatEmail(user.email);
  // ... more code
}

function formatName(name: string): string {
  return name.trim().toLowerCase();
}

function formatEmail(email: string): string {
  return email.trim().toLowerCase();
}

Extracting Classes

typescript
// Before
class OrderService {
  processOrder(order: Order) {
    // Validate
    if (!order.items || order.items.length === 0) {
      throw new Error('Invalid order');
    }

    // Calculate total
    const total = order.items.reduce((sum, item) => {
      return sum + (item.price * item.quantity);
    }, 0);

    // Apply discount
    const discount = total > 100 ? total * 0.1 : 0;
    const finalTotal = total - discount;

    // Save order
    // ... more code
  }
}

// After - with tests still passing
class OrderValidator {
  validate(order: Order) {
    if (!order.items || order.items.length === 0) {
      throw new Error('Invalid order');
    }
  }
}

class OrderCalculator {
  calculateTotal(order: Order): number {
    const total = order.items.reduce((sum, item) => {
      return sum + (item.price * item.quantity);
    }, 0);

    const discount = this.calculateDiscount(total);
    return total - discount;
  }

  private calculateDiscount(total: number): number {
    return total > 100 ? total * 0.1 : 0;
  }
}

class OrderService {
  private validator = new OrderValidator();
  private calculator = new OrderCalculator();

  processOrder(order: Order) {
    this.validator.validate(order);
    const total = this.calculator.calculateTotal(order);
    // Save order
  }
}

Measuring TDD Success

Code Coverage

bash
npm test -- --coverage

Target: >80% coverage

Test Quality Indicators

  • Fast test execution (seconds, not minutes)
  • Clear test names that describe behavior
  • Tests that fail for valid reasons
  • No flaky tests
  • Easy to understand test failures

Common TDD Mistakes

1. Writing Tests After Code

This defeats the purpose of TDD. Always write tests first.

2. Testing Implementation Details

Focus on behavior, not implementation:

typescript
// Bad - testing implementation
test('should set isEmailValid to true', () => {
  const validator = new EmailValidator();
  validator.validate('test@example.com');
  expect(validator.isEmailValid).toBe(true);
});

// Good - testing behavior
test('should return true for valid email', () => {
  const validator = new EmailValidator();
  const result = validator.validate('test@example.com');
  expect(result).toBe(true);
});

3. Skipping Refactoring

Don't move to the next feature without refactoring. Technical debt accumulates.

4. Writing Too Many Tests at Once

Write one test at a time, make it pass, then refactor.

TDD in CI/CD

yaml
# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Generate coverage
        run: npm test -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3

Conclusion

TDD is a discipline that transforms how you write software. By following the red-green-refactor cycle, you'll produce better-designed, more maintainable code with fewer bugs. Start small, be patient, and watch your code quality improve.

Related essays

Next essay
npm vs yarn vs pnpm: Package Manager Comparison