Mert Özen Further With Every Line
Testing & Quality TDD — #02

Red-Green-Refactor in Practice: Fake It, Triangulation, Obvious Implementation and Baby Steps

Mert Özen Nov 14, 2026 12 min 2 views
Red-Green-Refactor in Practice: Fake It, Triangulation, Obvious Implementation and Baby Steps

There are three ways to turn a red test green: obvious implementation when you know the code, fake it when you don't, triangulation when the design is unclear; and every surprising red means a smaller step. Growing CouponValidator live with xUnit v3, we see a banker's rounding surprise, a fake that breaks old tests, verifying a test that was green right away by breaking the code, an Istanbul-time expiry rule with TimeProvider and a small commit on every green, with real output.

Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.3 and Microsoft.Extensions.TimeProvider.Testing 10.11.0.

Predict First

In the previous post two items on CouponValidator's test list were done and a new item had been added: the percentage discount must be rounded to cents. We write the test: a 15% coupon on a 99.90 cart. The raw result is 14.985. We write 14.99 as the expected value and, to get to green, add the first line that comes to mind:

CouponKind.Percentage => Math.Round(subtotal * coupon.Value / 100m, 2),

The code looks obvious: round to two digits. Does the test turn green? If not, which number shows up on the screen? Pick an answer; we see it in the first loop.

Three Ways to Get to Green

Kent Beck's Test-Driven Development: By Example (2002) describes three strategies for turning a red test green. All are part of TDD; the difference is how sure you are.

Obvious implementation: if you know the right code, you type it. The percentage calculation in the previous post was like that. It is the fastest path, and an experienced developer spends most of their time in this gear.

Fake it: you return the result the test expects as a constant, then turn the constant into the real expression step by step. It sounds silly, but it works: first you see that the test is set up correctly and that the code is wired to the right place, and you think about the general solution afterwards.

Triangulation: when one example isn't enough to generalize, you write a second test that asks for the same behavior with different values. The two tests prove the constant isn't enough and force you to the general solution. Beck says he uses this path only when he is really unsure about the right abstraction.

The selection rule is simple and works like the gears of a car: if you know it, write the obvious implementation. If you don't know what to type, fake it. If the design is still unclear, triangulate. Most importantly: if a red bar surprises you while you are going with the obvious implementation, shift down. Step size is adjusted to uncertainty; when unsure, make it smaller, when sure, make it bigger.

Obvious Implementation and Shifting Down

The first item on the list is that a fixed-amount coupon must not exceed the cart subtotal. A 50 coupon applied to a 40 cart should give 40 off. The test is set up just like the tests in the previous post; the red output states exactly the expected failure:

failed Bookstore.Discounts.Tests.CouponValidatorTests.Fixed_amount_coupon_never_exceeds_the_subtotal (8ms)
  Expected: 40
  Actual:   50

The solution is obvious, so we type it:

CouponKind.FixedAmount => Math.Min(coupon.Value, subtotal),

Three tests green. The next item is rounding, with the line from "Predict First". The test:

[Fact]
public void Percentage_discount_is_rounded_to_cents()
{
    Coupon coupon = Coupon.Percentage("BOOK15", 15m);
    CouponValidator validator = new();

    CouponResult result = validator.Validate(coupon, subtotal: 99.90m);

    Assert.Equal(14.99m, result.Discount);
}

First we see red with the unrounded code, then we add the line with Math.Round. The output of the two runs:

failed Bookstore.Discounts.Tests.CouponValidatorTests.Percentage_discount_is_rounded_to_cents (2ms)
  Expected: 14.99
  Actual:   14.985

failed Bookstore.Discounts.Tests.CouponValidatorTests.Percentage_discount_is_rounded_to_cents (1ms)
  Expected: 14.99
  Actual:   14.98

The first red was expected. The second is a surprise: 14.985 was rounded to two digits as 14.98. The Math.Round documentation on Microsoft Learn explains why: overloads that don't take a mode round a value exactly in the middle to the nearest even number (MidpointRounding.ToEven, also known as banker's rounding). 8 is even, hence 14.98.

This is the moment to shift down. Code you thought was "obvious" produced a result you didn't expect; instead of carrying on guessing, stop and make clear what you don't know. Here the unknown was not technical but a business rule: when the cent is exactly in the middle, does it round up or to even? Let's assume you discussed it with the product owner and accounting, and the answer is "up". Then we write the mode explicitly:

CouponKind.Percentage => Math.Round(subtotal * coupon.Value / 100m, 2, MidpointRounding.AwayFromZero),

Four tests green. The test made an unclear part of the business rule visible and documented the decision permanently in code. Had we not written the test first, banker's rounding would have gone to production silently and the differences of a few cents would have surfaced at month end during the accounting reconciliation.

Fake It and Triangulation: The Minimum Subtotal

The next item is rejecting the coupon on a cart below the minimum subtotal. This item also has to answer the question left open at the end of the previous post, "what does the result look like for a rejected coupon?". We decide while writing the test: CouponResult gets a nullable rejection reason, and a rejected coupon has a zero discount.

[Fact]
public void Coupon_is_rejected_below_its_minimum_subtotal()
{
    Coupon coupon = Coupon.Percentage("BOOK10", 10m) with { MinimumSubtotal = 150m };
    CouponValidator validator = new();

    CouponResult result = validator.Validate(coupon, subtotal: 100m);

    Assert.Equal(CouponRejection.BelowMinimumSubtotal, result.Rejection);
    Assert.Equal(0m, result.Discount);
}

A with expression is a comfortable way to add an optional property to the coupon without changing the factory methods. To clear the compiler errors we add a MinimumSubtotal init property to Coupon, a CouponRejection enum and a second parameter to CouponResult. Red: Expected: BelowMinimumSubtotal, Actual: null.

Now we move on with fake it on purpose. The first attempt copies the constant from the test into the code:

if (subtotal < 150m)
{
    return new(0m, CouponRejection.BelowMinimumSubtotal);
}
failed Bookstore.Discounts.Tests.CouponValidatorTests.Fixed_amount_coupon_never_exceeds_the_subtotal (6ms)
  Expected: 40
  Actual:   0
failed Bookstore.Discounts.Tests.CouponValidatorTests.Percentage_discount_is_rounded_to_cents (0ms)
  Expected: 14.99
  Actual:   0
Test run summary: Failed!
  total: 5
  failed: 2
  succeeded: 3

The new test passed but two old tests broke: the 40 and 99.90 carts are below 150 as well. Step three of Canon TDD says it plainly: green means not just the new test but all the earlier tests pass. A fake must not be fake for the rest of the system. We try a narrower fake: reject every coupon that has a minimum.

if (coupon.MinimumSubtotal > 0m)
{
    return new(0m, CouponRejection.BelowMinimumSubtotal);
}

Five tests green. The code is plainly wrong, but the tests don't say so yet. This is exactly where triangulation comes in: the next item on the list is already the example that disproves the fake.

[Fact]
public void Coupon_is_accepted_above_its_minimum_subtotal()
{
    Coupon coupon = Coupon.Percentage("BOOK10", 10m) with { MinimumSubtotal = 150m };
    CouponValidator validator = new();

    CouponResult result = validator.Validate(coupon, subtotal: 200m);

    Assert.Null(result.Rejection);
    Assert.Equal(20m, result.Discount);
}
failed Bookstore.Discounts.Tests.CouponValidatorTests.Coupon_is_accepted_above_its_minimum_subtotal (3ms)
  Assert.Null() Failure: Value of type 'Nullable<CouponRejection>' has a value
  Expected: null
  Actual:   BelowMinimumSubtotal

Together the two tests prove that the constant doesn't work. Now we have to write the general solution, and what we write is a single comparison:

if (subtotal < coupon.MinimumSubtotal)

In this example the general solution was clear from the start; in a real project you wouldn't need fake it for a comparison this simple. The value of fake it and triangulation shows when the shape of the calculation is not clear: tax brackets, tiered discounts or a price calculation that combines several rules. In those cases two or three concrete examples are a more reliable guide than building the general formula in your head.

The Test That Is Green Right Away

There is a boundary item on the list: a cart exactly at the minimum is accepted. A 150 cart, a coupon with a 150 minimum. We write the test and run it, and on the first run seven tests are green. We never saw red.

This can mean two things. Either the behavior was already written correctly (the < operator accepts equality), or the test passes for the wrong reason: it checks the wrong variable, an assertion is missing or the test doesn't run at all. The cheap way to find out which is to break the code on purpose. We change < to <= and run again:

failed Bookstore.Discounts.Tests.CouponValidatorTests.Coupon_is_accepted_exactly_at_its_minimum_subtotal (1ms)
  Assert.Null() Failure: Value of type 'Nullable<CouponRejection>' has a value
  Expected: null
  Actual:   BelowMinimumSubtotal
Test run summary: Failed!
  total: 7
  failed: 1
  succeeded: 6

The test turned red, so it really guards the boundary. We put the operator back and return to green. This small experiment is mutation testing done by hand; the coverage post in the Test Strategy series shows how to do it with a tool. Accepting a test that is green right away without this check means giving up TDD's most important guarantee.

A Date Rule with TimeProvider

The question left from the previous post: where does "today" come from? A rule that uses DateTime.Now passes in the test today and breaks tomorrow. The TimeProvider abstraction that came with .NET 8 exists for this job; in tests we can put time at any moment we want with FakeTimeProvider. The details of testing time are in the hard cases post of the Unit Test series; here we focus only on the test-first flow.

Writing the test makes the design decision for us: the validator must take time through its constructor.

[Fact]
public void Expired_coupon_is_rejected()
{
    FakeTimeProvider time = new(new DateTimeOffset(2026, 11, 14, 10, 0, 0, TimeSpan.Zero));
    Coupon coupon = Coupon.Percentage("BOOK10", 10m) with { ExpiresOn = new DateOnly(2026, 11, 13) };
    CouponValidator validator = new(time);

    CouponResult result = validator.Validate(coupon, subtotal: 200m);

    Assert.Equal(CouponRejection.Expired, result.Rejection);
    Assert.Equal(0m, result.Discount);
}

Changing the constructor will also affect the seven old tests that call the parameterless constructor with new(). We update them in the same step with new(new FakeTimeProvider()) and leave the tidying to the refactor step. Because the production code hasn't changed yet, the compiler reports CS1729 (no constructor that takes one argument) in eight places and CS0117 (no ExpiresOn, no Expired) in two. After making the code compile and seeing the red (Expected: Expired, Actual: null), we write the first implementation that comes to mind:

DateOnly today = DateOnly.FromDateTime(time.GetUtcNow().UtcDateTime);
if (coupon.ExpiresOn < today)
{
    return new(0m, CouponRejection.Expired);
}

Eight tests green. But the list has the item "a coupon is still valid on its last day", and writing it raises a question: the last day where? Bookstore sells in Türkiye; if a coupon ends on 14 November it should be valid until midnight Istanbul time. UTC (Coordinated Universal Time) and Istanbul are three hours apart. We write two tests together: one says the coupon is valid at 23:59 in Istanbul, the other says it is invalid at 00:30 (while it is still 14 November in UTC).

[Fact]
public void Coupon_expires_at_midnight_Istanbul_time_not_UTC()
{
    // 21:30 UTC = 00:30 Istanbul, next day
    FakeTimeProvider time = new(new DateTimeOffset(2026, 11, 14, 21, 30, 0, TimeSpan.Zero));
    Coupon coupon = Coupon.Percentage("BOOK10", 10m) with { ExpiresOn = new DateOnly(2026, 11, 14) };
    CouponValidator validator = new(time);

    CouponResult result = validator.Validate(coupon, subtotal: 200m);

    Assert.Equal(CouponRejection.Expired, result.Rejection);
}
failed Bookstore.Discounts.Tests.CouponValidatorTests.Coupon_expires_at_midnight_Istanbul_time_not_UTC (0ms)
  Assert.Equal() Failure: Values differ
  Expected: Expired
  Actual:   null
Test run summary: Failed!
  total: 10
  failed: 1
  succeeded: 9

The 23:59 test passed on the first run (it is still 14 November in UTC too), and the 00:30 test is red for the right reason. Once the day calculation moves to the store's time zone, ten tests are green:

private static readonly TimeZoneInfo StoreTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");

// inside Validate
DateOnly today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(time.GetUtcNow(), StoreTimeZone).DateTime);

What to Look at in the Refactor Step

With ten tests green, we wear the two hats one at a time: first the production code, without touching the tests; then the test code, without touching the production code.

On the production side the repeated new(0m, CouponRejection.X) expressions hide the fact that rejection and acceptance are built with the same constructor. Named factory methods state the intent, and the day calculation moves into its own method:

public sealed record CouponResult(decimal Discount, CouponRejection? Rejection)
{
    public static CouponResult Accepted(decimal discount) => new(discount, null);
    public static CouponResult Rejected(CouponRejection reason) => new(0m, reason);
}

public sealed class CouponValidator(TimeProvider time)
{
    private static readonly TimeZoneInfo StoreTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");

    public CouponResult Validate(Coupon coupon, decimal subtotal)
    {
        if (coupon.ExpiresOn < StoreToday())
        {
            return CouponResult.Rejected(CouponRejection.Expired);
        }

        if (subtotal < coupon.MinimumSubtotal)
        {
            return CouponResult.Rejected(CouponRejection.BelowMinimumSubtotal);
        }

        return CouponResult.Accepted(DiscountFor(coupon, subtotal));
    }

    private DateOnly StoreToday() =>
        DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(time.GetUtcNow(), StoreTimeZone).DateTime);

    // DiscountFor is unchanged
}

On the test side every test repeats the same three lines: set up a FakeTimeProvider, create the validator, call it. That repetition means going through ten tests again at the next constructor change. We gather it into a single helper method and switch the assertions to record comparison:

public sealed class CouponValidatorTests
{
    private static readonly DateTimeOffset NoonInIstanbul = new(2026, 11, 14, 9, 0, 0, TimeSpan.Zero);
    private static readonly Coupon Book10 = Coupon.Percentage("BOOK10", 10m);

    private static CouponResult Validate(Coupon coupon, decimal subtotal, DateTimeOffset? now = null)
    {
        CouponValidator validator = new(new FakeTimeProvider(now ?? NoonInIstanbul));
        return validator.Validate(coupon, subtotal);
    }

    [Fact]
    public void Coupon_is_rejected_below_its_minimum_subtotal()
    {
        CouponResult result = Validate(Book10 with { MinimumSubtotal = 150m }, subtotal: 100m);

        Assert.Equal(CouponResult.Rejected(CouponRejection.BelowMinimumSubtotal), result);
    }

    [Fact]
    public void Coupon_expires_at_midnight_Istanbul_time_not_UTC()
    {
        // 21:30 UTC = 00:30 Istanbul, next day
        DateTimeOffset afterMidnight = new(2026, 11, 14, 21, 30, 0, TimeSpan.Zero);

        CouponResult result = Validate(Book10 with { ExpiresOn = new DateOnly(2026, 11, 14) }, 200m, afterMidnight);

        Assert.Equal(CouponResult.Rejected(CouponRejection.Expired), result);
    }

    // The other eight tests follow the same shape
}
Test run summary: Passed!
  total: 10
  failed: 0
  succeeded: 10
  skipped: 0
  duration: 328ms

Record comparison changed one detail: the acceptance tests now verify not only the discount but also that the rejection reason is empty. Strengthening an assertion is not a change in behavior, but it is still a good habit to break each strengthened test on purpose once and watch it turn red.

The frame for what to look at while refactoring is in the Clean Code series post on the four rules of Simple Design: tests pass, intent is clear, no duplication, fewest elements. In this round we worked on duplication and intent. The "fewest elements" rule explains what we did not do: we didn't build separate rule classes or a strategy pattern for two rules. When the third and fourth rules arrive and Validate becomes hard to read, that abstraction will show itself. Abstracting duplication early is betting on a direction of change you haven't seen yet.

Commit Rhythm

A small commit on every green. The git log --oneline output of this session:

bbcaf57 Refactor tests: one Validate helper, compare whole results
51bf43e Refactor: named CouponResult factories and StoreToday
0e0b153 Use the store's Istanbul date for coupon expiry
bd250ff Reject expired coupons using TimeProvider
72600d1 Pin the minimum subtotal boundary
afff0d1 Compare subtotal with the coupon minimum (triangulated)
de181a4 Reject coupons with a minimum subtotal (fake it)
4d8c0a2 Round percentage discount to cents, midpoint away from zero
e3269c3 Cap fixed-amount discount at the subtotal
c1c4820 Percentage and fixed-amount coupons

This rhythm has two benefits. The first is a point to return to: if a refactoring goes wrong, or a new test unexpectedly breaks old ones, you go back to the last green with a single command and take the step again, smaller, instead of debugging. The second is that refactoring commits stand apart from behavior commits, which makes it easy to say in a review "behavior didn't change in this commit".

Don't let a commit containing a fake in the history bother anyone; the local branch is your workspace. If you want to combine these commits before opening a pull request, the interactive rebase and squash post in the Git series is exactly for that.

In Production

Time zone data. IANA IDs such as Europe/Istanbul work on Windows too since .NET 6, but the conversion relies on the ICU library of the operating system. If time zone data is missing in a container image, FindSystemTimeZoneById throws an exception. So that tests don't pass on the developer machine and blow up in production, it is more robust to run this line once at application startup and read the store time zone from configuration.

Server time. In this post we used the store's time zone explicitly instead of TimeProvider.GetLocalNow(). Most servers run in UTC; "local" is the server's local, not the customer's. For every test that involves a date rule, put "the day in which time zone?" on the test list as an item.

Rounding decisions. In any code that calculates money, the rounding mode is a business rule and should be written explicitly in a test. To see for which amounts a mode makes a difference, put midpoint values (like 14.985) into the test data on purpose.

Common Pitfalls

Moving on without seeing red. A test that is green right away shows either that the behavior already exists or that the test measures nothing. Don't move on without knowing which; break the code once and watch the test turn red.

Adding new behavior while green. With the tests green it is tempting to add an if to the code "while I'm here, let me check the percentage limit too". No test asks for that if; if someone deletes it later, nothing breaks. New behavior goes to the list first, then into a red test, and only then into the code.

Changing the tests while refactoring. If you touch the tests while refactoring production code, green loses its meaning: did the tests change, or the behavior? Wear the two hats separately and run the tests at the end of each.

Forgetting the fake. If a test passed with fake it is not followed by a test that disproves it, the constant goes to production. The moment you write a fake, add the disproving example to the test list.

A Small Experiment

★☆☆ Add the remaining list item "Coupon.Percentage(0) and Percentage(120) are not allowed" test-first. Which exception type did you choose, and did the test make that decision for you?

★★☆ Add a "valid on weekends only" rule to coupons. Write the test list first; set up the boundary tests by converting Saturday 00:00 and Sunday 23:59 Istanbul time to UTC. Which of these tests breaks an implementation that uses GetLocalNow()?

★★★ Design a tiered coupon: 5% up to 200, 10% between 200 and 500, 15% above 500. This time use fake it and triangulation because you really need them: how many examples did you need before writing the general formula?

In the next post we step away from production code and move to katas: with String Calculator and Bowling Game we see how to practice this rhythm over and over without memorizing the result.