Testing Strategy — What to Unit Test, What to Integration Test, What to Skip
A practical testing strategy for Clean Architecture .NET projects: the test pyramid, what belongs in each level, Testcontainers for integration tests, and the production quality signals that come from the right test mix.
The Test Pyramid for Clean Architecture
┌─────────────────────┐
│ E2E / API Tests │ ← Few, slow, high confidence, test real behavior
├─────────────────────┤
│ Integration Tests │ ← Some, test DB + handler + mapping together
├─────────────────────┤
│ Unit Tests │ ← Many, fast, test domain + handler logic in isolation
└─────────────────────┘Unit tests: Domain entities, Application handlers, Value objects
Integration tests: Persistence (real DB), full handler-to-DB pipeline
Architecture tests: Layer boundaries, naming conventions (always run)
E2E / API tests: Critical flows through real HTTP endpoints (selective)What to Unit Test
// ✓ Domain entity invariants
[Fact]
public void Create_patient_with_future_dob_should_fail() { ... }
[Fact]
public void AddPrescription_to_inactive_patient_should_return_error() { ... }
// ✓ Application handler logic
[Fact]
public async Task Handle_duplicate_mrn_should_not_save_and_return_conflict() { ... }
[Fact]
public async Task Handle_valid_command_should_persist_patient_and_return_id() { ... }
// ✓ Value object validation
[Theory]
[InlineData(0, "mg")] // zero amount
[InlineData(-1, "mg")] // negative amount
[InlineData(100, "pints")] // invalid unit
public void Dosage_create_with_invalid_values_should_fail(decimal amount, string unit) { ... }
// ✗ Do NOT unit test:
// Controllers — they just call handlers; test the handlers
// Repository implementations — test them with real DB in integration tests
// FluentValidation validators in isolation — test them through the handlerWhat to Integration Test
// Integration tests use Testcontainers for a real SQL Server instance
// They test the actual persistence behavior — not an in-memory approximation
// tests/Integration.Tests/Patients/PatientRepositoryTests.cs
public class PatientRepositoryTests : IAsyncLifetime
{
private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("YourStrong@Passw0rd")
.Build();
private AppDbContext _context = null!;
private PatientRepository _sut = null!;
public async Task InitializeAsync()
{
await _sqlContainer.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(_sqlContainer.GetConnectionString())
.Options;
_context = new AppDbContext(options, new NoOpDomainEventPublisher());
await _context.Database.MigrateAsync();
_sut = new PatientRepository(_context);
}
[Fact]
public async Task AddAsync_and_GetByIdAsync_should_persist_and_retrieve_patient()
{
// Arrange
var patient = Patient.Create("John Smith", new DateOnly(1985, 3, 15), "MRN-001").Value;
// Act
await _sut.AddAsync(patient, CancellationToken.None);
await _context.SaveChangesAsync();
var retrieved = await _sut.GetByIdAsync(patient.Id, CancellationToken.None);
// Assert
retrieved.Should().NotBeNull();
retrieved!.MRN.Should().Be("MRN-001");
retrieved.Name.Should().Be("John Smith");
}
[Fact]
public async Task ExistsByMRNAsync_should_return_true_for_existing_mrn()
{
// Arrange
var patient = Patient.Create("Jane Doe", new DateOnly(1990, 7, 22), "MRN-002").Value;
await _sut.AddAsync(patient, CancellationToken.None);
await _context.SaveChangesAsync();
// Act
var exists = await _sut.ExistsByMRNAsync("MRN-002", CancellationToken.None);
// Assert
exists.Should().BeTrue();
}
public async Task DisposeAsync()
{
await _context.DisposeAsync();
await _sqlContainer.DisposeAsync();
}
}Testcontainers Setup
<!-- tests/Integration.Tests.csproj -->
<ItemGroup>
<PackageReference Include="Testcontainers.MsSql" Version="3.*" />
<PackageReference Include="Testcontainers.Redis" Version="3.*" />
<PackageReference Include="FluentAssertions" Version="7.*" />
<PackageReference Include="xunit" Version="2.*" />
</ItemGroup>Production issue I've seen: A team used EF Core's in-memory database for integration tests. Their tests passed. In production, a query used
Contains()with string comparison that behaved differently in SQL Server (case-insensitive) vs in-memory (case-sensitive). The bug was only caught in production. Testcontainers runs a real SQL Server in Docker — the tests would have caught it.
API-Level Integration Tests
// tests/Integration.Tests/Api/PatientsApiTests.cs
public class PatientsApiTests : IClassFixture<SystemForgeWebApplicationFactory>
{
private readonly HttpClient _client;
public PatientsApiTests(SystemForgeWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task POST_api_patients_should_return_201_with_patient_id()
{
// Arrange
var request = new
{
Name = "John Smith",
DateOfBirth = "1985-03-15",
MRN = "MRN-001"
};
// Act
var response = await _client.PostAsJsonAsync("/api/patients", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("id").GetGuid().Should().NotBeEmpty();
}
[Fact]
public async Task POST_api_patients_duplicate_mrn_should_return_409()
{
// Arrange — first create
var request = new { Name = "First", DateOfBirth = "1985-03-15", MRN = "MRN-DUP" };
await _client.PostAsJsonAsync("/api/patients", request);
// Act — duplicate
var response = await _client.PostAsJsonAsync("/api/patients", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
}
}
// tests/Integration.Tests/SystemForgeWebApplicationFactory.cs
public sealed class SystemForgeWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly MsSqlContainer _sql = new MsSqlBuilder().Build();
private readonly RedisContainer _redis = new RedisBuilder().Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Replace the real DB connection string with the test container's
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(_sql.GetConnectionString()));
services.Configure<RedisCacheOptions>(opts =>
opts.Configuration = _redis.GetConnectionString());
});
}
public override async ValueTask DisposeAsync()
{
await _sql.DisposeAsync();
await _redis.DisposeAsync();
await base.DisposeAsync();
}
}What to Skip
Skip (or defer) testing:
✗ Mapping code (if it is a simple property assignment with no logic)
✗ DI registration (if AddApplication() runs without throwing, it works)
✗ Framework behavior (ASP.NET model binding, EF Core change tracking)
✗ Third-party library behavior (Microsoft.AspNetCore.Identity's password hashing)
✗ Trivial getters/setters on DTOs
Write a test only when:
✓ There is a business rule that could be implemented incorrectly
✓ There is a conditional branch (if/switch) that could go the wrong way
✓ The code touches data persistence or external systems
✓ The behavior is non-obvious or has caught a production bug beforePRO TIP — Test the Error Paths First
Happy path tests are written by everyone. Error path tests catch the bugs that reach production. For every handler, write the failure tests first: not found, already exists, invalid input, inactive entity. These are the paths that are easiest to miss in implementation and most likely to reach production without a test.
Key Takeaway
The right test mix for Clean Architecture is: many domain and handler unit tests (fast, no infrastructure), some Testcontainers integration tests (real SQL Server, real Redis), always-running architecture tests (no infrastructure, catches structural drift), and selective API-level tests for critical flows. Coverage percentage is not the goal — confidence in real behavior is. A 95% coverage score with in-memory DB tests and a production SQL bug is worse than 70% coverage with Testcontainers tests that actually catch it.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.