Let’s approach TDD as a design discipline rather than a demand to write more tests.
When I mentor a .NET developer on test-driven development, I do not stop at “write the test first.” I want them to understand how a failing test clarifies behaviour, how dependency injection creates testable boundaries, how test doubles isolate risk and how a reliable test suite gives a team confidence to change code.
We will work through xUnit, red–green–refactor, Arrange–Act–Assert, dependency injection, test doubles, FIRSTHAND guidelines, Domain-Driven Design, repositories, Entity Framework Core, continuous integration, brownfield systems and team adoption.
The real value is that tests become executable documentation, design pressure, regression protection and confidence.
Let’s build the mental model.
Requirement
↓
Write a failing test
↓
Write the smallest production code to pass
↓
Refactor safely
↓
Repeat
This is the rhythm. Not theory. Rhythm.
1. Unit testing versus TDD: do not confuse them
Unit testing and TDD are related, but they are not the same thing.
Unit testing means you test a small behaviour of your system in isolation.
TDD means you write the test first, let it fail, then write the production code, then refactor.
So the difference is timing and mindset.
A team can have unit tests without doing TDD. They write code first, then add tests afterwards. That is still useful, but the tests are no longer shaping the design.
In TDD, the test is the first client of your production code.
That is important.
Before a controller, UI, database, or API consumer uses your method, the unit test uses it. If the method is awkward to test, that is usually a design smell.
A practical starting point is a class library with a matching xUnit project. The production project contains the code; the test project references it and verifies its behaviour.
A normal production method might look like this:
public class Division
{
public static decimal Divide(int dividend, int divisor)
{
decimal quotient = (decimal)dividend / divisor;
return quotient;
}
}
A matching unit test might look like this:
public class DivisionTests
{
[Fact]
public void Divide_DivisibleIntegers_WholeNumber()
{
// Arrange
int dividend = 10;
int divisor = 5;
decimal expectedQuotient = 2;
// Act
decimal actualQuotient = Division.Divide(dividend, divisor);
// Assert
Assert.Equal(expectedQuotient, actualQuotient);
}
}
This test is simple, but it teaches important ideas.
The class under test is Division.
The method under test is Divide.
The condition is divisible integers.
The expectation is a whole number.
The pattern is Arrange, Act, Assert.
This naming style is valuable:
MethodUnderTest_Condition_Expectation
Examples:
CalculateInterest_ValidLoan_ReturnsMonthlyInterest()
ApproveLoan_AlreadyRejected_ThrowsInvalidOperationException()
CreateApplication_MissingApplicantName_ReturnsValidationError()
I want the reason for a failure to be obvious from the test name.
My rule of thumb:
A good test name should explain the business behaviour before you even open the test body.
2. Red, green, refactor: the real TDD loop
TDD has a famous loop:
Red → write a failing test
Green → make it pass
Refactor → improve the design without changing behaviour
Red is not failure in a bad way. Red is proof that the test can detect missing or incorrect behaviour.
Imagine you are building a loan application system.
Business rule:
“A loan application cannot be submitted if the requested amount is zero or less.”
Start with the test:
public class LoanApplicationTests
{
[Fact]
public void Submit_AmountIsZero_ThrowsValidationException()
{
// Arrange
var application = new LoanApplication(
applicantName: "Sarah Khan",
requestedAmount: 0);
// Act
Action act = () => application.Submit();
// Assert
Assert.Throws<InvalidOperationException>(act);
}
}
At this point, maybe LoanApplication does not even exist.
That is okay. Red can mean the test does not compile yet. Writing the test first can expose missing classes or methods, and that is part of the feedback loop.
Now write the smallest production code:
public class LoanApplication
{
public string ApplicantName { get; }
public decimal RequestedAmount { get; }
public LoanApplicationStatus Status { get; private set; }
public LoanApplication(string applicantName, decimal requestedAmount)
{
ApplicantName = applicantName;
RequestedAmount = requestedAmount;
Status = LoanApplicationStatus.Draft;
}
public void Submit()
{
if (RequestedAmount <= 0)
{
throw new InvalidOperationException(
"Requested amount must be greater than zero.");
}
Status = LoanApplicationStatus.Submitted;
}
}
public enum LoanApplicationStatus
{
Draft,
Submitted,
Approved,
Rejected
}
Now green.
Then you add another test:
[Fact]
public void Submit_ValidAmount_ChangesStatusToSubmitted()
{
// Arrange
var application = new LoanApplication(
applicantName: "Sarah Khan",
requestedAmount: 250_000);
// Act
application.Submit();
// Assert
Assert.Equal(
LoanApplicationStatus.Submitted,
application.Status);
}
Now you are not just testing code. You are designing a domain object.
The test is asking:
“Can I create this object easily?” “Can I express the business rule clearly?” “Is the behaviour visible from outside?” “Is the class doing too much?” “Is the rule hidden in some controller instead of living in the domain?”
That is where TDD becomes design.
My rule of thumb:
TDD is not about writing more tests. It is about letting tests pressure the code into better shape.
3. Arrange, Act, Assert: discipline inside the test
The AAA pattern is one of the simplest but most useful testing habits.
[Fact]
public void Method_Condition_Expectation()
{
// Arrange
// create objects and expected values
// Act
// call the method under test
// Assert
// compare actual result with expected result
}
I use this structure because it keeps tests readable and consistent.
Bad test:
[Fact]
public void TestLoan()
{
var app = new LoanApplication("Sarah", 100000);
app.Submit();
Assert.True(app.Status == LoanApplicationStatus.Submitted);
app.Approve();
Assert.True(app.Status == LoanApplicationStatus.Approved);
}
This test has multiple behaviours. If it fails, what exactly failed? Submit? Approve? Status transition? Constructor?
Better:
[Fact]
public void Submit_DraftApplication_StatusBecomesSubmitted()
{
// Arrange
var application = new LoanApplication("Sarah", 100_000);
// Act
application.Submit();
// Assert
Assert.Equal(
LoanApplicationStatus.Submitted,
application.Status);
}
And separately:
[Fact]
public void Approve_SubmittedApplication_StatusBecomesApproved()
{
// Arrange
var application = new LoanApplication("Sarah", 100_000);
application.Submit();
// Act
application.Approve();
// Assert
Assert.Equal(
LoanApplicationStatus.Approved,
application.Status);
}
One behaviour per test.
Yes, the second test calls Submit() in Arrange. That is acceptable if the behaviour under test is approval and the submitted state is part of the setup.
My rule of thumb:
Keep one clear Act. If your test has many Acts, it is probably testing a workflow, not a unit behaviour.
4. Fact and Theory: one case versus many cases
In xUnit, [Fact] means a fixed test.
[Fact]
public void ConvertCelsiusToFahrenheit_ZeroCelsius_Returns32()
{
// Arrange
var converter = new TemperatureConverter();
// Act
double result = converter.ConvertCToF(0);
// Assert
Assert.Equal(32, result);
}
[Theory] means the same behaviour should be tested with multiple inputs.
public class TemperatureConverterTests
{
[Theory]
[InlineData(0, 32)]
[InlineData(10, 50)]
[InlineData(-10, 14)]
[InlineData(100, 212)]
public void ConvertCToF_ValidCelsius_ReturnsExpectedFahrenheit(
double celsius,
double expectedFahrenheit)
{
// Arrange
var converter = new TemperatureConverter();
// Act
double actual = converter.ConvertCToF(celsius);
// Assert
Assert.Equal(expectedFahrenheit, actual, precision: 1);
}
}
[Theory] and [InlineData] allow several examples of one behaviour to run as separate test cases.
I use theories when the behaviour remains the same and only the data changes.
Do not overuse it.
If the behaviour changes, write separate tests.
My rule of thumb:
Use[Theory]for multiple examples of the same rule. Use separate[Fact]tests for different rules.
5. Dependency Injection: testability begins with design
Now we come to the heart of serious unit testing.
You cannot properly unit test code that is tightly coupled to concrete dependencies.
Imagine this service:
public class LoanApprovalService
{
public async Task ApproveAsync(Guid loanId)
{
var db = new LoanDbContext();
var email = new SmtpEmailSender();
var clock = DateTime.UtcNow;
var loan = await db.Loans.FindAsync(loanId);
loan.Status = "Approved";
loan.ApprovedOn = clock;
await db.SaveChangesAsync();
await email.SendAsync(
loan.ApplicantEmail,
"Your loan has been approved");
}
}
This code may work, but it is painful to unit test.
Why?
Because it creates the database context itself. It creates the email sender itself. It uses the real clock. It sends real email if you are not careful. It hides seams.
Dependency injection matters because code that constructs every dependency internally becomes awkward or impossible to unit test. Logging, configuration, HTTP, time, randomness and external clients are all useful seams where abstractions improve testability.
A better design:
public interface ILoanRepository
{
Task<LoanApplication?> GetByIdAsync(Guid id);
Task SaveChangesAsync();
}
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
public interface IClock
{
DateTime UtcNow { get; }
}
public class LoanApprovalService
{
private readonly ILoanRepository _loanRepository;
private readonly IEmailSender _emailSender;
private readonly IClock _clock;
public LoanApprovalService(
ILoanRepository loanRepository,
IEmailSender emailSender,
IClock clock)
{
_loanRepository = loanRepository;
_emailSender = emailSender;
_clock = clock;
}
public async Task ApproveAsync(Guid loanId)
{
var loan = await _loanRepository.GetByIdAsync(loanId);
if (loan is null)
{
throw new InvalidOperationException("Loan not found.");
}
loan.Approve(_clock.UtcNow);
await _loanRepository.SaveChangesAsync();
await _emailSender.SendAsync(
loan.ApplicantEmail,
"Loan approved",
"Your loan application has been approved.");
}
}
Now the service depends on abstractions.
That means during production, you can inject real implementations.
During unit tests, you can inject test doubles.
My rule of thumb:
If a class creates its own dependencies, it controls its world. If dependencies are injected, tests can control the world.
6. Seams: where behaviour can be replaced
A seam is a place where you can change behaviour without editing the production code.
Logging is a simple example: production uses a real logger, while a unit test can inject NullLogger. The dependency becomes a seam rather than an obstacle.
Example:
public class AuditService
{
private readonly ILogger<AuditService> _logger;
public AuditService(ILogger<AuditService> logger)
{
_logger = logger;
}
public void Record(string message)
{
_logger.LogInformation("Audit: {Message}", message);
}
}
In production, ASP.NET Core injects a real logger.
In a unit test:
var logger = NullLogger<AuditService>.Instance;
var service = new AuditService(logger);
No file. No Application Insights. No console dependency. No noise.
A more business-focused seam:
public interface ICreditScoreProvider
{
Task<int> GetScoreAsync(string applicantName);
}
Production implementation:
public class ExperianCreditScoreProvider : ICreditScoreProvider
{
public async Task<int> GetScoreAsync(string applicantName)
{
// Real HTTP call to external credit agency
throw new NotImplementedException();
}
}
Test double:
public class StubCreditScoreProvider : ICreditScoreProvider
{
private readonly int _score;
public StubCreditScoreProvider(int score)
{
_score = score;
}
public Task<int> GetScoreAsync(string applicantName)
{
return Task.FromResult(_score);
}
}
Now the loan decision service can be tested without calling a real credit agency.
My rule of thumb:
Every external dependency should have a seam: time, randomness, file system, database, HTTP, email, queues, logging, configuration and third-party APIs.
7. Test doubles: dummy, stub, fake, mock and spy
This is where many developers become confused.
A test double is a replacement object used during testing.
Test doubles help distinguish isolated unit tests from tests that use real infrastructure. Mixed approaches can be useful, but I name them honestly so the team understands the scope and risk each test covers.
Let’s simplify.
A dummy is passed only because the method requires it, but the test does not use it.
var logger = NullLogger<LoanApprovalService>.Instance;
A stub returns controlled data.
public class StubCreditScoreProvider : ICreditScoreProvider
{
public Task<int> GetScoreAsync(string applicantName)
{
return Task.FromResult(750);
}
}
A fake has a lightweight working implementation, often in memory.
public class FakeLoanRepository : ILoanRepository
{
private readonly Dictionary<Guid, LoanApplication> _loans = new();
public void Add(LoanApplication loan)
{
_loans[loan.Id] = loan;
}
public Task<LoanApplication?> GetByIdAsync(Guid id)
{
_loans.TryGetValue(id, out var loan);
return Task.FromResult(loan);
}
public Task SaveChangesAsync()
{
return Task.CompletedTask;
}
}
A mock verifies interaction.
Using NSubstitute-style syntax:
[Fact]
public async Task ApproveAsync_ValidLoan_SendsApprovalEmail()
{
// Arrange
var loan = new LoanApplication(
id: Guid.NewGuid(),
applicantName: "Sarah Khan",
applicantEmail: "sarah@example.com",
requestedAmount: 250_000);
loan.Submit();
var repository = Substitute.For<ILoanRepository>();
var emailSender = Substitute.For<IEmailSender>();
var clock = Substitute.For<IClock>();
repository.GetByIdAsync(loan.Id).Returns(loan);
clock.UtcNow.Returns(new DateTime(2026, 07, 27));
var service = new LoanApprovalService(
repository,
emailSender,
clock);
// Act
await service.ApproveAsync(loan.Id);
// Assert
await emailSender.Received(1).SendAsync(
loan.ApplicantEmail,
"Loan approved",
"Your loan application has been approved.");
}
A spy records what happened so you can assert later.
public class SpyEmailSender : IEmailSender
{
public List<string> SentEmails { get; } = new();
public Task SendAsync(string to, string subject, string body)
{
SentEmails.Add(to);
return Task.CompletedTask;
}
}
Test:
[Fact]
public async Task ApproveAsync_ValidLoan_RecordsEmailInSpy()
{
// Arrange
var spyEmailSender = new SpyEmailSender();
// other setup omitted for brevity
// Act
await service.ApproveAsync(loan.Id);
// Assert
Assert.Contains("sarah@example.com", spyEmailSender.SentEmails);
}
My rule of thumb:
Use stubs to control input. Use mocks/spies to verify important interactions. Do not mock everything just because you can.
8. What should you unit test?
This question matters.
Do not unit test private methods directly.
Private methods are implementation detail.
Test behaviour through the public method.
Bad mindset:
“I want to test this private helper.”
Better mindset:
“What public behaviour depends on this helper?”
Example:
public class LoanApplication
{
public LoanRiskCategory CalculateRisk()
{
decimal ratio = CalculateLoanToIncomeRatio();
if (ratio > 5)
{
return LoanRiskCategory.High;
}
return LoanRiskCategory.Normal;
}
private decimal CalculateLoanToIncomeRatio()
{
// internal helper
}
}
Test:
[Fact]
public void CalculateRisk_HighLoanToIncomeRatio_ReturnsHighRisk()
{
// Arrange
var application = new LoanApplication(
requestedAmount: 500_000,
annualIncome: 80_000);
// Act
var risk = application.CalculateRisk();
// Assert
Assert.Equal(LoanRiskCategory.High, risk);
}
You do not care whether the private method exists tomorrow. You care that high loan-to-income applications are treated as high risk.
My rule of thumb:
Unit tests should protect behaviour, not implementation furniture.
9. FIRSTHAND guidelines: a practical TDD checklist
FIRSTHAND provides a memorable TDD checklist: First, Intention, Readability, Single-Behaviour, Thoroughness, High-Performance, Automation, No Interdependency and Deterministic.
Let’s translate those into practical engineering terms.
First means write tests early, ideally before production code. “Later” usually means “never.” Testing first also forces dependency injection readiness and avoids speculative code.
Intention means the test name and structure should explain what behaviour is expected.
ApproveAsync_LoanDoesNotExist_ThrowsInvalidOperationException()
That name has intention.
Readability means the test should be easier to understand than the production code. If your tests are unreadable, they become another maintenance problem.
Use builders when setup becomes noisy:
public class LoanApplicationBuilder
{
private string _applicantName = "Sarah Khan";
private string _email = "sarah@example.com";
private decimal _amount = 250_000;
public LoanApplicationBuilder WithAmount(decimal amount)
{
_amount = amount;
return this;
}
public LoanApplication Build()
{
return new LoanApplication(
Guid.NewGuid(),
_applicantName,
_email,
_amount);
}
}
Usage:
var application = new LoanApplicationBuilder()
.WithAmount(0)
.Build();
Much cleaner.
Single-Behavior means one behaviour per test.
Thoroughness means cover happy paths, edge cases, exceptions, boundaries, nulls, invalid state and important business rules.
High-Performance means unit tests should be fast. If tests take too long, developers stop running them.
Automation means tests must run in CI from day one.
No Interdependency means tests should not rely on order or shared state.
Bad:
private static int _counter;
[Fact]
public void Test1()
{
_counter++;
Assert.Equal(1, _counter);
}
[Fact]
public void Test2()
{
_counter++;
Assert.Equal(2, _counter);
}
This is fragile. Test order is not a business requirement.
Deterministic means the test should give the same result every time. If time, randomness, environment, network, or database state changes the result, isolate it.
My rule of thumb:
A good unit test is fast, isolated, readable, repeatable, intentional and behaviour-focused.
10. DDD and TDD: why they work well together
TDD works naturally with Domain-Driven Design because real systems contain business language, rules, entities, value objects, aggregates, services and repositories—not merely technical workflows.
This is where TDD becomes powerful.
Take an appointment booking system.
Business rules might be:
An appointment must have a valid time slot. The end time must be after the start time. A doctor cannot be double-booked. A patient cannot book two appointments at the same time. Cancelled appointments cannot be confirmed.
A value object:
public sealed record TimeSlot
{
public DateTime Start { get; }
public DateTime End { get; }
public TimeSlot(DateTime start, DateTime end)
{
if (end <= start)
{
throw new ArgumentException(
"End time must be after start time.");
}
Start = start;
End = end;
}
public bool Overlaps(TimeSlot other)
{
return Start < other.End && other.Start < End;
}
}
Tests:
public class TimeSlotTests
{
[Fact]
public void Constructor_EndBeforeStart_ThrowsArgumentException()
{
// Arrange
var start = new DateTime(2026, 07, 27, 10, 0, 0);
var end = new DateTime(2026, 07, 27, 9, 0, 0);
// Act
Action act = () => new TimeSlot(start, end);
// Assert
Assert.Throws<ArgumentException>(act);
}
[Fact]
public void Overlaps_OverlappingSlots_ReturnsTrue()
{
// Arrange
var slot1 = new TimeSlot(
new DateTime(2026, 07, 27, 10, 0, 0),
new DateTime(2026, 07, 27, 11, 0, 0));
var slot2 = new TimeSlot(
new DateTime(2026, 07, 27, 10, 30, 0),
new DateTime(2026, 07, 27, 11, 30, 0));
// Act
bool overlaps = slot1.Overlaps(slot2);
// Assert
Assert.True(overlaps);
}
}
This is beautiful because the tests are not testing EF Core. They are not testing controllers. They are testing business truth.
That is what domain testing should feel like.
My rule of thumb:
The best unit tests usually live close to business rules, not close to framework plumbing.
11. EF Core, repositories and testing boundaries
Relational databases, document databases and repository abstractions each create different testing boundaries. The important point is to choose the test type that matches the risk.
Here is the important distinction.
You do not need to mock EF Core for every test.
If you are testing pure domain rules, avoid EF entirely.
If you are testing application service orchestration, mock or fake the repository.
If you are testing EF mappings, queries, migrations, database constraints or transaction behaviour, that is no longer a pure unit test. That is integration or near-integration testing.
Example application service:
public class AppointmentBookingService
{
private readonly IAppointmentRepository _appointments;
public AppointmentBookingService(
IAppointmentRepository appointments)
{
_appointments = appointments;
}
public async Task BookAsync(Appointment appointment)
{
bool hasClash = await _appointments.ExistsOverlappingAsync(
appointment.DoctorId,
appointment.TimeSlot);
if (hasClash)
{
throw new InvalidOperationException(
"Doctor already has an appointment in this time slot.");
}
await _appointments.AddAsync(appointment);
await _appointments.SaveChangesAsync();
}
}
Test:
[Fact]
public async Task BookAsync_DoctorAlreadyBooked_ThrowsInvalidOperationException()
{
// Arrange
var repository = Substitute.For<IAppointmentRepository>();
var appointment = new Appointment(
doctorId: Guid.NewGuid(),
patientId: Guid.NewGuid(),
timeSlot: new TimeSlot(
DateTime.Today.AddHours(10),
DateTime.Today.AddHours(11)));
repository
.ExistsOverlappingAsync(
appointment.DoctorId,
appointment.TimeSlot)
.Returns(true);
var service = new AppointmentBookingService(repository);
// Act
Func<Task> act = () => service.BookAsync(appointment);
// Assert
await Assert.ThrowsAsync<InvalidOperationException>(act);
}
This test does not need SQL Server. It tests the booking rule.
But if you want to verify that your EF query correctly detects overlapping appointments, write an integration test against a real or test database.
My rule of thumb:
Do not pretend every test is a unit test. Use the right test category for the risk you are covering.
12. CI: tests must run outside your machine
A test suite that only runs on one developer’s laptop is not enough.
Continuous integration turns tests into part of the automated delivery process rather than a local developer habit.
A simple GitHub Actions workflow for .NET:
name: build-and-test
on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
- develop
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore packages
run: dotnet restore
- name: Build solution
run: dotnet build --configuration Release --no-restore
- name: Run tests
run: dotnet test --configuration Release --no-build
Now every pull request proves the solution builds and tests pass.
This changes team behaviour.
Developers become more careful. Code review becomes easier. Regression bugs are caught earlier. Broken builds become visible. Refactoring becomes safer.
My rule of thumb:
TDD without CI is local confidence. TDD with CI becomes team confidence.
13. Brownfield projects: do not be naïve
Greenfield TDD is easier. Brownfield TDD is where maturity shows.
A brownfield project is an existing system, often with legacy code, tight coupling, static methods, hidden dependencies, large classes, direct database calls and weak test coverage.
Brownfield systems bring challenges such as missing dependency injection, static members, hidden object creation, high refactoring cost and weak existing coverage.
Do not walk into a legacy system and say:
“We must rewrite everything with TDD.”
That is how you lose trust.
A better approach:
- Identify high-risk areas.
- Add characterization tests where possible.
- Create seams around external dependencies.
- Refactor small areas.
- Add tests for new changes.
- Avoid big-bang rewrites unless the business case is clear.
public class PaymentService
{
public bool TakePayment(decimal amount)
{
var gateway = new PaymentGateway();
var reference = Guid.NewGuid().ToString();
var now = DateTime.UtcNow;
return gateway.Charge(amount, reference, now);
}
}
Step one: introduce wrappers and abstractions.
public interface IPaymentGateway
{
bool Charge(decimal amount, string reference, DateTime timestamp);
}
public interface IReferenceGenerator
{
string NewReference();
}
public interface IClock
{
DateTime UtcNow { get; }
}
Refactored:
public class PaymentService
{
private readonly IPaymentGateway _gateway;
private readonly IReferenceGenerator _referenceGenerator;
private readonly IClock _clock;
public PaymentService(
IPaymentGateway gateway,
IReferenceGenerator referenceGenerator,
IClock clock)
{
_gateway = gateway;
_referenceGenerator = referenceGenerator;
_clock = clock;
}
public bool TakePayment(decimal amount)
{
string reference = _referenceGenerator.NewReference();
DateTime now = _clock.UtcNow;
return _gateway.Charge(amount, reference, now);
}
}
Now testable.
My rule of thumb:
In brownfield systems, TDD is introduced through seams, not speeches.
14. Rolling TDD into a team
Introducing TDD across a team involves technical challenges, experience, willingness, timing, business benefits, costs and misconceptions.
This is important because technical truth alone does not change a team.
Some developers dislike TDD because it feels slow.
Some managers dislike it because they see more code but not immediate visible features.
Some teams say they are doing unit testing, but they are actually doing integration testing.
Some teams write tests that are brittle, slow and unreadable, then blame TDD.
I believe the business value must be explained clearly.
TDD helps because:
It catches bugs earlier. It documents behaviour. It supports refactoring. It reduces regression risk. It encourages better design. It improves onboarding. It gives confidence during change.
But be honest.
TDD also has costs.
It requires discipline. It requires skill. It can slow initial development. Bad tests can become technical debt. Not all code needs the same level of unit coverage.
The mature position is not:
“Everything must be TDD.”
The mature position is:
“Critical business logic, complex rules, volatile code and high-risk behaviours should be protected by strong automated tests. TDD is one of the best ways to design and protect those areas.”
My rule of thumb:
Do not sell TDD as religion. Sell it as risk management, design discipline and change confidence.
15. Mentoring build: one loan approval rule, one vertical slice
Let us practise TDD on a bounded requirement:
A submitted loan can be approved by an authorised underwriter when:
requested amount is positive;
verified annual income is positive;
loan-to-income ratio does not exceed the product limit;
all mandatory evidence is verified;
the command is based on the current application version.
Otherwise the application is referred for manual review or the command conflicts.
The product owner must define whether each condition means decline, referral or invalid input. Tests cannot rescue an ambiguous requirement.
Junior: Should I create the controller, repository interface and mocks first?>
Senior: Start at the rule that has the most uncertainty and can be expressed without infrastructure. Let the test pull the smallest useful design into existence.First test:
public sealed class LoanApplicationTests
{
[Fact]
public void Approve_when_application_satisfies_policy_changes_status()
{
var application = LoanApplicationFixture.Submitted(
requestedAmount: 200_000m,
verifiedAnnualIncome: 50_000m,
mandatoryEvidenceVerified: true,
version: 7);
application.Approve(
ProductPolicy.WithMaximumLoanToIncome(4.5m),
UnderwriterId.Parse("underwriter-42"),
approvedAt: new DateTimeOffset(2026, 7, 30, 10, 0, 0, TimeSpan.Zero));
application.Status.Should().Be(LoanStatus.Approved);
application.ApprovedBy.Should().Be(UnderwriterId.Parse("underwriter-42"));
application.Version.Should().Be(8);
}
}
This test supplies time rather than using the system clock inside the aggregate. It observes state meaningful to the domain. It does not verify that a private method ran.
The smallest implementation should be honest, not intentionally wrong:
public void Approve(
ProductPolicy policy,
UnderwriterId approvedBy,
DateTimeOffset approvedAt)
{
if (Status is not LoanStatus.Submitted)
throw new InvalidLoanTransitionException(Status, LoanStatus.Approved);
var decision = policy.Evaluate(this);
if (!decision.CanApprove)
throw new LoanRequiresReviewException(decision.Reasons);
Status = LoanStatus.Approved;
ApprovedBy = approvedBy;
ApprovedAt = approvedAt;
Version++;
}
“Simplest” does not mean hard-code Approved and ignore the requirement. It means avoid speculative factories, buses and rule engines until a test reveals the variation.
16. Grow the rule with a decision table
Examples reveal boundary semantics better than one happy path:
| Requested | Income | Evidence | Maximum ratio | Expected |
|---|---|---|---|---|
| 200,000 | 50,000 | verified | 4.5 | approve |
| 225,000 | 50,000 | verified | 4.5 | approve at boundary |
| 225,001 | 50,000 | verified | 4.5 | manual review |
| 200,000 | 0 | verified | 4.5 | invalid/referral by policy |
| 200,000 | 50,000 | missing | 4.5 | manual review |
[Theory] and MemberData when inline values become unreadable:
public static TheoryData<decimal, decimal, bool, decimal, bool> ApprovalCases =>
new()
{
{ 200_000m, 50_000m, true, 4.5m, true },
{ 225_000m, 50_000m, true, 4.5m, true },
{ 225_001m, 50_000m, true, 4.5m, false },
{ 200_000m, 50_000m, false, 4.5m, false },
};
[Theory]
[MemberData(nameof(ApprovalCases))]
public void Policy_returns_expected_approval(
decimal amount,
decimal income,
bool evidenceVerified,
decimal limit,
bool expected)
{
var loan = LoanApplicationFixture.Submitted(
amount, income, evidenceVerified);
var result = ProductPolicy.WithMaximumLoanToIncome(limit).Evaluate(loan);
result.CanApprove.Should().Be(expected);
}
The test name plus row values must make a failure diagnosable. Split different behaviours into separate theories when one table accumulates dozens of flags.
Junior: Are these tests duplicating the implementation’s formula?>
Senior: The product examples define expected outcomes. A test that independently calculates the same formula line by line can duplicate an implementation defect. Use approved boundary examples and invariants.For money, define currency, scale and rounding. A test using
double and arbitrary values may hide the real contract. TDD makes missing domain decisions visible; take them back to the product owner.
17. Test outcomes, not private collaboration
A brittle test might mock IRatioCalculator, verify it is called once, verify SetStatus, then assert a repository method. A refactor that inlines a calculation breaks the test while user behaviour remains correct.
Prefer observable behaviour:
- status and domain event change;
- expected failure leaves state unchanged;
- version increments once;
- reason codes match policy;
- public application result and persisted state are consistent.
Tests should not make every class public. InternalsVisibleTo can be justified for specific internal algorithms, but a growing need indicates boundaries worth reviewing.
18. The first application-service test
The aggregate does not load itself, authorise a user or commit a transaction. The use case coordinates those responsibilities:
public sealed record ApproveLoanCommand(
Guid ApplicationId,
long ExpectedVersion,
string IdempotencyKey,
UnderwriterId ActorId,
TenantId TenantId);
public sealed class ApproveLoanUseCase(
ILoanApplicationRepository applications,
IProductPolicyProvider policies,
IAuthorizer authorizer,
IIdempotencyStore idempotency,
IUnitOfWork unitOfWork,
TimeProvider timeProvider)
{
public async Task<ApproveLoanResult> ExecuteAsync(
ApproveLoanCommand command,
CancellationToken cancellationToken)
{
// orchestration developed through the next tests
throw new NotImplementedException();
}
}
Start with an existing loan and approved identity. Use simple fakes for stateful collaborators and a spy only where interaction is the outcome.
[Fact]
public async Task Execute_approves_current_application_and_commits()
{
var loan = LoanApplicationFixture.Submitted(version: 7);
var repository = new InMemoryLoanRepository(loan);
var unitOfWork = new SpyUnitOfWork();
var sut = BuildUseCase(repository: repository, unitOfWork: unitOfWork);
var result = await sut.ExecuteAsync(
CommandFor(loan, expectedVersion: 7), CancellationToken.None);
result.Status.Should().Be(LoanStatus.Approved);
repository.Get(loan.Id).Status.Should().Be(LoanStatus.Approved);
unitOfWork.CommitCount.Should().Be(1);
}
Why verify commit count? The transaction boundary is an essential collaboration. Avoid verifying every repository read argument if the resulting state already proves it, unless tenant/identity filtering is the behaviour under test.
Junior: The setup is becoming long. Should I use AutoFixture for everything?>
Senior: Use builders to make irrelevant defaults quiet while keeping decisive values visible. Random auto-generated domain data can hide why a test passes.
19. Test builders that preserve meaning
A builder should create valid defaults and expose domain choices:
public sealed class LoanApplicationBuilder
{
private decimal requestedAmount = 200_000m;
private decimal annualIncome = 50_000m;
private bool evidenceVerified = true;
private LoanStatus status = LoanStatus.Submitted;
private long version = 7;
public LoanApplicationBuilder WithRequestedAmount(decimal value)
{
requestedAmount = value;
return this;
}
public LoanApplicationBuilder WithMissingEvidence()
{
evidenceVerified = false;
return this;
}
public LoanApplicationBuilder AtVersion(long value)
{
version = value;
return this;
}
public LoanApplication Build() => LoanApplication.RehydrateForTest(
requestedAmount, annualIncome, evidenceVerified, status, version);
}
Do not create impossible state through a public production bypass. A test-only rehydration factory can itself become dangerous. Prefer the same factory and domain transitions production uses, or build persisted fixtures through a dedicated internal test assembly with careful review.
Object-mother methods such as ValidSubmittedLoan() are readable until callers need many variations. Builders or named fixture methods can coexist. Delete fields no test uses.
20. Fakes versus mocks in application tests
An in-memory repository fake stores aggregates and can support several tests. It may diverge from SQL behaviour, so it proves orchestration, not EF Core mapping or concurrency.
A mock is useful for a boundary where the call itself matters:
publisher.Verify(x => x.PublishAsync(
It.Is<LoanApproved>(message => message.LoanId == loan.Id),
cancellationToken),
Times.Once);
But if production uses an outbox, the use case should add an outbox message in the transaction rather than call a publisher. Then integration tests prove the database record, and dispatcher tests prove publication.
Do not mock value objects or pure policies. Instantiate them. Avoid mocking DbSet; use the real provider for query translation. Avoid mocking framework types when a thin adapter can expose application vocabulary.
Junior: Is using a fake database repository dishonest?>
Senior: It is honest when the test claims to prove application orchestration. It is dishonest when we claim it proves transactions, constraints or SQL. Name the test layer and add the missing integration evidence.
21. Outside-in versus inside-out TDD
Inside-out starts with domain objects and grows toward orchestration. It gives quick feedback on rules but can discover the user-facing contract late.
Outside-in begins with an acceptance/API test and mocks the next boundary, then drives inward. It protects the user journey but can produce interaction-heavy mocks and speculative interfaces if followed mechanically.
A pragmatic combination works well:
- Write one pending or failing acceptance example that expresses the vertical outcome.
- Drop to fast domain tests for uncertain rules.
- Add application-service tests for coordination.
- Complete the API/database integration test.
- Refactor across layers while all tests stay meaningful.
22. Test the HTTP contract with WebApplicationFactory
An ASP.NET Core integration test can host the real pipeline, routes, serialisation and dependency injection while substituting approved external boundaries.
public sealed class LoanApiTests : IClassFixture<LoanApiFactory>
{
private readonly HttpClient client;
public LoanApiTests(LoanApiFactory factory)
=> client = factory.CreateClient();
[Fact]
public async Task Approve_returns_conflict_for_stale_version()
{
var loan = await SeedSubmittedLoanAsync(version: 8);
using var request = new HttpRequestMessage(
HttpMethod.Post, $"/api/loans/{loan.Id}/approval")
{
Content = JsonContent.Create(new
{
expectedVersion = 7,
idempotencyKey = "test-operation-stale-0001",
}),
};
request.Headers.Authorization = TestTokens.ForUnderwriter(loan.TenantId);
using var response = await client.SendAsync(request);
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Extensions["code"]!.ToString().Should().Be("loan_changed");
}
}
The factory should replace identity through a test authentication scheme, not disable authorisation. Seed data through a clear fixture and isolate tests. The response assertion checks stable contract, not the full default Problem Details text.
Test 400, 401, 403, 404, 409, success and unexpected failure mapping. Confirm sensitive fields are absent. Generate or compare OpenAPI as a separate compatibility check where the public schema matters.
23. Real database tests and isolation
Use a disposable instance of the production database engine for constraints, transactions, EF translation and concurrency. SQLite in-memory can be useful but does not behave exactly like SQL Server or PostgreSQL.
Database-test setup should:
- apply real migrations;
- create a unique database/schema or reliably reset state;
- seed only required data;
- run tests in parallel only when isolation supports it;
- capture SQL/diagnostics on failure;
- clean resources after the run.
EnsureVersion check.
[Fact]
public async Task Concurrent_approvals_cannot_overwrite_each_other()
{
await using var first = CreateDbContext();
await using var second = CreateDbContext();
var loanA = await first.Loans.SingleAsync(x => x.Id == loanId);
var loanB = await second.Loans.SingleAsync(x => x.Id == loanId);
loanA.Approve(policy, underwriterA, now);
await first.SaveChangesAsync();
loanB.Approve(policy, underwriterB, now);
var act = () => second.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateConcurrencyException>();
}
If the mapping does not configure a concurrency token, the test exposes it. Do not replace this with mocked exception throwing and claim persistence safety.
24. Idempotency tests need real races
Sequential duplicate tests are insufficient. Two requests can both check “key absent” before either commits. The unique database constraint must choose a winner.
Coordinate two calls with a barrier or controlled hook so they overlap, then assert:
- one domain transition;
- one outbox event;
- same stored response for same fingerprint;
- conflict for same key/different request;
- safe result after response loss and retry.
TaskCompletionSource with RunContinuationsAsynchronously and a timeout so the test cannot hang forever.
Junior: This test is slower and harder than the unit test. Should it run only nightly?>
Senior: The race protects a costly correctness guarantee and should run in pull-request CI if we can keep infrastructure reliable. Optimise setup; do not remove the only convincing evidence.Tag truly expensive end-to-end or load suites separately, but keep critical database integration feedback frequent.
25. Contract testing external providers
Our service may call a credit provider. Unit tests can fake it; adapter contract tests verify request and response shapes against a controlled HTTP server or provider sandbox.
Test:
- authentication and required headers;
- idempotency key reuse;
- JSON field names, units and enum mapping;
- success, not-found, conflict, throttling and server error;
- malformed, empty and unknown response values;
- timeout after possible acceptance;
- maximum body size and safe diagnostics.
Do not record real personal data in HTTP snapshots. Redact fixtures and review recorded headers. A golden snapshot that includes a rotating timestamp or token becomes noisy and dangerous.
26. Approval tests should include authorisation, not merely authentication
An authenticated user from another tenant must not approve the loan. A user in the same tenant may still lack the underwriter role or product authority.
Use a matrix:
| Identity | Resource | Expected |
|---|---|---|
| valid underwriter, same tenant/product | existing loan | allowed |
| valid underwriter, other tenant | existing loan | denied/not found policy |
| authenticated broker | existing loan | forbidden |
| expired token | any | unauthenticated |
| privileged support without approval permission | existing loan | forbidden |
Security regression tests should be hard gates. Do not average one isolation failure into a percentage coverage target.
27. Property-based testing for invariants
Example tests cover known boundaries. Property-based tests generate many values and verify an invariant.
For a fixed income and policy, increasing requested amount should not improve the decision—if that monotonicity is genuinely part of the rule. For valid money, creating then serialising/deserialising a public value should preserve its amount and currency.
Property tests can shrink a failure to a small counterexample. They are not random testing without thought: the property and generator encode domain knowledge. Exclude invalid values only when another boundary owns them; otherwise generate them and assert rejection.
Do not test framework arithmetic properties or repeat the implementation. Use property-based testing where the input space is wide and a stable invariant exists.
28. Mutation testing asks whether assertions can detect defects
Line coverage says a test executed a branch. Mutation testing changes operators or removes statements and sees whether tests fail. If changing > to >= survives, the boundary suite may be weak.
Run mutation testing on focused domain projects, not necessarily the entire solution on every commit. It can be computationally expensive. Use surviving mutants as review prompts, not a score to game with meaningless assertions.
A killed mutant does not prove the business rule is correct; it proves the suite distinguishes that change. Product examples and review still matter.
29. Snapshot tests: useful at stable rendering boundaries
Snapshots can protect generated OpenAPI, serialised integration events or complex UI output. They are risky when developers approve huge changes without review.
Keep snapshots small, deterministic and free of secrets/timestamps/unstable ordering. Add semantic assertions for critical fields. A changed snapshot should trigger the question “Is this contract change intended?” not an automatic update command.
For domain decisions, explicit assertions usually communicate better than serialising the entire aggregate.
30. Testing time, randomness and identifiers
.NET’s TimeProvider provides a standard time abstraction. Inject it into application orchestration or pass timestamps into domain methods. Tests can use a controllable provider.
Identifiers and randomness may also need a seam when their values affect observable behaviour. Do not create IGuidGenerator reflexively if the test only needs a non-empty ID. Capture the returned ID and assert relationships. Introduce deterministic generation when idempotency, ordering or snapshots require it.
Never seed production cryptographic randomness with a predictable test seed. Wrap the capability and substitute it in the test environment.
31. Asynchronous test correctness
Test methods returning Task should await the operation. Never use async void tests. Avoid .Result and .Wait(), which can block or wrap exceptions.
[Fact]
public async Task Cancellation_stops_before_provider_call()
{
using var source = new CancellationTokenSource();
source.Cancel();
var act = () => useCase.ExecuteAsync(command, source.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
provider.CallCount.Should().Be(0);
}
The production contract may check cancellation before work; if not, adjust the expected boundary. Test cancellation during I/O with a controllable fake. Avoid “wait 100 ms and hope it reached the line.”
When testing concurrent failures, observe every started task. Apply a test timeout and print diagnostic state on failure. A hanging CI worker is not useful feedback.
32. Resilience tests and ambiguous failures
Suppose a provider stores a credit search then drops the response. The client receives a timeout. A correct test server can implement that sequence. Retry with the same operation key and assert the provider returns the existing search.
Test transient retry count and total deadline, but do not assert exact millisecond delays in ordinary wall-clock tests. Inject a resilience time provider or use library-supported virtual time where practical.
Ensure validation, forbidden and conflict responses are not retried. Confirm a circuit or overload rejection produces the documented response and metrics. Resilience is part of behaviour, not only configuration.
33. What not to TDD
Do not test-drive trivial property assignments, framework internals or generated code solely to increase count. Verify generated contracts and your custom seams. Do not unit-test that ASP.NET Core routing works generally; test that your route, policy and contract are wired.
Exploratory spikes can precede TDD when the API or algorithm is unknown. Time-box the spike, learn, discard or clean it, then express settled behaviour with tests. Pretending exploration is production TDD creates brittle guesses.
Visual design, performance and usability need their own feedback tools. Automated accessibility and screenshot checks help but do not replace human review. Load tests prove capacity; unit tests do not.
Junior: Does code without a test always mean poor quality?>
Senior: No. Risk and feedback determine the right evidence. Critical business rules deserve strong automated tests; a throwaway migration probe may need review and dry-run evidence instead. Be explicit.
34. Test smells and how to refactor them
Obscure test: setup hides decisive values. Make the boundary visible.
Mystery guest: test depends on a shared file, clock or database row. Own the fixture and lifecycle.
Fragile interaction: test verifies every internal call. Assert outcome and only essential boundary interactions.
Conditional test logic: loops and branches make the test another program. Use theory rows or separate cases.
Eager test: one method verifies six behaviours. Split so failure points to one rule.
Slow unit suite: hidden I/O or excessive host startup. Move tests to the correct layer and share only safe expensive infrastructure.
Flaky concurrency test: sleep and scheduling luck. Coordinate the schedule explicitly.
Overspecified exception text: punctuation change breaks tests. Assert stable exception type, code and meaningful fragments where text is not the contract.
Refactor tests with production code. Duplication is acceptable when it keeps behaviour obvious; extract only shared knowledge. A test helper with ten boolean parameters is worse than repeated setup.
35. Coverage as a map, not a target
Coverage can identify unexecuted paths. It cannot tell whether assertions are meaningful or requirements correct. A 100% covered method can return the wrong value if tests assert nothing useful.
Use coverage to ask questions:
- Why is this error branch never exercised?
- Is this code dead?
- Does critical policy have boundary tests?
- Are infrastructure failures represented?
36. CI pipeline as layered feedback
A useful pipeline gives fast feedback first:
restore/build/analyzers
-> fast domain and application tests
-> database/API integration tests
-> contract and security tests
-> package/image build
-> smoke test built artefact
-> selected end-to-end/performance gates
Run the same commands locally and in CI. Pin SDK and dependencies. Do not rely on developer machine state. Publish test results and useful failure diagnostics, but avoid uploading sensitive fixtures.
Quarantine is not a permanent home for flaky tests. Assign an owner and deadline, investigate the cause and restore or delete. A suite everyone reruns until green provides false confidence.
Parallelise only isolated tests. Shared database, ports, static state and environment variables can race. Fix isolation before turning off parallel execution globally; serialising everything hides architecture problems and slows feedback.
37. Production testing and observability
Pre-release tests cannot model every production combination. Safe production evidence includes health checks, synthetic canaries, contract probes, feature flags, canary rollout, dashboards and alerts.
A synthetic approval must use an isolated test tenant and never create a real financial outcome. Monitoring should observe request/result categories, idempotency conflicts, concurrency conflicts, outbox lag and dependency health without logging personal data.
Production defects become regression tests at the lowest convincing layer. If an EF translation caused the incident, add a real database test, not only a mocked repository test. If a race caused it, reproduce the schedule. If requirements were misunderstood, update examples and acceptance criteria before code.
38. Debugging clinic: the test passes alone and fails in the suite
Suspect shared state: static caches, environment variables, current culture, working directory, database records, ports, clock or singleton fakes. Run with random order or parallelism and record the failing neighbours.
One test may change CultureInfo.CurrentCulture and not restore it. Use a scoped helper with try/finally, or avoid global mutation. A fixture may reuse a mutable builder result. Create fresh objects per test.
Junior: Can we disable parallel tests to fix it?>
Senior: That may stabilise CI temporarily, but the shared state remains and can affect production. Identify ownership. Disable only where a genuinely exclusive resource requires it.Ensure every disposable server, database and cancellation source is cleaned up. Await background tasks. A previous test’s fire-and-forget work can mutate the next test.
39. Debugging clinic: mocks pass, production transaction fails
The use-case unit test verifies repository.Save then publisher.Publish. In production, database commit succeeds and broker publication fails. The mock test encoded an unsafe architecture as success.
Replace direct publication with an outbox record committed alongside the aggregate. Integration-test atomicity by forcing commit failure and verifying neither record persists, then successful commit and pending outbox. Dispatcher tests cover retry and duplicate publication.
This is a critical TDD lesson: tests drive the design toward whatever they assert. If the test boundary ignores distributed failure, green tests can reinforce a flawed design. Review architectural assumptions, not only colour.
40. Brownfield characterisation in practice
Suppose LegacyLoanCalculator.Calculate has no specification and dozens of callers. Before refactoring, collect representative inputs from approved non-sensitive fixtures and record current outputs as characterisation tests.
Characterisation means “this is what it does,” not “this is correct.” Label surprising outputs. Ask domain owners which are defects. For intended fixes, add a new failing requirement test and change behaviour explicitly.
Create a seam at the boundary, not an interface for every private method. For static time or HTTP, wrap the smallest external capability. Use branch-by-abstraction when replacing a provider: run old and new implementations against the same fixtures, compare, then switch gradually.
Avoid snapshotting enormous database outputs. Select business-relevant columns and assert invariants so the safety net remains reviewable.
41. Team adoption through one working agreement
Agree on:
- test-layer names and folder conventions;
- which suite runs in the inner loop and pull request;
- fixture and database-isolation strategy;
- mock/fake conventions;
- time, randomness and external-call seams;
- critical behaviours that require integration evidence;
- flakiness ownership;
- coverage/mutation use;
- review expectations for production and test code.
Managers need the risk story: tests reduce the cost of change and release, but they also require maintenance. Delete tests whose behaviour no longer matters. A smaller trusted suite is better than a large ignored one.
42. Code-review checklist for tests
- Does the test name state behaviour and condition?
- Are decisive inputs visible?
- Does it assert a public outcome or essential collaboration?
- Is the chosen layer capable of proving the claim?
- Can it pass for the wrong reason?
- Is time, randomness, I/O and shared state controlled?
- Does failure produce useful diagnostics?
- Are security and tenant boundaries hard gates?
- Does concurrency testing force the dangerous schedule?
- Is the fixture valid and free from sensitive production data?
- Can implementation be refactored without rewriting unrelated tests?
- Is there duplicated setup worth extracting—or would extraction hide meaning?
43. Exercises for the developer I am mentoring
Exercise one: five red-green-refactor cycles
Add approval rules one at a time: valid boundary, over limit, missing evidence, wrong state and invalid income. Commit after each green/refactor step. Review whether each test caused a useful design decision.
Exercise two: replace a brittle mock
Find a test verifying more than five internal calls. Rewrite it around observable state/result and retain only essential interactions. Refactor production structure and compare which version survives.
Exercise three: prove database concurrency
Use two real contexts to update one versioned aggregate. Confirm one save conflicts. Map the conflict through the API to a stable 409 contract.
Exercise four: race an idempotency key
Synchronise two create requests before commit. Prove the unique constraint, stored fingerprint and response behaviour. Do not use Thread.Sleep.
Exercise five: inject an ambiguous timeout
Make a fake provider accept a write then drop the response. Verify retry uses the same key and does not duplicate. Add it to CI.
Exercise six: run mutation testing
Run mutation analysis on one domain project. Review surviving boundary mutants. Add only tests that express missing behaviour, not assertions solely to improve a score.
44. Cross-links for continuing the mentoring path
Use Clean C# Design Patterns and Defensive Code for the domain and integration boundaries these tests protect. EF Core Best Practices develops real database evidence. C# Async/Await, Race Conditions and Locks strengthens deterministic concurrency tests. HTTP and Web APIs from First Principles expands contract and idempotency semantics, while Microservices with .NET develops outbox, duplicate delivery and contract testing across services.
Together they reinforce one principle: choose the smallest test that can convincingly prove the risk, then add a wider layer only for behaviour the smaller test cannot see.
45. Turn acceptance criteria into executable examples
“Approval should work” is not testable. A good refinement conversation asks for examples and edge conditions:
Scenario: Approve a current eligible application
Given loan LN-1042 is Submitted at version 7
And its verified loan-to-income ratio is 4.5
And every mandatory document is verified
And Alice is authorised for its tenant and product
When Alice approves version 7 with operation key OP-81
Then the application is Approved at version 8
And one LoanApproved event is pending for delivery
And repeating OP-81 returns the same result
Gherkin is optional. The value is shared precision. Add negative examples:
Scenario: Stale approval is rejected
Given Alice loaded version 7
And Bob has already changed the application to version 8
When Alice approves version 7
Then the response is a conflict
And no approval event is added
And the response identifies how to refresh safely
Do not automate the prose through a giant step-definition framework unless it improves collaboration. Step layers can become another programming language that hides fixture setup and duplicates unit tests. Sometimes a clearly named API integration test is the best executable specification.
Junior: Should every acceptance criterion have one end-to-end browser test?>
Senior: No. Every criterion needs convincing evidence. A domain boundary may be best proved by a fast unit theory, with one browser journey proving wiring. Duplicate every example at every layer and the suite becomes expensive without adding confidence.Maintain traceability lightly: test names, requirement IDs for regulated evidence where necessary, or a living behaviour catalogue. Do not couple all tests to ticket numbers that lose meaning after the tracker is archived.
46. The test pyramid is an economic model
The familiar pyramid suggests many fast unit tests, fewer integration tests and a small number of end-to-end tests. Its purpose is feedback economics, not a mandated shape.
For a calculation library, the base may be overwhelmingly unit tests. For an EF Core query service, integration tests may provide more value than mocked units. For a thin frontend over stable APIs, component and contract tests may dominate.
Ask four questions for each behaviour:
- What defect are we trying to detect?
- What is the narrowest layer that can detect it honestly?
- How fast and deterministic is that evidence?
- What wider integration remains unproven?
Build a portfolio. Avoid both extremes: thousands of microscopic interaction tests with no real infrastructure, or one slow browser suite expected to diagnose everything.
47. Refactoring under the protection of tests
Red-green-refactor means refactor after behaviour passes, not postpone design indefinitely. Refactoring can improve both production and test code while the observable contract remains stable.
Suppose policy logic grew in the aggregate. Tests reveal product limits vary independently. Extract ProductPolicy and move the existing examples. Do not change API, persistence and policy in one step if smaller transformations are possible.
Use this rhythm:
green behaviour tests
-> make one structural change
-> run focused suite
-> run wider suite
-> inspect diff
-> commit
If tests break during a pure refactor, decide whether they were coupled to implementation or exposed accidental public behaviour. Do not automatically rewrite tests to match new code; first verify product behaviour remained the same.
Junior: I need to change twenty mocks after renaming a private collaborator. Is that normal?>
Senior: It is evidence that tests know too much about structure. Move assertions toward outcomes and reduce intermediary mocks before continuing the refactor.Characterisation tests may intentionally protect awkward behaviour until a product decision changes it. Label that constraint so a future developer does not “clean up” an apparently strange assertion without context.
48. Verify domain events without coupling to storage shape
The aggregate raises LoanApproved; the application persists it to an outbox. Domain tests can assert the semantic event:
var approved = application.DomainEvents
.Should().ContainSingle(x => x is LoanApproved)
.Subject.Should().BeOfType<LoanApproved>().Subject;
approved.LoanId.Should().Be(application.Id);
approved.Version.Should().Be(application.Version);
Avoid asserting list index or private collection implementation. Clear events according to the production unit-of-work contract.
An integration test should inspect the resulting outbox envelope: message ID, type, aggregate/version, schema version and payload. It should not compare a raw JSON string whose property ordering is irrelevant. Deserialize to the public contract and assert meaning.
Dispatcher tests deliver the same message twice to a consumer and prove one side effect. The layers form an evidence chain:
aggregate raises correct fact
-> transaction stores correct envelope atomically
-> dispatcher publishes/retries
-> consumer deduplicates and applies once
No single unit test proves the whole distributed guarantee.
49. Test data privacy and lifecycle
Never copy a production database into a developer test environment casually. Synthetic fixtures should represent shapes and edge cases without real names, addresses, account details or free text. If masked production-derived data is approved, verify re-identification risk, access and retention.
Logs, screenshots, snapshots and CI artefacts can leak fixtures. Use obviously synthetic identifiers and scan outputs. Delete disposable environments and set retention on test reports.
Security tests sometimes need secrets or certificates. Generate short-lived test credentials, scope them to test resources and keep them in the CI secret mechanism. Do not embed a “test key” that also works in shared staging.
Test isolation is also tenant isolation. Fixtures for tenant A and B should prove filters and authorisation with distinct identities. Random IDs alone do not replace explicit ownership assertions.
50. Diagnose a flaky timeout test
A test expects cancellation after 100 milliseconds. It passes locally and fails on a loaded CI worker. Wall-clock scheduling, JIT, contention and machine speed make the threshold unreliable.
Replace time passage with a controllable dependency when testing policy. For an actual network timeout integration, use a comfortably bounded assertion and diagnose timing separately. Do not assert completion at exactly 100 ms.
var providerStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
fakeProvider.OnCall(async token =>
{
providerStarted.TrySetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, token);
return default!;
});
using var cancellation = new CancellationTokenSource();
var operation = sut.ExecuteAsync(command, cancellation.Token);
await providerStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
cancellation.Cancel();
await operation.Should().ThrowAsync<OperationCanceledException>();
The five-second WaitAsync is a test safety timeout, not the business timing assertion. If setup never reaches the provider, the test fails instead of hanging.
Record seeds for property tests, freeze culture/time zone where behaviour depends on them and avoid shared ports. Retry a failing test only to gather diagnostics, not to turn red into green.
51. Diagnose an overly broad integration fixture
One fixture boots the complete application, broker, database, search, identity server and five provider containers for every test. The suite takes forty minutes and frequently fails for irrelevant reasons.
Map behaviours to boundaries. A repository query needs database plus mapping, not the broker. An HTTP contract needs ASP.NET Core plus authentication fixture and database, with providers stubbed at HTTP. A message consumer needs broker contract and local database. Keep a few full-system smoke journeys.
Split fixtures by capability. Reuse expensive infrastructure at collection or assembly scope only when data isolation remains reliable. Reset state between tests with transactions, schema cleanup or unique databases according to provider behaviour.
Measure suite duration by test and fixture startup. Parallelise independent groups within resource capacity. The answer is not to replace every real boundary with mocks; it is to use the real boundary each test claims to prove and no unrelated ones.
52. Approval-testing anti-patterns
Mocking the aggregate: hides domain rules. Use the real aggregate.
Asserting SaveChangesAsync only: proves a call, not committed state or constraints.
Happy-path-only authorisation: proves allowed access but not cross-tenant denial.
One giant fixture loan: changes between tests and hides required setup.
Catching all exceptions in the test: can make unexpected failures look expected. Assert the specific type/code.
Testing logs as primary output: logs support operations; business outcome should be observed directly. Assert structured audit events only when they are a contractual requirement.
Reaching into private fields: couples tests to layout. Drive public behaviour.
Calling the production database from a “unit” suite: makes feedback slow and environment-dependent. Label and isolate integration tests.
Using an EF in-memory provider as SQL proof: it lacks relational behaviour. Use the target provider for critical queries.
Deleting failing tests after a refactor: may erase the only evidence of a requirement. Understand the failure first.
53. Definition of done for the approval slice
The slice is ready when:
- product examples and boundary decisions are approved;
- domain tests cover allowed, referral and invalid transitions;
- state remains unchanged after rejected operations;
- application tests prove authorisation, idempotency intent and transaction coordination;
- real database tests prove concurrency, constraints and outbox atomicity;
- API tests prove authentication, resource authorisation and stable responses;
- provider contract tests cover ambiguity, timeout and unknown values;
- duplicate delivery produces one downstream effect;
- cancellation and shutdown paths are exercised;
- fixtures contain no sensitive production data;
- CI builds the same artefact that is smoke-tested and deployed;
- canary signals and rollback are defined;
- every flaky test has an owner rather than a retry mask.
54. A mentoring retrospective after the feature
Senior: Which test changed the design most?>
Junior: The concurrent idempotency test. It showed our repository check could race and forced a unique database constraint.>
Senior: Which test should we remove?>
Junior: The handler mock test repeats the API result and breaks when orchestration is renamed. It adds no separate evidence.>
Senior: What remains uncertain?>
Junior: Provider behaviour after an ambiguous timeout. We need a sandbox contract test and reconciliation runbook.That retrospective is part of TDD maturity. Tests are not a permanent monument. Keep the ones that explain and protect valuable behaviour, improve weak evidence, and remove redundant coupling.
Track whether the suite makes the next change safer. If developers avoid refactoring because tests are brittle, address test design. If incidents occur in untested boundaries, rebalance layers. If CI is slow, profile and isolate rather than abandoning integration evidence.
The red-green-refactor loop continues at system scale: production reveals a gap, a failing regression captures it, the smallest safe change fixes it, and the architecture is improved so the same class of defect becomes harder.
55. The ten-minute TDD teaching exercise
When introducing the discipline to a teammate, choose a tiny rule with one meaningful boundary. Do not begin with a controller and five mocks. For example: an evidence checklist is complete only when every mandatory item is verified; optional missing items do not block it.
Write one failing example for all mandatory evidence verified. Implement the smallest collection rule. Add a missing mandatory item and watch red. Add an optional missing item. Refactor names and remove duplication while green. The entire exercise should show several loops, not one large red phase followed by twenty minutes of implementation.
Then ask the learner what changed:
- the examples clarified “mandatory” versus “optional”;
- the public API emerged from use;
- the tests permitted renaming and extraction;
- no mock was needed because the rule was pure;
- the boundary case prevented an off-by-one or
Any/Allmistake.
Junior: This example is much smaller than production. Does it really teach TDD?>
Senior: It teaches the feedback loop cleanly. Production adds boundaries, and we apply the same habit at the right layer: make a claim fail, add the smallest honest behaviour, then improve the design.Follow the exercise with one integration example so nobody concludes that TDD means mocking reality. Add a unique constraint, first show a unit fake cannot prove it, then write the database test that fails until the mapping exists. This contrast teaches test selection better than definitions alone.
The lasting team habit is to ask before implementation: “What observation would convince us this behaviour works?” Sometimes the answer is a unit test, sometimes an integration test, a contract probe, a load experiment or a security review. TDD is strongest where examples can guide design quickly; professional testing is broader than TDD and supplies the rest of the evidence.
Keep the exercise honest by deliberately making the first test fail for the expected reason. A test that starts green may be observing existing behaviour, using the wrong system or asserting nothing meaningful. Read the failure message before implementing. After green, make a temporary mutation to confirm the assertion detects the defect, then restore the correct code.
That small discipline prevents ceremonial TDD. Red is evidence that the test can detect absence; green is evidence for the current example; refactoring is the opportunity to improve structure without changing the promise.
What I want you to take away
Test-driven development in C# and .NET is not about being fashionable. It is about writing software that can survive change.
A developer who understands TDD knows how to start from a requirement, express the expected behaviour as a failing test, write the smallest production code to pass, then refactor safely. They understand xUnit, [Fact], [Theory], [InlineData], assertions, exception testing, naming conventions, AAA structure and red-green-refactor.
But that is only the entry point.
A serious .NET developer understands that testability is architecture. If code directly creates databases, HTTP clients, clocks, random generators, email senders and external services, it becomes hard to test. Dependency injection creates seams. Interfaces allow behaviour to be replaced. Test doubles allow unit tests to isolate the behaviour that matters.
A strong developer also knows the difference between a unit test, an integration test and a mixed test. They do not pretend that every automated test is a unit test. They use unit tests for business rules and isolated behaviour. They use integration tests for database, infrastructure and real dependency boundaries.
They write tests that are intentional, readable, single-behaviour, thorough, fast, automated, independent and deterministic. That is the FIRSTHAND mindset.
They understand DDD enough to place tests near business truth: entities, value objects, aggregates, domain services and application services. They know that testing a TimeSlot overlap rule or a loan approval rule is more valuable than testing framework plumbing.
They know how to deal with legacy systems carefully: add seams, refactor gradually, protect behaviour before changing it, and avoid reckless rewrites. They know CI turns tests from personal confidence into team confidence.
The interview-level answer is this:
“TDD is a design and feedback discipline. I write a failing test to express behaviour, make it pass with the simplest production code, then refactor safely. To make this work in real .NET systems, I design with dependency injection, abstractions, test doubles, clear boundaries, and automated CI. I focus tests on business behaviour, keep them fast and deterministic, and use integration tests where real infrastructure matters.”That is the difference between someone who has written unit tests and someone who understands test-driven development as a professional engineering practice.
