Test-Driven Development in C# · Lesson 2 of 6
Writing Your First TDD Feature — End to End
The Red-Green-Refactor Cycle
Red: Write a test that fails.
The test describes the behaviour you want — that doesn't exist yet.
If the test passes without writing code, you are testing the wrong thing.
Green: Write the minimum code to make the test pass.
Not clean code. Not production code. Just: make it pass.
"Fake it till you make it" is valid at this stage.
Refactor: Clean up the code you just wrote.
Remove duplication. Improve naming. Extract helpers.
The tests tell you when you break something.
The cycle length: minutes, not hours.
If a cycle takes more than 15 minutes, the step is too large. Break it down.A First Test — Clinical Domain Rule
// Domain rule: Warfarin prescriptions require an INR check within 24 hours before approval.
// Start with the test — no production code yet.
// Tests/Domain/PrescriptionTests.cs
public sealed class PrescriptionApprovalTests
{
[Fact]
public void Approve_WithRecentInrCheck_Succeeds()
{
// Arrange
var prescription = Prescription.CreateDraft(
PatientId.Of(Guid.NewGuid()),
MedicationName.Of("Warfarin"),
DosageValue.Of(5m, "mg"));
var inrValue = 2.5m;
var checkedAt = DateTime.UtcNow.AddHours(-2); // 2 hours ago — within 24h
// Act
var result = prescription.Approve(inrValue, checkedAt, approvedBy: Guid.NewGuid());
// Assert
result.IsSuccess.Should().BeTrue();
prescription.Status.Should().Be(PrescriptionStatus.Approved);
}
}Running this test: RED
Cannot compile — Prescription, PatientId, MedicationName, DosageValue don't exist.
That's fine. The test is describing what we want.Making It Compile (Still Red)
// Create minimal stubs to make the test compile
public sealed class Prescription
{
public PrescriptionStatus Status { get; private set; } = PrescriptionStatus.Draft;
public static Prescription CreateDraft(
PatientId patientId,
MedicationName medication,
DosageValue dosage) => new();
public Result Approve(decimal inrValue, DateTime checkedAt, Guid approvedBy)
{
// TODO: implement
throw new NotImplementedException();
}
}
public enum PrescriptionStatus { Draft, Approved, Suspended }
// Value objects — bare minimum to compile:
public sealed record PatientId(Guid Value) { public static PatientId Of(Guid v) => new(v); }
public sealed record MedicationName(string Value) { public static MedicationName Of(string v) => new(v); }
public sealed record DosageValue(decimal Amount, string Unit)
{
public static DosageValue Of(decimal a, string u) => new(a, u);
}Running the test: RED (throws NotImplementedException)
Good. The test fails for the right reason.Making It Pass (Green)
public Result Approve(decimal inrValue, DateTime checkedAt, Guid approvedBy)
{
if (checkedAt < DateTime.UtcNow.AddHours(-24))
return Result.Failure(Error.Validation("InrCheck",
"INR check must be within the last 24 hours."));
Status = PrescriptionStatus.Approved;
return Result.Success();
}Running the test: GREEN
Minimum code to pass the test written.
Don't gold-plate — the refactor step comes next.Adding More Cases (Still TDD)
[Fact]
public void Approve_WithExpiredInrCheck_Fails()
{
var prescription = Prescription.CreateDraft(
PatientId.Of(Guid.NewGuid()),
MedicationName.Of("Warfarin"),
DosageValue.Of(5m, "mg"));
var checkedAt = DateTime.UtcNow.AddHours(-25); // older than 24h
var result = prescription.Approve(2.5m, checkedAt, Guid.NewGuid());
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Validation.InrCheck");
}
[Fact]
public void Approve_AlreadyApproved_Fails()
{
var prescription = Prescription.CreateDraft(
PatientId.Of(Guid.NewGuid()),
MedicationName.Of("Warfarin"),
DosageValue.Of(5m, "mg"));
prescription.Approve(2.5m, DateTime.UtcNow, Guid.NewGuid());
// Second approval should fail
var result = prescription.Approve(2.5m, DateTime.UtcNow, Guid.NewGuid());
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Contain("Status");
}Refactor — Cleaning Up After Green
// After all cases pass, refactor for clarity
public sealed class Prescription
{
private const int InrValidityHours = 24;
public PrescriptionStatus Status { get; private set; } = PrescriptionStatus.Draft;
public decimal? InrValue { get; private set; }
public DateTime? InrCheckedAt { get; private set; }
public static Prescription CreateDraft(
PatientId patientId,
MedicationName medication,
DosageValue dosage)
{
ArgumentNullException.ThrowIfNull(patientId);
ArgumentNullException.ThrowIfNull(medication);
ArgumentNullException.ThrowIfNull(dosage);
return new Prescription();
}
public Result Approve(decimal inrValue, DateTime checkedAt, Guid approvedBy)
{
if (Status != PrescriptionStatus.Draft)
return Result.Failure(Error.Validation("Status",
"Only Draft prescriptions can be approved."));
if (InrCheckIsExpired(checkedAt))
return Result.Failure(Error.Validation("InrCheck",
$"INR check must be within the last {InrValidityHours} hours."));
InrValue = inrValue;
InrCheckedAt = checkedAt;
Status = PrescriptionStatus.Approved;
return Result.Success();
}
private static bool InrCheckIsExpired(DateTime checkedAt) =>
checkedAt < DateTime.UtcNow.AddHours(-InrValidityHours);
}Re-run tests after refactor: all GREEN
Refactoring is safe because the tests tell you immediately if you break something.Production issue I've seen: A team wrote tests after the implementation — "test-after" rather than TDD. The tests were written to match the existing code, not to specify desired behaviour. When the INR validity window changed from 24 hours to 12 hours (a clinical guideline update), the developer updated the constant in production code but not in the tests. All tests still passed. The 24-hour check was wrong in production for 3 weeks before a pharmacist noticed. With TDD, the test would have been written first as
checkedAt = DateTime.UtcNow.AddHours(-13)→ should fail. Updating the constant would have fixed both the test and the code simultaneously.
Key Takeaway
Red-Green-Refactor: write a failing test first, write minimal code to pass, then clean up. The test specifies desired behaviour before the code exists. Don't write more code than necessary to make the test pass — the next test drives the next increment. Refactor only when tests are green. In a clinical domain, TDD ensures that rule changes (INR validity, dose limits) are expressed as test cases first — making regressions impossible to silently introduce.