Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETTesting StrategyIntegration TestsTestcontainersTest Pyramid
Share:𝕏

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

C#
// ✓ 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 handler

What to Integration Test

C#
// 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

XML
<!-- 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

C#
// 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 before

PRO 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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.