Back to blog
Backend Systemsbeginner

Red-Green-Refactor — The TDD Cycle Explained

Learn Test-Driven Development: write a failing test first, make it pass, then refactor. See TDD in action with a real C# example and understand when it pays off.

Asma HafeezApril 17, 20264 min read
testingtddxunitdotnetclean-code
Share:š•

Test-Driven Development

TDD flips the order: write a failing test first, then write just enough code to make it pass, then improve the code. The cycle is Red → Green → Refactor.


The TDD Cycle

Red    → Write a test that fails (the feature doesn't exist yet)
Green  → Write the minimum code to make the test pass
Refactor → Clean up the code without breaking the tests
Repeat

TDD in Action — Building a ShoppingCart

Red — Write the failing test first

C#
// CartTests.cs
[Fact]
public void NewCart_IsEmpty()
{
    var cart = new ShoppingCart();
    cart.ItemCount.Should().Be(0);
    cart.Total.Should().Be(0);
}

This fails because ShoppingCart doesn't exist yet. Good.

Green — Minimum code to pass

C#
public class ShoppingCart
{
    public int     ItemCount => 0;
    public decimal Total     => 0;
}

Tests pass. Don't over-engineer — just make the test green.


Next test — Adding items

C#
[Fact]
public void AddItem_IncreasesItemCount()
{
    var cart = new ShoppingCart();
    cart.Add("SKU-001", "Widget", 9.99m, quantity: 2);
    cart.ItemCount.Should().Be(2);
}

[Fact]
public void AddItem_CalculatesTotal()
{
    var cart = new ShoppingCart();
    cart.Add("SKU-001", "Widget", 9.99m, quantity: 2);
    cart.Total.Should().Be(19.98m);
}

Green

C#
public class ShoppingCart
{
    private readonly List<CartLine> _lines = [];

    public int     ItemCount => _lines.Sum(l => l.Quantity);
    public decimal Total     => _lines.Sum(l => l.Price * l.Quantity);

    public void Add(string sku, string name, decimal price, int quantity)
    {
        _lines.Add(new CartLine(sku, name, price, quantity));
    }
}

public record CartLine(string Sku, string Name, decimal Price, int Quantity);

Next test — Duplicate SKU combines

C#
[Fact]
public void AddSameSku_CombinesQuantities()
{
    var cart = new ShoppingCart();
    cart.Add("SKU-001", "Widget", 9.99m, 1);
    cart.Add("SKU-001", "Widget", 9.99m, 3);
    cart.ItemCount.Should().Be(4);
    cart.Lines.Should().HaveCount(1);  // combined, not duplicated
}

Green

C#
public void Add(string sku, string name, decimal price, int quantity)
{
    var existing = _lines.FirstOrDefault(l => l.Sku == sku);
    if (existing is not null)
    {
        _lines.Remove(existing);
        _lines.Add(existing with { Quantity = existing.Quantity + quantity });
    }
    else
    {
        _lines.Add(new CartLine(sku, name, price, quantity));
    }
}

Next test — Remove item

C#
[Fact]
public void RemoveItem_DecreasesCount()
{
    var cart = new ShoppingCart();
    cart.Add("SKU-001", "Widget", 9.99m, 3);
    cart.Remove("SKU-001");
    cart.ItemCount.Should().Be(0);
}

[Fact]
public void RemoveMissingSku_DoesNothing()
{
    var cart = new ShoppingCart();
    var act = () => cart.Remove("NOT-FOUND");
    act.Should().NotThrow();
}

Green → Refactor

C#
// Full ShoppingCart after refactor
public class ShoppingCart
{
    private readonly List<CartLine> _lines = [];

    public int                      ItemCount => _lines.Sum(l => l.Quantity);
    public decimal                  Total     => _lines.Sum(l => l.Price * l.Quantity);
    public IReadOnlyList<CartLine>  Lines     => _lines.AsReadOnly();

    public void Add(string sku, string name, decimal price, int quantity)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        var existing = _lines.FirstOrDefault(l => l.Sku == sku);
        if (existing is not null)
        {
            _lines.Remove(existing);
            _lines.Add(existing with { Quantity = existing.Quantity + quantity });
        }
        else
        {
            _lines.Add(new CartLine(sku, name, price, quantity));
        }
    }

    public void Remove(string sku)
    {
        var line = _lines.FirstOrDefault(l => l.Sku == sku);
        if (line is not null) _lines.Remove(line);
    }
}

When TDD Pays Off

āœ“ Complex business rules (pricing, discounts, eligibility)
āœ“ Core domain logic that must be correct
āœ“ Code that will be maintained for years
āœ“ New features on existing, tested code

When TDD Is Overkill

āœ— UI layout and styling
āœ— Database migrations
āœ— Simple CRUD with no business logic
āœ— Infrastructure configuration

Key Takeaways

  1. Write the test first — it forces you to think about the API before the implementation
  2. The Red phase defines the desired behavior; the Green phase implements it minimally
  3. Refactor only when tests are green — tests are your safety net
  4. TDD produces more focused, testable code — if a test is hard to write, the code is hard to test
  5. TDD is not about 100% coverage — it's about designing through examples

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.