Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Test-Driven Development (TDD)

Overview

Test-Driven Development (TDD) là một development methodology trong đó tests được viết trước khi viết code. Chu trình cơ bản là: Red - Green - Refactor.

┌─────────────────────────────────────────────────────────────┐
│                    TDD Cycle                                │
│                                                              │
│   ┌──────────┐   ┌──────────┐   ┌─────────────┐             │
│   │   Red    │──▶│  Green   │──▶│  Refactor   │             │
│   │(Fail)    │   │(Pass)    │   │(Improve)    │             │
│   └──────────┘   └──────────┘   └──────┬──────┘             │
│        │              │                  │                   │
│        │              │                  │                   │
│        ▼              ▼                  ▼                   │
│   Write failing   Write minimal     Improve code            │
│   test            code to pass      while keeping           │
│   first           test              tests passing            │
└─────────────────────────────────────────────────────────────┘

The Three Laws of TDD

  1. First Law: Không viết production code cho đến khi có một failing unit test
  2. Second Law: Không viết thêm test gì ngoài cái test đang fail để make it pass
  3. Third Law: Không viết thêm production code ngoài cái để make test pass

Implementation in C#

1. Basic Example - Calculator

// Step 1: Write failing test (Red)
// CalculatorTests.cs
public class CalculatorTests
{
    [Fact]
    public void Add_TwoNumbers_ReturnsSum()
    {
        // Arrange
        var calculator = new Calculator();
        
        // Act
        var result = calculator.Add(2, 3);
        
        // Assert
        Assert.Equal(5, result);
    }
}

// Step 2: Write minimal code (Green)
// Calculator.cs
public class Calculator
{
    public int Add(int a, int b)
    {
        return 5; // Minimal implementation to pass
    }
}

// Step 3: Refactor - implement properly
public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}

2. Bank Account Example

// Domain/BankAccount.cs
public class BankAccount
{
    public decimal Balance { get; private set; }
    
    public void Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Amount must be positive", nameof(amount));
            
        Balance += amount;
    }
    
    public void Withdraw(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Amount must be positive", nameof(amount));
            
        if (amount > Balance)
            throw new InvalidOperationException("Insufficient balance");
            
        Balance -= amount;
    }
}

// Tests/BankAccountTests.cs
public class BankAccountTests
{
    [Theory]
    [InlineData(100, 50, 50)]
    [InlineData(100, 10, 90)]
    [InlineData(0, 0, 0)]
    public void Withdraw_ValidAmount_DecreasesBalance(
        decimal initialBalance, 
        decimal withdrawAmount, 
        decimal expectedBalance)
    {
        // Arrange
        var account = new BankAccount();
        account.Deposit(initialBalance);
        
        // Act
        account.Withdraw(withdrawAmount);
        
        // Assert
        Assert.Equal(expectedBalance, account.Balance);
    }
    
    [Fact]
    public void Withdraw_MoreThanBalance_ThrowsException()
    {
        var account = new BankAccount();
        account.Deposit(100);
        
        Assert.Throws<InvalidOperationException>(() => 
            account.Withdraw(200));
    }
    
    [Theory]
    [InlineData(0)]
    [InlineData(-10)]
    public void Deposit_NonPositiveAmount_ThrowsException(decimal amount)
    {
        var account = new BankAccount();
        
        Assert.Throws<ArgumentException>(() => account.Deposit(amount));
    }
}

3. String Calculator (Kent Beck Style)

// StringCalculator.cs
public class StringCalculator
{
    public int Add(string numbers)
    {
        if (string.IsNullOrEmpty(numbers))
            return 0;
            
        var parts = numbers.Split(',');
        return parts.Sum(int.Parse);
    }
}

// StringCalculatorTests.cs
public class StringCalculatorTests
{
    [Fact]
    public void Add_EmptyString_ReturnsZero()
    {
        var calculator = new StringCalculator();
        
        var result = calculator.Add("");
        
        Assert.Equal(0, result);
    }
    
    [Fact]
    public void Add_SingleNumber_ReturnsThatNumber()
    {
        var result = new StringCalculator().Add("5");
        
        Assert.Equal(5, result);
    }
    
    [Fact]
    public void Add_TwoNumbers_ReturnsSum()
    {
        var result = new StringCalculator().Add("1,2");
        
        Assert.Equal(3, result);
    }
    
    [Fact]
    public void Add_UnknownAmountOfNumbers_ReturnsSum()
    {
        var result = new StringCalculator().Add("1,2,3,4,5");
        
        Assert.Equal(15, result);
    }
}

4. Testing with Dependencies

// Service using interface for dependency
public interface IOrderRepository
{
    Task<Order> GetByIdAsync(Guid id);
}

public class OrderService
{
    private readonly IOrderRepository _repository;
    
    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }
    
    public async Task<bool> ValidateOrderAsync(Guid orderId)
    {
        var order = await _repository.GetByIdAsync(orderId);
        
        if (order == null)
            return false;
            
        return order.Status == OrderStatus.Active;
    }
}

// Testing with mocking
public class OrderServiceTests
{
    [Fact]
    public async Task ValidateOrderAsync_ActiveOrder_ReturnsTrue()
    {
        // Arrange
        var mockRepo = new Mock<IOrderRepository>();
        var order = new Order { Id = Guid.NewGuid(), Status = OrderStatus.Active };
        
        mockRepo.Setup(r => r.GetByIdAsync(order.Id))
            .ReturnsAsync(order);
            
        var service = new OrderService(mockRepo.Object);
        
        // Act
        var result = await service.ValidateOrderAsync(order.Id);
        
        // Assert
        Assert.True(result);
    }
    
    [Fact]
    public async Task ValidateOrderAsync_OrderNotFound_ReturnsFalse()
    {
        var mockRepo = new Mock<IOrderRepository>();
        
        mockRepo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>()))
            .ReturnsAsync((Order)null);
            
        var service = new OrderService(mockRepo.Object);
        
        var result = await service.ValidateOrderAsync(Guid.NewGuid());
        
        Assert.False(result);
    }
}

5. Testing Edge Cases

public class OrderTests
{
    [Fact]
    public void PlaceOrder_EmptyItems_ThrowsException()
    {
        var order = new Order();
        
        Assert.Throws<InvalidOperationException>(() => order.Place());
    }
    
    [Fact]
    public void PlaceOrder_AlreadyPlaced_ThrowsException()
    {
        var order = new Order();
        order.AddItem(CreateSampleProduct(), 1);
        order.Place();
        
        Assert.Throws<InvalidOperationException>(() => order.Place());
    }
    
    [Fact]
    public void AddItem_NegativeQuantity_ThrowsException()
    {
        var order = new Order();
        
        Assert.Throws<ArgumentException>(() => 
            order.AddItem(CreateSampleProduct(), -1));
    }
    
    private Product CreateSampleProduct()
    {
        return new Product("Test Product", 10.00m);
    }
}

AAA Pattern

[Fact]
public void Example()
{
    // Arrange - Setup objects, prepare data
    var calculator = new Calculator();
    var expected = 10;
    
    // Act - Execute the functionality
    var result = calculator.Add(6, 4);
    
    // Assert - Verify the result
    Assert.Equal(expected, result);
}

Naming Conventions

// MethodName_Scenario_ExpectedBehavior
[Fact]
public void Withdraw_InsufficientFunds_ThrowsException() { }

[Fact]
public void Add_NegativeNumbers_ThrowsArgumentException() { }

[Fact]
public void ProcessOrder_ValidOrder_ReturnsSuccess() { }

Test Organization

public class OrderServiceTests
{
    // Order Creation Tests
    [Fact] public void CreateOrder_ValidData_ReturnsOrder() { }
    [Fact] public void CreateOrder_NullData_ThrowsException() { }
    
    // Order Modification Tests
    [Fact] public void AddItem_ValidItem_AddsToOrder() { }
    [Fact] public void RemoveItem_ExistingItem_RemovesFromOrder() { }
    
    // Order Processing Tests
    [Fact] public void Place_PlacedOrder_ChangesStatus() { }
    [Fact] public void Cancel_CancellableOrder_ChangesStatus() { }
}

Benefits

BenefitDescription
Better DesignTests force good architecture
Fewer BugsCatch issues early
Living DocumentationTests document the code
ConfidenceRefactor without fear
Faster DebuggingKnow exactly what’s broken

Challenges

ChallengeDescription
Learning CurveTakes time to get comfortable
Over-testingDon’t test trivial code
Test MaintenanceTests need to evolve with code
Slow StartSeems slower at first

Best Practices

  1. Test one thing: Each test should verify one behavior
  2. Use descriptive names: Test names explain behavior
  3. Follow AAA: Arrange, Act, Assert
  4. Keep tests independent: No test should depend on another
  5. Test edge cases: Include boundary conditions
  6. Refactor tests too: Keep test code clean

Test Pyramid

        ┌───────────┐
        │   E2E    │  ← Few tests, slow
        │  Tests   │
      ┌─┴───────────┴─┐
      │  Integration  │  ← More tests
      │    Tests     │
    ┌─┴───────────────┴─┐
    │    Unit Tests    │  ← Many tests, fast
    └───────────────────┘

Tools

  • xUnit: Modern testing framework
  • NUnit: Classic .NET testing
  • Moq: Mocking framework
  • FluentAssertions: Better assertions

References