Skip to main content
    Courses/C#/Test-Driven Development

    Advanced Track

    Test-Driven Development

    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.

    What You'll Learn

    • Run the Red-Green-Refactor cycle: fail first, pass minimally, then clean up
    • Why writing the test first changes how you design (not just how you test)
    • Work in baby steps — the smallest change that moves the bar
    • Use triangulation: a second test to force real logic out of fake code
    • Refactor safely under a green bar, with tests as your safety net
    • Judge when TDD helps most and when test-after is the pragmatic choice

    💡 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.

    Worked example: the full TDD cycle (xUnit)

    A Roman-numeral converter grown one failing test at a time. Read the expected runner output at the bottom.

    Try it Yourself »
    C#
    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
    ...

    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.

    Worked example: baby steps & triangulation

    See how 'return 0;' passes one test, then a second test forces a real loop.

    Try it Yourself »
    C#
    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 (triangulatio
    ...

    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.

    🎯 Your turn: RED → GREEN (leap year)

    The test is given and failing. Implement IsLeapYear so all four assertions print PASS.

    Try it Yourself »
    C#
    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
    ...

    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.

    🎯 Your turn: add a test, then extend the code

    Add the failing 'Buzz' assertion, then grow Fizz() just enough to pass it.

    Try it Yourself »
    C#
    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
    ...

    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.

    Worked example: refactor safely under green (xUnit)

    Tests pin the behaviour, so a messy discount calc is replaced with a clean one — bar stays green.

    Try it Yourself »
    C#
    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
        [Inlin
    ...

    🔎 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

    • 💡 Always watch the test fail first. A test that's green before you wrote the code is testing nothing — you've a typo'd assertion or it's hitting old behaviour.
    • 💡 One failing test at a time. If two tests are red, you've taken too big a step; comment one out and finish the other.
    • 💡 Name tests as sentences: Withdraw_MoreThanBalance_IsRejected reads like a spec and tells you exactly what broke.
    • 💡 Refactor the tests too. Duplicated setup is a smell; extract a helper or a fixture — test code is real code.
    • 💡 For bugs, reproduce before you fix: write a failing test that triggers the bug, then make it pass. Now it can never silently come back.
    • 💡 Keep tests fast and isolated so the cycle feels instant — slow tests get skipped, and skipped tests don't protect you.

    Common Mistakes (and the fix)

    • Writing too much code at once: you implement the whole feature, then bolt tests on after. You've lost the design feedback and probably built more than needed. Shrink the step — one behaviour, one test.
    • No failing test first: if you never saw red, you don't know the test can fail. Run it before the implementation and confirm it fails for the right reason.
    • Testing internals instead of behaviour: asserting on private fields or exact call order makes tests break on every refactor. Assert through the public API on observable results.
    • Skipping the refactor step: green code is correct, not clean. Duplication and bad names pile up. Spend the minute to tidy while the bar is green.
    • Over-mocking: mocking your own pure logic freezes the implementation. Mock only true boundaries (I/O, time, network); leave your domain code real.

    📋 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.

    🎯 Mini-Challenge: TDD the FizzBuzz function

    The tests are given and failing. Implement FizzBuzz so all four assertions print PASS.

    Try it Yourself »
    C#
    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 te
    ...

    🎉 Lesson Complete

    • ✅ The cycle is Red (failing test) → Green (smallest pass) → Refactor (clean up)
    • ✅ Test-first shapes the design, not just the verification — and gives you living documentation
    • Baby steps keep you in a known-good state; triangulation forces real logic out of fake code
    • ✅ Always watch the test fail first — a never-red test protects nothing
    • Refactor under green: the tests pin behaviour so you can rewrite without fear
    • ✅ Test behaviour, not internals; mock boundaries, not your own pure logic
    • ✅ Use TDD for logic, algorithms & bug fixes; relax it for UI and throwaway prototypes
    • Next lesson: Clean Architecture — structuring whole applications around these testable boundaries

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service