Test-Driven Development

Reviewed & published by Brayan K

By the end of this lesson you'll be able to drive your code with tests instead of writing them afterwards — working the Red-Green-Refactor cycle in tiny, safe steps, using failing tests to shape clean designs, and knowing the few places where TDD costs more than it pays.

Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

TDD is like writing the exam answer key before you sit the exam. First you decide, in black and white, what a correct answer looks like (the failing test). Only then do you write the answer and check it against the key. Because the target was fixed before you started, you can't fool yourself into marking a wrong answer "close enough" — and you instantly know the moment a later change breaks a question you'd already got right. The key isn't bureaucracy; it's the thing that lets you move fast without lying to yourself.

The Three-Step Cycle

StepWhat you doWhy
🔴 RedWrite one small failing test for the next bit of behaviour — and run it to watch it fail.Proves the test actually tests something, and pins down exactly what "done" means.
🟢 GreenWrite the smallest code that makes the bar go green. Even a hard-coded value is fine.Gets you to a known-good state fast; resists over-engineering.
🔵 RefactorImprove names & remove duplication while the bar stays green. Change nothing else.Keeps the design clean; the green tests prove you didn't break behaviour.

Then repeat — one tiny loop per behaviour. The discipline is: never write production code without a failing test asking for it, and never refactor on a red bar.

1. Red → Green → Refactor (the worked cycle)

Here's the whole cycle in one go, in xUnit. You start in Red: a test for RomanConverter that won't even compile because the class doesn't exist yet. Then Green: the smallest converter that passes all four tests. Then Refactor: tidy the code while the tests stay green. Read every comment, then picture running dotnet test and watching the bar flip from red to green.

using Xunit;

// === The TDD cycle: RED -> GREEN -> REFACTOR ===
// Goal: a Roman-numeral converter. We grow it one failing test at a time.

public class RomanNumeralTests
{
    // ── STEP 1: RED ───────────────────────────────────────────────
    // Write ONE failing test BEFORE any production code exists.
    // Run it now and it FAILS to even compile — RomanConverter is missing.
    [Fact]
    public void One_Returns_I()
    {
        var converter = new RomanConverter();
        Assert.Equal("I", converter.Convert(1));
    }

    // ── A second test forces real logic (this is "triangulation") ──
    [Fact]
    public void Two_Returns_II()
    {
        var converter = new RomanConverter();
        Assert.Equal("II", converter.Convert(2));
    }

    [Fact]
    public void Four_Returns_IV()
    {
        var converter = new RomanConverter();
        Assert.Equal("IV", converter.Convert(4));
    }

    [Fact]
    public void Nine_Returns_IX()
    {
        var converter = new RomanConverter();
        Assert.Equal("IX", converter.Convert(9));
    }
}

// ── STEP 2: GREEN ─────────────────────────────────────────────────
// Write the SMALLEST code that turns every red test green. No more.
public class RomanConverter
{
    // (value, symbol) pairs, largest first — including the 4/9 specials.
    private static readonly (int Value, string Symbol)[] Map =
    {
        (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")
    };

    public string Convert(int number)
    {
        var result = "";
        foreach (var (value, symbol) in Map)
            while (number >= value)        // subtract the biggest symbol you can
            {
                result += symbol;
                number -= value;
            }
        return result;
    }
}

// ── STEP 3: REFACTOR ──────────────────────────────────────────────
// Tidy names / remove duplication WHILE the bar stays green. Behaviour
// is locked by the tests, so you refactor without fear of breaking it.

// ✅ Expected test-runner output (e.g. 'dotnet test'):
//    Passed!  - Failed: 0, Passed: 4, Skipped: 0, Total: 4

Note: this example uses the xUnit framework, so it runs with dotnet test in a test project rather than in the simple online editor. The "Try it Yourself" exercises below are plain C# you can paste and run anywhere.

2. Baby Steps & Triangulation

Baby steps means you only ever write enough code to pass the current test — sometimes that's literally return 0;. That looks like cheating, but it's deliberate: it keeps you in a known-good state and stops you building features nobody has asked for yet. Triangulation is how you escape a fake answer: you add a second test with a different input, and now the hard-coded value fails, forcing real logic to emerge. Two points define a line; two tests define the behaviour.

using Xunit;

// === Baby steps & triangulation, frame by frame ===
// Each test pushes the code to do a LITTLE more. You never write a
// feature you don't yet have a failing test demanding.

public class SumTests
{
    // RED #1 — the simplest case. Empty input.
    [Fact]
    public void Empty_Returns_Zero()
    {
        Assert.Equal(0, Calculator.Sum(new int[] { }));
    }

    // GREEN #1: 'return 0;' literally passes. That feels like cheating —
    // it's not. A second test (triangulation) forces real logic next.

    // RED #2 — one number. 'return 0;' now fails, so the code must grow.
    [Fact]
    public void Single_Returns_That_Number()
    {
        Assert.Equal(5, Calculator.Sum(new[] { 5 }));
    }

    // RED #3 — many numbers. Forces a loop / aggregate.
    [Fact]
    public void Many_Returns_Their_Total()
    {
        Assert.Equal(6, Calculator.Sum(new[] { 1, 2, 3 }));
    }
}

// The implementation only ever grew as a test demanded it:
public static class Calculator
{
    public static int Sum(int[] numbers)
    {
        var total = 0;
        foreach (var n in numbers) total += n;   // emerged at RED #2/#3
        return total;
    }
}

// ✅ Expected: Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3

3. Your Turn: Make the Failing Test Pass

Now you drive the cycle. The program below already contains a failing test (Red) for IsLeapYear — the assertions are written, but the method body is empty, so they'd print FAIL. Don't touch the test: fill in the one blank in the method to turn every line green (PASS). This is plain C# with a tiny built-in assert helper, so you can run it in any compiler.

using System;

class Program
{
    // 🎯 YOUR TURN (RED -> GREEN) — a failing test is already written below.
    // The test calls IsLeapYear(...) and the assertions currently FAIL because
    // the method body is empty. Your job: fill in the body to make them PASS.

    // RED: this test is given. Do NOT change it — make the code satisfy it.
    static void Test_IsLeapYear()
    {
        Assert(IsLeapYear(2024) == true,  "2024 is a leap year");   // /4
        Assert(IsLeapYear(2023) == false, "2023 is not a leap year");
        Assert(IsLeapYear(1900) == false, "1900 is NOT (÷100, not ÷400)");
        Assert(IsLeapYear(2000) == true,  "2000 IS (÷400)");
    }

    // GREEN: implement this so EVERY assertion above prints PASS.
    static bool IsLeapYear(int year)
    {
        // 👉 A year is a leap year if it is divisible by 4,
        //    EXCEPT centuries, UNLESS the century is divisible by 400.
        return ___;   // 👉 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
    }

    // ── tiny test harness (no framework needed) — leave this alone ──
    static void Assert(bool condition, string name)
        => Console.WriteLine((condition ? "PASS" : "FAIL") + " — " + name);

    static void Main()
    {
        Test_IsLeapYear();

        // ✅ Expected output once GREEN:
        //    PASS — 2024 is a leap year
        //    PASS — 2023 is not a leap year
        //    PASS — 1900 is NOT (÷100, not ÷400)
        //    PASS — 2000 IS (÷400)
    }
}

4. Your Turn: Write the Next Test First

Real TDD is a loop, so here you add the next failing test yourself before extending the code. Fizz(n) already handles multiples of 3. Your job: add a new assertion that 5 should return "Buzz" (it fails now — that's Red), then add the one line to Fizz that makes it pass (Green). Fill in both blanks.

using System;

class Program
{
    // 🎯 YOUR TURN (write the next test) — Fizz works; now drive "Buzz".
    // The cycle: add a failing test FIRST, watch it fail, then extend Fizz()
    // just enough to make it pass.

    static void Test_Fizz()
    {
        Assert(Fizz(1) == "1",    "1 stays \"1\"");
        Assert(Fizz(3) == "Fizz", "3 is divisible by 3 -> Fizz");
        Assert(Fizz(6) == "Fizz", "6 is divisible by 3 -> Fizz");

        // 1) Add YOUR new failing test here: 5 should return "Buzz".
        Assert(Fizz(5) == ___, "5 is divisible by 5 -> Buzz");  // 👉 "Buzz"
    }

    static string Fizz(int n)
    {
        if (n % 3 == 0) return "Fizz";

        // 2) Extend the method to satisfy your new test (÷5 -> "Buzz").
        ___   // 👉 if (n % 5 == 0) return "Buzz";

        return n.ToString();
    }

    static void Assert(bool condition, string name)
        => Console.WriteLine((condition ? "PASS" : "FAIL") + " — " + name);

    static void Main()
    {
        Test_Fizz();

        // ✅ Expected output once both blanks are filled:
        //    PASS — 1 stays "1"
        //    PASS — 3 is divisible by 3 -> Fizz
        //    PASS — 6 is divisible by 3 -> Fizz
        //    PASS — 5 is divisible by 5 -> Buzz
    }
}

5. Refactoring Under a Green Bar

The third step is the one beginners skip — and it's where TDD pays off. Once your tests are green, they pin the behaviour in place. That means you can rewrite a tangled implementation into a clean one and instantly know whether you broke anything: if the bar stays green, you didn't. Refactoring without tests is a leap of faith; refactoring under green is routine.

using Xunit;
using System.Linq;

// === Refactoring under a green bar ===
// The tests pin the BEHAVIOUR. So you can rip out the messy implementation
// and replace it with a clean one — if the bar stays green, you didn't
// break anything. That safety net is the whole point of TDD.

public class DiscountTests
{
    [Theory]
    [InlineData(0,   0.00)]    // no items -> no discount
    [InlineData(1,   0.00)]    // 1 item   -> 0%
    [InlineData(5,   5.00)]    // 5 items  -> 5% of 100
    [InlineData(10, 10.00)]    // 10 items -> 10% (capped)
    [InlineData(50, 10.00)]    // more than 10 -> still capped at 10%
    public void Discount_ScalesWithQuantity_CappedAt10Percent(int qty, decimal expected)
    {
        // 100.00 order total, 'qty' items.
        Assert.Equal(expected, Pricing.DiscountFor(100m, qty));
    }
}

public static class Pricing
{
    // BEFORE refactor (works, but tangled):
    //   if (qty <= 0) return 0; if (qty == 1) return 0;
    //   var pct = qty; if (pct > 10) pct = 10; return total * pct / 100m;
    //
    // AFTER refactor (clean) — tests stayed green the whole time:
    public static decimal DiscountFor(decimal total, int quantity)
    {
        var percent = System.Math.Clamp(quantity, 0, 10);   // 0..10
        return total * percent / 100m;
    }
}

// ✅ Expected: Passed! - Failed: 0, Passed: 5, Skipped: 0, Total: 5

🔎 Deep Dive: test behaviour, not internals

A test should assert what the code does (its observable behaviour through its public surface), never how it does it. If you assert on private fields, the exact sequence of internal calls, or a specific algorithm, your test breaks the moment you refactor — even though the behaviour is identical. That turns your safety net into a ball and chain.

Rule of thumb: if a correct refactor makes a test go red, that test was coupled to the implementation, not the behaviour.

// ❌ Brittle — asserts an internal detail
Assert.Equal(2, cart.InternalItemArray.Length);

// ✅ Robust — asserts observable behaviour
Assert.Equal(19.98m, cart.Total);

The same idea is why you mock at boundaries (a database, a clock, an email gateway) but not your own pure logic — you want freedom to rewrite the inside.

When TDD Helps — and When It Hurts

TDD shines for…TDD gets in the way of…
Business rules & domain logic with clear inputs/outputsThrowaway spikes & exploratory prototypes
Algorithms, parsers, calculators, validatorsUI layout & pixel-level visual work
Bug fixes (write the failing test that reproduces it first)Code whose requirements you genuinely don't understand yet
Anything you'll refactor a lot and must not breakThin glue code with no logic worth pinning down

TDD is a tool, not a religion. Reach for it where behaviour is well-defined and stable; for genuinely exploratory work, prototype freely and add tests once the shape settles.

Pro Tips

Common Mistakes (and the fix)

📋 Quick Reference

IdeaIn practiceNote
RedAssert.Equal("I", c.Convert(1));Write it, run it, watch it fail
Greenreturn "I";Smallest thing that passes
RefactorMath.Clamp(qty, 0, 10)Clean up, bar stays green
TriangulationConvert(1), then Convert(2)2nd test kills a fake answer
Run xUnit testsdotnet testGreen bar = all passing
One case per row[Theory] [InlineData(...)]Data-drives many inputs

Frequently Asked Questions

Q: Isn't writing the test first slower?

It feels slower for the first few minutes and is usually faster over the life of the code. You spend less time debugging, less time afraid to change things, and you never build features nobody asked for. The tests also become living documentation of what the code is supposed to do.

Q: Do I really have to watch the test fail?

Yes. A test that passes before you've written the code is silently broken — maybe it asserts the wrong thing, or never runs. Seeing red first proves the test can fail, so green later actually means something.

Q: What's the difference between TDD and just writing tests?

Order. TDD writes the test before the code, so the test shapes the design. "Test-after" writes tests for code that already exists — useful, but it can't influence the design and tends to test what the code happens to do rather than what it should do.

Q: How small should a step be?

Small enough that if the test goes red unexpectedly, you know exactly what caused it. If you can't make a failing test pass in a couple of minutes, your step was too big — back up and take a smaller one.

Q: Should I always use TDD?

No. It's brilliant for business logic, algorithms, and bug fixes, and awkward for UI work and throwaway prototypes. Use it where behaviour is well-defined and you'll refactor a lot; relax it where you're still exploring what to build.

Mini-Challenge: TDD FizzBuzz

No blanks this time — the failing tests are given (Red) and the implementation is up to you. Work in baby steps: make FizzBuzz(1) pass, then 3, then 5, then 15, tidying as you go. Watch your ordering — check divisible-by-15 ("FizzBuzz") before 3 and 5, or you'll never reach it. Run it and every line should print PASS.

using System;

class Program
{
    // 🎯 MINI-CHALLENGE: TDD the classic FizzBuzz(n)
    // The tests are GIVEN (this is RED). Implement FizzBuzz so every line
    // prints PASS — and do it in baby steps: make ONE assertion pass, then
    // the next, refactoring as you go.
    //
    // Rules:
    //   • divisible by 3 AND 5 -> "FizzBuzz"   (check this FIRST!)
    //   • divisible by 3       -> "Fizz"
    //   • divisible by 5       -> "Buzz"
    //   • otherwise            -> the number as text

    static void Test_FizzBuzz()
    {
        Assert(FizzBuzz(1)  == "1",        "1 -> 1");
        Assert(FizzBuzz(3)  == "Fizz",     "3 -> Fizz");
        Assert(FizzBuzz(5)  == "Buzz",     "5 -> Buzz");
        Assert(FizzBuzz(15) == "FizzBuzz", "15 -> FizzBuzz");
    }

    static string FizzBuzz(int n)
    {
        // your code here — order matters: test %15 before %3 and %5
        return "";
    }

    static void Assert(bool condition, string name)
        => Console.WriteLine((condition ? "PASS" : "FAIL") + " — " + name);

    static void Main()
    {
        Test_FizzBuzz();

        // ✅ Expected output when done:
        //    PASS — 1 -> 1
        //    PASS — 3 -> Fizz
        //    PASS — 5 -> Buzz
        //    PASS — 15 -> FizzBuzz
    }
}

🎉 Lesson Complete

Practice quiz

What are the three steps of the TDD cycle, in order?

  • Refactor, Red, Green
  • Green, Red, Refactor
  • Red, Green, Refactor
  • Write, Run, Review

Answer: Red, Green, Refactor. The cycle is Red (failing test) then Green (smallest pass) then Refactor (clean up).

In the Red step, what do you do?

  • Write one small failing test and run it to watch it fail
  • Write the production code
  • Refactor the code
  • Delete old tests

Answer: Write one small failing test and run it to watch it fail. Red means write a failing test first and run it, proving it actually tests something.

In the Green step, how much code should you write?

  • The entire feature
  • As much as possible
  • Only comments
  • The smallest code that makes the bar go green

Answer: The smallest code that makes the bar go green. Green means write the smallest code that passes - even a hard-coded value is acceptable.

Why must you watch a test fail before writing the code?

  • To measure speed
  • To prove the test can actually fail, so a later green means something
  • It's required by xUnit
  • To warm up the compiler

Answer: To prove the test can actually fail, so a later green means something. A test green before you write code is testing nothing; seeing red first proves it can fail.

What is 'triangulation' in TDD?

  • Adding a second test with a different input to force real logic out of a fake answer
  • Using three test frameworks
  • Testing in three environments
  • A refactoring pattern

Answer: Adding a second test with a different input to force real logic out of a fake answer. A second test with a different input makes a hard-coded value fail, forcing real logic to emerge.

What does the Refactor step require about the test bar?

  • The bar must be red
  • Skip running tests
  • Improve the code while the bar stays green
  • Add new failing tests

Answer: Improve the code while the bar stays green. You refactor only under a green bar; the passing tests prove you didn't change behaviour.

A good test should assert what?

  • Private fields and internal call order
  • Observable behaviour through the public surface
  • The exact algorithm used
  • Memory addresses

Answer: Observable behaviour through the public surface. Test behaviour, not internals - asserting on internals breaks tests on every refactor.

If a correct refactor makes a test go red, what does that usually mean?

  • The refactor was wrong
  • The framework is broken
  • The test is fine
  • The test was coupled to the implementation, not the behaviour

Answer: The test was coupled to the implementation, not the behaviour. A behaviour-preserving refactor shouldn't break a behaviour-focused test; red means it tested internals.

What is the difference between TDD and just writing tests?

  • TDD uses more assertions
  • Order - TDD writes the test before the code, so the test shapes the design
  • TDD requires mocks
  • There is no difference

Answer: Order - TDD writes the test before the code, so the test shapes the design. TDD writes the test first so it influences design; test-after can only check what the code already does.

When is TDD generally LESS suitable?

  • Business rules with clear inputs/outputs
  • Algorithms and validators
  • Throwaway spikes, exploratory prototypes, and pixel-level UI work
  • Bug fixes

Answer: Throwaway spikes, exploratory prototypes, and pixel-level UI work. TDD shines for well-defined logic; it gets in the way of exploratory prototypes and visual UI work.

Continue this course