What Is TDD? Canon TDD, the Test List and the Real Purpose of Red-Green-Refactor
TDD is not writing lots of unit tests but a discipline of growing behavior in small steps while correcting the design all the time: a test list first, then one test from it, red, green, an optional refactor and an updated list. We look at Kent Beck's Canon TDD, how it differs from Uncle Bob's three laws, and how the list for Bookstore's CouponValidator changes over two loops, with real test output.
Version note: The code in this post was built and run with .NET 10 and xUnit.net v3 4.0.3.
Predict First
Bookstore is getting coupons: a percentage coupon (BOOK10 takes 10% off the cart), a fixed-amount coupon (SAVE50 takes 50 off), an expiry date and a minimum cart subtotal. Two developers share the work, and both say "I'll do it with TDD" and write a list before touching the code. The first list:
// List A
// [ ] A 10% coupon gives 20 off a 200 subtotal
// [ ] A 50 fixed-amount coupon gives 50 off a 200 subtotal
// [ ] A fixed-amount coupon never exceeds the subtotal (50 coupon on a 40 cart: 40)
// [ ] An expired coupon is rejected
// [ ] A coupon is still valid on its last day
// [ ] A cart below the minimum subtotal is rejected
// [ ] A cart exactly at the minimum is accepted
// [ ] Unknown coupon code?
The second list:
// List B
// [ ] Add the CouponKind enum
// [ ] Write the IDiscountStrategy interface and its two implementations
// [ ] Pass the strategies to CouponValidator through the constructor
// [ ] Write the ExpiryRule and MinimumSubtotalRule classes
// [ ] Open a test class for every class
Both are tidy, and both split the work into small pieces. The question: which one is a test list, the place where TDD starts, and which one is not? And if the developer with the second list does write the tests first, what have they still missed? Pick an answer; we come back to these two lists halfway through the post.
A Short History: From Test-First to Canon TDD
TDD appeared in the late 1990s as "test-first programming", one of Kent Beck's XP (Extreme Programming) practices. The XP post in the Agile Mindset series covers its place in the team; we won't repeat it here. Beck says he did not invent the idea but rediscovered it: he recalls a suggestion from an old programming book. Prepare the expected output first, then write the program until it produces that output.
Beck's Test-Driven Development: By Example (Addison-Wesley, 2002) described the practice as a discipline independent of XP. In the book a currency example grows in small steps, one test at a time; the "red, green, refactor" rhythm and the idea of a test list spread with it. Martin Fowler's TestDrivenDevelopment article, written in 2005 and updated in 2023, sums up the same loop in three steps: write a test for the next small piece of functionality, write the code that makes it pass, refactor the new and the old code.
Over twenty years so many distorted descriptions of TDD piled up that on 11 December 2023 Beck published a post called "Canon TDD" in his Tidy First? newsletter. The aim was not to propose a new method but to make clear whether the thing critics attacked was really TDD. He stresses at the start that it is not a recipe for how you should do TDD, but a description of what he does. When this series says TDD, it means that description.
Let's clear up one misunderstanding right away: TDD does not mean "writing lots of unit tests". There are projects with lots of tests and no TDD at all, and projects written with TDD whose test count stayed modest. The real product of TDD is a design that is corrected continuously while behavior grows in small steps. The tests are both the tool and a by-product of that process.
Canon TDD: Five Steps

Beck's description has five steps. In our own words:
Step one is the test list. You write down every variant the new behavior has to handle: the basic case, the edges, the failure cases and the places where existing behavior must not break. This is behavioral analysis. The list holds "what should the system do in which situation", not "which class will I write".
Step two is writing a test. You pick exactly one item from the list and turn it into a real, automated, runnable test: setup, invocation and assertions. You make plenty of design decisions while doing so, but they are mostly interface decisions: how the behavior is called, what the parameters are, how the result comes back.
Step three is making it pass. You change the system so that this test and every earlier test pass. If a new scenario occurs to you along the way, you add it to the list instead of dropping everything to chase it.
Step four is the optional refactor. While the tests are green you improve the implementation design: duplication, names, where responsibilities live. Beck calls the step "optional" on purpose; not every green bar leaves something to refactor.
Step five is going around again: until the list is empty, go back to step two. The list changes every round: the finished item is ticked off, newly noticed scenarios are added, and items that turn out to be unnecessary are dropped.
The subtlest point of the description is that it separates two kinds of design. While writing the test you decide how the behavior looks from outside, the interface. In the refactor step you decide how the code is organized inside, the implementation. Fowler makes the same point: thinking about the test first forces you to think about how the code will be used before writing it, and separating interface from implementation is a design skill many programmers struggle with.
The Test List: A First List for CouponValidator
Back to the opening question. List A is a test list: every item is behavior observable from outside, most of them written with concrete numbers, and none describes the inside of the code. List B is an implementation plan. Even if you write the tests first, starting from that list means you made the design before writing any code: two strategies, two rule classes, a test class per class. The tests no longer discover the design; they confirm a design drawn up in advance. The first mistake Beck names for the test list step is exactly this: mixing implementation decisions into the list.
We put List A into the test project as comments, right above the first test. Keeping the list next to the code reminds you to update it on every green:
namespace Bookstore.Discounts.Tests;
// Test list: CouponValidator
// [ ] A 10% coupon gives 20 off a 200 subtotal
// [ ] A 50 fixed-amount coupon gives 50 off a 200 subtotal
// [ ] A fixed-amount coupon never exceeds the subtotal (50 coupon, 40 cart: 40)
// [ ] An expired coupon is rejected
// [ ] A coupon is still valid on its last day
// [ ] A cart below the minimum subtotal is rejected
// [ ] A cart exactly at the minimum is accepted
// [ ] Unknown coupon code?
public sealed class CouponValidatorTests
{
[Fact]
public void Percentage_coupon_takes_its_share_of_the_subtotal()
{
Coupon coupon = Coupon.Percentage("BOOK10", 10m);
CouponValidator validator = new();
CouponResult result = validator.Validate(coupon, subtotal: 200m);
Assert.Equal(20m, result.Discount);
}
}
The last item ends with a question mark on purpose. A test list does not always consist of clear answers; "I don't know this yet" items belong on it too, and when their turn comes they either become tests or leave the list. Nor is the list a priority order. Choosing which item to start with is a skill of its own: as Fowler puts it, a good order takes you quickly to the important design decisions. We started with the simplest and most central behavior, the percentage coupon.
The First Loop: Red, Then Green
When we write the test there is no type called Coupon, CouponValidator or CouponResult. The test describes how we want to use the code: create the coupon with a factory method, hand the validator the cart subtotal, read the discount from the result. Running dotnet test, the first red comes from the compiler:
tests/Bookstore.Discounts.Tests/CouponValidatorTests.cs(18,9): error CS0246: The type or namespace name 'Coupon' could not be found (are you missing a using directive or an assembly reference?)
tests/Bookstore.Discounts.Tests/CouponValidatorTests.cs(18,25): error CS0103: The name 'Coupon' does not exist in the current context
tests/Bookstore.Discounts.Tests/CouponValidatorTests.cs(19,9): error CS0246: The type or namespace name 'CouponValidator' could not be found (are you missing a using directive or an assembly reference?)
tests/Bookstore.Discounts.Tests/CouponValidatorTests.cs(21,9): error CS0246: The type or namespace name 'CouponResult' could not be found (are you missing a using directive or an assembly reference?)
A test that doesn't compile is red too, but it doesn't teach us anything yet. So first we write just enough code for the test to compile and leave Validate wrong on purpose:
namespace Bookstore.Discounts;
public enum CouponKind { Percentage, FixedAmount }
public sealed record Coupon(string Code, CouponKind Kind, decimal Value)
{
public static Coupon Percentage(string code, decimal percent) => new(code, CouponKind.Percentage, percent);
}
public sealed record CouponResult(decimal Discount);
public sealed class CouponValidator
{
public CouponResult Validate(Coupon coupon, decimal subtotal) => new(0m);
}
Now we see the real red (I shortened the paths and the framework lines of the stack trace):
Assert.Equal() Failure: Values differ
Expected: 20
Actual: 0
at Bookstore.Discounts.Tests.CouponValidatorTests.Percentage_coupon_takes_its_share_of_the_subtotal() in …/CouponValidatorTests.cs:23
…
Test run summary: Failed!
total: 1
failed: 1
succeeded: 0
skipped: 0
duration: 294ms
Why does this red matter? Because it proves the test actually measures something. A test with a broken assertion, or one that never runs, can look green from the start. Seeing red for the right reason, "expected 20, got 0", is the test being tested. Then we write the code that makes it pass. The implementation of this behavior is obvious, so we type it directly:
public CouponResult Validate(Coupon coupon, decimal subtotal) => new(subtotal * coupon.Value / 100m);
Test run summary: Passed!
total: 1
failed: 0
succeeded: 1
skipped: 0
duration: 199ms
What to do when the code is not this obvious, returning a constant and generalizing it later or forcing the implementation with a second test, is the topic of the next post.
Refactoring and Updating the List
There is nothing to refactor after the first green; we skip the step consciously. We go back to the list, tick off the first item and turn the second one, the fixed-amount coupon, into a test:
[Fact]
public void Fixed_amount_coupon_takes_its_amount_off()
{
Coupon coupon = Coupon.FixedAmount("SAVE50", 50m);
CouponValidator validator = new();
CouponResult result = validator.Validate(coupon, subtotal: 200m);
Assert.Equal(50m, result.Discount);
}
After adding the FixedAmount factory method and running, the red comes with an interesting number:
failed Bookstore.Discounts.Tests.CouponValidatorTests.Fixed_amount_coupon_takes_its_amount_off (8ms)
Assert.Equal() Failure: Values differ
Expected: 50
Actual: 100
…
total: 2
failed: 1
succeeded: 1
The 100 comes from the current code reading 50 as a percentage: 50% of 200. The red describes the missing behavior exactly. We take the shortest path to green:
public CouponResult Validate(Coupon coupon, decimal subtotal)
{
if (coupon.Kind == CouponKind.FixedAmount)
{
return new(coupon.Value);
}
return new(subtotal * coupon.Value / 100m);
}
Both tests are green. Now, in the refactor step, it's time to think about the implementation design. The items still waiting on the list (expiry, minimum subtotal) will add rules to Validate. Separating the discount calculation from the rules keeps the next steps readable. Without changing behavior, we move the if into a switch expression in a separate method:
public sealed class CouponValidator
{
public CouponResult Validate(Coupon coupon, decimal subtotal) => new(DiscountFor(coupon, subtotal));
private static decimal DiscountFor(Coupon coupon, decimal subtotal) => coupon.Kind switch
{
CouponKind.Percentage => subtotal * coupon.Value / 100m,
CouponKind.FixedAmount => coupon.Value,
_ => throw new ArgumentOutOfRangeException(nameof(coupon), coupon.Kind, "Unknown coupon kind.")
};
}
Test run summary: Passed!
total: 2
failed: 0
succeeded: 2
skipped: 0
duration: 276ms
The rule of refactoring is simple: tests green before, green after. No new behavior is added in this step and no tests are changed. There is no reason yet to move to the Strategy pattern or separate rule classes; a switch is enough for two coupon kinds. Beck's warning for this step is to treat duplication as a hint, not a command: build the abstraction when the need has really shown up.
The interesting part is what the list looks like after two loops:
// Test list: CouponValidator
// [x] A 10% coupon gives 20 off a 200 subtotal
// [x] A 50 fixed-amount coupon gives 50 off a 200 subtotal
// [ ] A fixed-amount coupon never exceeds the subtotal (50 coupon, 40 cart: 40)
// [ ] An expired coupon is rejected
// [ ] A coupon is still valid on its last day
// [ ] A cart below the minimum subtotal is rejected
// [ ] A cart exactly at the minimum is accepted
// [ ] NEW: 15% coupon on a 99.90 cart: the discount is rounded to cents
// [ ] NEW: Coupon.Percentage(0) and Percentage(120) are not allowed (Coupon's job)
// [ ] NEW: What does the result look like for a rejected coupon? (zero discount or an error message?)
// [-] Unknown coupon code: the coupon lookup's job, not the validator's
// [?] Where does "today" come from? Decide before the date tests
Three items were born during the loops. While writing the percentage formula we noticed that the decimal result can carry more digits than cents. While writing the factory method the limits of the percentage value came to mind, but that is Coupon's behavior, not CouponValidator's, so we noted it. The pending rejection items also say that the current shape of CouponResult won't be enough. The unknown coupon code item left the list: the code that looks the coupon up in the database will live somewhere else; the validator judges the coupon it is given. The "today" question stays open; a test that uses DateTime.Now gives a different result tomorrow. We answer it in the next post with TimeProvider.
This list is a far more valuable document than the design of the code. It shows which behaviors are known, which are questions and which decisions were postponed on purpose.
The Three Laws vs Canon TDD
Another common description of TDD is Robert C. Martin's (Uncle Bob) "three laws of TDD". As he states them in his 2014 post The Cycles of TDD, in our own words: you write no production code without a failing test; you write only as much test as is enough to fail, or to fail to compile; and you write only as much production code as makes that test pass. Martin describes these laws as a "nano-cycle" that turns over in seconds, with red-green-refactor as a cycle above it on the scale of minutes.
The two descriptions don't contradict each other, but they shine light on different places. The three laws describe the rhythm at the keyboard: going back and forth between test and code within seconds. Canon TDD makes the decisions around that rhythm visible: the test list step says a behavioral analysis happens before the test; the optional refactor step says where design changes. The three laws have no test list, and refactoring is not part of the rules.
In practice this difference fed two misunderstandings. The first is that TDD is a rigid ritual in which you mechanically switch between test and code on every line. That perception is the source of experienced developers' objection, "steps this small feel slow to me". Beck, however, suggests adjusting the step size to the uncertainty; we see that in the next post. The second goes the opposite way: the belief that TDD means "write all the tests first, then the code". In Canon TDD Beck explicitly lists this as a mistake. Turning the whole list into tests up front means rewriting them all when early decisions change, and a demoralizing stretch during which nothing passes.
In Production
Where the test list lives. Keeping the list as comments in the test file works well for small jobs, but it doesn't need to be committed as a comment. Some teams move the list into the task description or the pull request description. What matters is that the list lives until the work is done and is empty when it is done. A list in a pull request that still has "[ ]" items shows either a half-finished behavior or a consciously postponed decision; both deserve a conversation in the review.
Loop time. The rhythm of TDD depends on the speed of the tests. Running the two tests in this post took 200–300 ms excluding the build. Spending seconds per loop keeps the rhythm; waiting minutes for a single test file breaks it and pushes you to take bigger steps. The first place to look in a slowing test suite is hidden I/O: database, file system, network. The Unit Test series covers this in detail.
Seeing red. When you hear "I wrote the test and it passed" on the team, ask: was it red first? A test that has never been red may or may not be verifying anything. Especially for tests added to existing code later, breaking the code on purpose once and watching the test go red is cheap insurance.
Common Pitfalls
Writing all the tests up front. Writing the eight items on the list as eight tests and only then starting on the code looks like TDD but isn't. When you decide to change the shape of CouponResult at the third test, you fix all eight and see nothing but red for a long time. A list item is an item, not a test; the moment it becomes a test is when its turn comes.
Skipping the refactor step. According to Fowler this is the most common way to break TDD. If every green is met with "it works, move on", the code becomes a set of pieces patched together that happen to pass the tests. Because the tests are there, the mess can at least be fixed safely, but the fix gets more expensive the longer it waits.
Turning the test list into a list of implementation steps. List B was the example. If you see class, interface or pattern names on the list, you have skipped the behavioral analysis and jumped to design. The items on a test list are sentences a product owner who has never seen the code could read and say "yes, that's how it should be".
Loosening the assertion to make the test pass. When a red test is stubborn it is tempting to delete the assertion or replace the expected value with "whatever actually comes back". Beck lists this among the Canon TDD mistakes as well: the test loses its double-checking value. Derive the expected value from the behavior, not from the code.
A Small Experiment
★☆☆ Turn the "a fixed-amount coupon never exceeds the subtotal" item into a test. When you see red, what exactly does the message say? What is the smallest change that gets you to green?
★★☆ Write a test list for Bookstore's shipping fee rule (free shipping above a certain amount, a surcharge for some regions, a weight fee for heavy orders) without touching any code. Then read the list again: how many items mention a class or method name? Rewrite them as behavior sentences.
★★★ Try applying the list items in two different orders: start once with the "minimum subtotal" item and once with the "expired" item. What did the first version of CouponResult look like in each attempt? How did the order affect the design?
In the next post we finish this list: the three ways to get to green (fake it, triangulation, obvious implementation), adjusting the step size to the uncertainty, and adding the expiry date test-first with TimeProvider, in one live session.