Clean Architecture is supposed to make testing easier. That’s the promise, right? Separate your concerns into layers, depend on abstractions, and suddenly every layer becomes independently testable. After building Kids Learn across five posts — from domain modeling in Part 1 through Infrastructure in Part 5 — let’s find out if that’s actually true.
Spoiler: the Domain layer is a dream to test. Pure logic, no dependencies, no mocks, no setup ceremonies. The Infrastructure layer… less so. Spinning up a real PostgreSQL with pgvector in a test just to verify your EF Core configuration maps correctly is not what I’d call “easy.” But it’s necessary, and the tooling has gotten dramatically better.
This post covers the full testing strategy for Kids Learn: unit tests for the domain, mocked tests for application handlers, integration tests with Testcontainers, API tests with WebApplicationFactory, architecture enforcement with NetArchTest, and realistic test data with Bogus. By the end, you’ll have a testing approach that covers every layer of a Clean Architecture application — and catches violations of the Dependency Rule before they reach production.
The Testing Pyramid for Clean Architecture
The classic testing pyramid says: lots of unit tests at the bottom (fast, cheap), fewer integration tests in the middle, and a thin layer of end-to-end tests at the top. Clean Architecture maps onto this naturally because each layer has different testing characteristics.
Here’s how it breaks down for Kids Learn:
Unit Tests — Domain Layer (Most tests, fastest) These test value objects, entities, aggregates, and domain services. No database, no HTTP, no external services. Pure C# logic. You can run thousands of these in seconds. This is where most of your test count should be.
Unit Tests with Mocks — Application Layer These test command/query handlers, validation pipelines, and authorization behavior. The handlers depend on abstractions (ports) defined in the Domain or Application layer, so you mock those with NSubstitute. Still fast, but you’re testing orchestration logic rather than pure domain rules.
Integration Tests — Infrastructure Layer These test EF Core configurations, repository implementations, database queries, and external service integrations. You need a real database — Testcontainers spins up PostgreSQL with pgvector in a Docker container. Slower, but they catch the bugs that mocks hide.
API Integration Tests — Presentation Layer These test the full HTTP pipeline: routing, model binding, authentication, middleware, and the entire request/response cycle. WebApplicationFactory creates an in-memory test server. You’re testing that all the layers work together correctly.
Architecture Tests — Cross-Cutting These aren’t functional tests. They verify structural rules: Domain doesn’t reference Infrastructure, Application doesn’t reference Presentation, handlers are internal. NetArchTest runs these as regular xUnit tests in CI.
The key insight is that Clean Architecture makes the pyramid natural. The Domain layer — which should contain most of your business logic — is also the easiest to test. If you find yourself writing mostly integration tests, that’s a sign you have an anemic domain and too much logic in your Infrastructure or Application layers.
Domain Layer Tests (No Mocks Needed)
This is the payoff for all the work we did in Parts 1 and 2 building a rich domain model. When your domain entities enforce their own invariants, testing them is trivially easy. No DI container, no mocks, no setup. Just instantiate objects and assert behavior.
Testing Value Objects
The MasteryLevel value object we built in Part 2 encapsulates a child’s skill level in a subject area. It has boundaries (0-100), increment/decrement logic, and classification rules. Here’s how to test it:
using FluentAssertions;
using KidsLearn.Domain.Learning.ValueObjects;
namespace KidsLearn.Domain.Tests.Learning.ValueObjects;
public class MasteryLevelTests
{
[Fact]
public void Create_WithValidScore_ShouldSucceed()
{
var mastery = MasteryLevel.Create(50);
mastery.Score.Should().Be(50);
mastery.Classification.Should().Be(MasteryClassification.Developing);
}
[Theory]
[InlineData(-1)]
[InlineData(101)]
[InlineData(-50)]
[InlineData(200)]
public void Create_WithInvalidScore_ShouldThrowDomainException(int score)
{
var act = () => MasteryLevel.Create(score);
act.Should().Throw<DomainException>()
.WithMessage("Mastery score must be between 0 and 100*");
}
[Theory]
[InlineData(0, MasteryClassification.Beginner)]
[InlineData(24, MasteryClassification.Beginner)]
[InlineData(25, MasteryClassification.Developing)]
[InlineData(49, MasteryClassification.Developing)]
[InlineData(50, MasteryClassification.Proficient)]
[InlineData(74, MasteryClassification.Proficient)]
[InlineData(75, MasteryClassification.Advanced)]
[InlineData(89, MasteryClassification.Advanced)]
[InlineData(90, MasteryClassification.Mastered)]
[InlineData(100, MasteryClassification.Mastered)]
public void Classification_ShouldMatchScore(
int score, MasteryClassification expected)
{
var mastery = MasteryLevel.Create(score);
mastery.Classification.Should().Be(expected);
}
[Fact]
public void Increase_ShouldRaiseScore_ClampedAt100()
{
var mastery = MasteryLevel.Create(95);
var increased = mastery.Increase(10);
increased.Score.Should().Be(100);
}
[Fact]
public void Decrease_ShouldLowerScore_ClampedAt0()
{
var mastery = MasteryLevel.Create(5);
var decreased = mastery.Decrease(10);
decreased.Score.Should().Be(0);
}
[Fact]
public void EqualityByValue_ShouldWork()
{
var a = MasteryLevel.Create(75);
var b = MasteryLevel.Create(75);
a.Should().Be(b);
(a == b).Should().BeTrue();
}
}
Notice what’s not here: no [SetUp], no dependency injection, no mocks, no async/await, no database connections. These tests run in microseconds. I have 47 tests for value objects alone, and the entire suite runs in under 200ms.
Testing Entities and Aggregates
The Child aggregate root is more interesting. When a child completes a lesson, the entity updates mastery levels, checks for level-ups, and raises domain events. All of this is testable without touching a database:
using FluentAssertions;
using KidsLearn.Domain.Learning.Entities;
using KidsLearn.Domain.Learning.Events;
using KidsLearn.Domain.Learning.ValueObjects;
namespace KidsLearn.Domain.Tests.Learning.Entities;
public class ChildTests
{
private Child CreateChild(string name = "Emma", int gradeLevel = 3)
{
return Child.Create(
name: name,
dateOfBirth: new DateOnly(2018, 5, 15),
gradeLevel: GradeLevel.Create(gradeLevel),
parentId: Guid.NewGuid());
}
[Fact]
public void Create_ShouldInitializeWithBeginnerMastery()
{
var child = CreateChild();
child.Name.Should().Be("Emma");
child.GradeLevel.Value.Should().Be(3);
child.SubjectMasteries.Should().BeEmpty();
}
[Fact]
public void CompleteLesson_ShouldUpdateMastery()
{
var child = CreateChild();
var lesson = LessonBuilder.Create()
.WithSubject(Subject.Mathematics)
.WithTopic("Addition")
.WithDifficultyScore(0.5m)
.Build();
var result = LessonResult.Create(
correctAnswers: 8,
totalExercises: 10,
timeSpent: TimeSpan.FromMinutes(12));
child.CompleteLesson(lesson, result);
child.SubjectMasteries.Should().ContainKey(Subject.Mathematics);
child.SubjectMasteries[Subject.Mathematics].Score.Should().BeGreaterThan(0);
}
[Fact]
public void CompleteLesson_ShouldRaiseLessonCompletedEvent()
{
var child = CreateChild();
var lesson = LessonBuilder.Create()
.WithSubject(Subject.Mathematics)
.WithDifficultyScore(0.5m)
.Build();
var result = LessonResult.Create(
correctAnswers: 9,
totalExercises: 10,
timeSpent: TimeSpan.FromMinutes(8));
child.CompleteLesson(lesson, result);
child.DomainEvents.Should().ContainSingle()
.Which.Should().BeOfType<LessonCompletedEvent>()
.Which.Should().Match<LessonCompletedEvent>(e =>
e.ChildId == child.Id &&
e.LessonId == lesson.Id &&
e.CorrectAnswers == 9);
}
[Fact]
public void CompleteLesson_WithPerfectScore_ShouldRaiseMasteryLevelUpEvent()
{
var child = CreateChild();
// Simulate existing mastery near threshold
child.SetMasteryForTesting(Subject.Mathematics, MasteryLevel.Create(73));
var lesson = LessonBuilder.Create()
.WithSubject(Subject.Mathematics)
.WithDifficultyScore(0.7m)
.Build();
var result = LessonResult.Create(
correctAnswers: 10,
totalExercises: 10,
timeSpent: TimeSpan.FromMinutes(6));
child.CompleteLesson(lesson, result);
child.DomainEvents.Should().HaveCount(2);
child.DomainEvents.Should().ContainSingle(
e => e is MasteryLevelUpEvent);
}
[Fact]
public void CompleteLesson_WithNoExercises_ShouldThrow()
{
var child = CreateChild();
var lesson = LessonBuilder.Create().Build();
var act = () => child.CompleteLesson(lesson, LessonResult.Create(
correctAnswers: 0,
totalExercises: 0,
timeSpent: TimeSpan.Zero));
act.Should().Throw<DomainException>()
.WithMessage("*at least one exercise*");
}
}
The SetMasteryForTesting method is a deliberate testing seam. Some people argue against any testing-specific methods in your domain. I think that’s purist nonsense. The alternative is constructing complex object graphs through the normal API just to get an entity into a specific state. That makes tests brittle and hard to read. A clearly-named ForTesting method is an honest trade-off.
Testing Domain Services
The adaptive difficulty engine is a domain service that decides what difficulty level a child should face next. It’s pure logic — takes mastery level, recent performance, and learning pace as inputs, returns a difficulty score:
public class AdaptiveDifficultyEngineTests
{
private readonly AdaptiveDifficultyEngine _engine = new();
[Fact]
public void Calculate_ForBeginner_ShouldReturnLowDifficulty()
{
var mastery = MasteryLevel.Create(15);
var recentPerformance = RecentPerformance.Create(
lastFiveScores: [0.6m, 0.5m, 0.7m, 0.4m, 0.6m]);
var difficulty = _engine.CalculateNextDifficulty(
mastery, recentPerformance);
difficulty.Score.Should().BeInRange(0.2m, 0.4m);
}
[Fact]
public void Calculate_ForStruggling_ShouldDecreaseDifficulty()
{
var mastery = MasteryLevel.Create(50);
var recentPerformance = RecentPerformance.Create(
lastFiveScores: [0.3m, 0.2m, 0.4m, 0.3m, 0.2m]);
var difficulty = _engine.CalculateNextDifficulty(
mastery, recentPerformance);
// Should be below the mastery-equivalent difficulty
difficulty.Score.Should().BeLessThan(0.5m);
}
[Fact]
public void Calculate_ForExcelling_ShouldIncreaseDifficulty()
{
var mastery = MasteryLevel.Create(50);
var recentPerformance = RecentPerformance.Create(
lastFiveScores: [0.95m, 0.9m, 1.0m, 0.85m, 0.9m]);
var difficulty = _engine.CalculateNextDifficulty(
mastery, recentPerformance);
difficulty.Score.Should().BeGreaterThan(0.5m);
}
}
These domain tests are my favorite kind of tests. They document business behavior, they run fast, and they never flake. When I refactor internal domain logic, these tests tell me instantly if I’ve broken anything. This is the testing payoff Clean Architecture promises — and it delivers.
Application Layer Tests
Moving up to the Application layer, things get slightly more involved. Command and query handlers depend on abstractions — repository interfaces, the IApplicationDbContext, external service ports like ILessonGenerator. We need mocks here, and NSubstitute makes that painless.
Testing Command Handlers
The GenerateLessonHandler takes a GenerateLessonCommand, calls the adaptive engine to determine difficulty, asks the AI lesson generator to create content, and persists the result. Here’s how to test it:
using FluentAssertions;
using KidsLearn.Application.Learning.Commands;
using KidsLearn.Application.Common.Interfaces;
using KidsLearn.Domain.Learning.Entities;
using KidsLearn.Domain.Learning.ValueObjects;
using NSubstitute;
namespace KidsLearn.Application.Tests.Learning.Commands;
public class GenerateLessonHandlerTests
{
private readonly IApplicationDbContext _dbContext;
private readonly ILessonGenerator _lessonGenerator;
private readonly IAdaptiveEngine _adaptiveEngine;
private readonly GenerateLessonHandler _handler;
public GenerateLessonHandlerTests()
{
_dbContext = Substitute.For<IApplicationDbContext>();
_lessonGenerator = Substitute.For<ILessonGenerator>();
_adaptiveEngine = Substitute.For<IAdaptiveEngine>();
_handler = new GenerateLessonHandler(
_dbContext, _lessonGenerator, _adaptiveEngine);
}
[Fact]
public async Task Handle_ShouldGenerateLessonAtCorrectDifficulty()
{
// Arrange
var childId = Guid.NewGuid();
var child = CreateTestChild(childId, masteryScore: 60);
var command = new GenerateLessonCommand(
childId, Subject.Mathematics, "Fractions");
_dbContext.Children
.FindAsync(childId, Arg.Any<CancellationToken>())
.Returns(child);
_adaptiveEngine
.CalculateNextDifficulty(Arg.Any<MasteryLevel>(),
Arg.Any<RecentPerformance>())
.Returns(DifficultyScore.Create(0.65m));
var generatedLesson = LessonBuilder.Create()
.WithSubject(Subject.Mathematics)
.WithTopic("Fractions")
.WithDifficultyScore(0.65m)
.WithExercises(8)
.Build();
_lessonGenerator
.GenerateAsync(Arg.Any<LessonRequest>(),
Arg.Any<CancellationToken>())
.Returns(generatedLesson);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.DifficultyScore.Score.Should().Be(0.65m);
result.Value.Exercises.Should().HaveCount(8);
await _dbContext.Received(1)
.SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenChildNotFound_ShouldReturnNotFound()
{
var command = new GenerateLessonCommand(
Guid.NewGuid(), Subject.Mathematics, "Algebra");
_dbContext.Children
.FindAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns((Child?)null);
var result = await _handler.Handle(command, CancellationToken.None);
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Child.NotFound");
await _lessonGenerator.DidNotReceive()
.GenerateAsync(Arg.Any<LessonRequest>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenGeneratorFails_ShouldReturnError()
{
var childId = Guid.NewGuid();
var child = CreateTestChild(childId, masteryScore: 40);
var command = new GenerateLessonCommand(
childId, Subject.Science, "Solar System");
_dbContext.Children
.FindAsync(childId, Arg.Any<CancellationToken>())
.Returns(child);
_adaptiveEngine
.CalculateNextDifficulty(Arg.Any<MasteryLevel>(),
Arg.Any<RecentPerformance>())
.Returns(DifficultyScore.Create(0.4m));
_lessonGenerator
.GenerateAsync(Arg.Any<LessonRequest>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new ExternalServiceException("Gemini API rate limited"));
var act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<ExternalServiceException>();
await _dbContext.DidNotReceive()
.SaveChangesAsync(Arg.Any<CancellationToken>());
}
private static Child CreateTestChild(
Guid id, int masteryScore = 50) { /* ... */ }
}
The arrange-act-assert pattern is explicit. NSubstitute’s .Returns() and .Received() syntax reads almost like English. I’m testing three things: the happy path, the “not found” case, and failure propagation. Each test verifies that the handler orchestrates correctly — calling the right dependencies in the right order with the right arguments.
Testing Validation Behavior
If you’re using MediatR’s pipeline behaviors (as we set up in Part 3), validation happens before the handler runs. Test them separately:
public class GenerateLessonCommandValidatorTests
{
private readonly GenerateLessonCommandValidator _validator = new();
[Fact]
public async Task Validate_WithValidCommand_ShouldPass()
{
var command = new GenerateLessonCommand(
Guid.NewGuid(), Subject.Mathematics, "Fractions");
var result = await _validator.ValidateAsync(command);
result.IsValid.Should().BeTrue();
}
[Fact]
public async Task Validate_WithEmptyChildId_ShouldFail()
{
var command = new GenerateLessonCommand(
Guid.Empty, Subject.Mathematics, "Fractions");
var result = await _validator.ValidateAsync(command);
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainSingle()
.Which.PropertyName.Should().Be("ChildId");
}
[Fact]
public async Task Validate_WithEmptyTopic_ShouldFail()
{
var command = new GenerateLessonCommand(
Guid.NewGuid(), Subject.Mathematics, "");
var result = await _validator.ValidateAsync(command);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(
e => e.PropertyName == "Topic");
}
[Fact]
public async Task Validate_WithTopicTooLong_ShouldFail()
{
var command = new GenerateLessonCommand(
Guid.NewGuid(), Subject.Mathematics,
new string('x', 201));
var result = await _validator.ValidateAsync(command);
result.IsValid.Should().BeFalse();
}
}
The Mocking Debate
Here’s a question I get asked a lot: is mocking IApplicationDbContext worth it, or should you just use a real database for Application layer tests?
My take: mock it for handler logic tests, use a real database for query tests.
When I’m testing that GenerateLessonHandler calls the adaptive engine, then the lesson generator, then saves to the database — mocks are perfect. I’m testing orchestration, not data access. The test runs in milliseconds and I can precisely control what each dependency returns.
But when I’m testing a complex query handler that builds a LINQ expression tree to filter and page children by mastery level, a mock of IApplicationDbContext that returns List<Child>.AsQueryable() doesn’t actually test EF Core’s query translation. That query might work against List<T> but throw at runtime because EF can’t translate it to SQL. For those, I use integration tests with a real database.
The rule of thumb: if your test would be equally valid with a different ORM, mock it. If the test is specifically about database behavior, use the real thing.
Infrastructure Tests with Testcontainers
Infrastructure tests are where the rubber meets the road. Your EF Core configurations, your value conversions, your pgvector queries — none of these can be tested with mocks. You need a real PostgreSQL instance with the pgvector extension installed.
Testcontainers makes this manageable. It spins up a Docker container with PostgreSQL + pgvector, runs your migrations, executes your tests, and tears everything down. The first test is slow (starting the container takes 3-5 seconds), but subsequent tests reuse the container.
The Test Fixture
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;
namespace KidsLearn.Infrastructure.Tests;
public class DatabaseFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("pgvector/pgvector:pg16")
.WithDatabase("kidslearn_test")
.WithUsername("test")
.WithPassword("test")
.Build();
public ApplicationDbContext DbContext { get; private set; } = null!;
public async Task InitializeAsync()
{
await _postgres.StartAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(_postgres.GetConnectionString(), npgsql =>
{
npgsql.UseVector();
npgsql.MigrationsAssembly(
typeof(ApplicationDbContext).Assembly.FullName);
})
.Options;
DbContext = new ApplicationDbContext(options,
Substitute.For<IDomainEventDispatcher>(),
Substitute.For<TimeProvider>());
await DbContext.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await DbContext.DisposeAsync();
await _postgres.DisposeAsync();
}
}
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>;
The pgvector/pgvector:pg16 image is critical. Using a plain postgres:16 image means your pgvector tests will fail because the extension isn’t installed. I wasted an hour debugging “function cosine_distance does not exist” before figuring this out.
Testing EF Core Configurations
These tests verify that your entity configurations actually work — that value objects are mapped correctly, JSON columns serialize/deserialize, and value conversions don’t lose data:
[Collection("Database")]
public class ChildEntityConfigurationTests(DatabaseFixture fixture)
{
private readonly ApplicationDbContext _db = fixture.DbContext;
[Fact]
public async Task Child_ShouldPersistAndRetrieve_WithAllProperties()
{
// Arrange
var child = Child.Create(
name: "Emma",
dateOfBirth: new DateOnly(2019, 3, 10),
gradeLevel: GradeLevel.Create(2),
parentId: Guid.NewGuid());
child.SetMasteryForTesting(Subject.Mathematics,
MasteryLevel.Create(65));
child.SetMasteryForTesting(Subject.Reading,
MasteryLevel.Create(80));
// Act
_db.Children.Add(child);
await _db.SaveChangesAsync();
_db.ChangeTracker.Clear();
var retrieved = await _db.Children
.FirstOrDefaultAsync(c => c.Id == child.Id);
// Assert
retrieved.Should().NotBeNull();
retrieved!.Name.Should().Be("Emma");
retrieved.GradeLevel.Value.Should().Be(2);
retrieved.SubjectMasteries.Should().HaveCount(2);
retrieved.SubjectMasteries[Subject.Mathematics]
.Score.Should().Be(65);
}
[Fact]
public async Task Child_SubjectMasteries_ShouldPersistAsJsonColumn()
{
var child = Child.Create(
name: "Liam",
dateOfBirth: new DateOnly(2017, 7, 22),
gradeLevel: GradeLevel.Create(4),
parentId: Guid.NewGuid());
child.SetMasteryForTesting(Subject.Science,
MasteryLevel.Create(45));
_db.Children.Add(child);
await _db.SaveChangesAsync();
// Verify it's stored as JSON using raw SQL
var json = await _db.Database
.SqlQuery<string>(
$"SELECT subject_masteries::text FROM children WHERE id = {child.Id}")
.FirstOrDefaultAsync();
json.Should().Contain("Science");
json.Should().Contain("45");
}
}
The ChangeTracker.Clear() call is important. Without it, EF Core returns the entity from its change tracker — which means you’re testing in-memory state, not what was actually persisted and retrieved from the database. Clearing the tracker forces a fresh round-trip.
Testing Vector Similarity Queries
This is where Testcontainers really earns its keep. Our RAG-based curriculum alignment uses pgvector to find lessons similar to a given embedding. You can’t test this with mocks:
[Collection("Database")]
public class VectorSearchTests(DatabaseFixture fixture)
{
private readonly ApplicationDbContext _db = fixture.DbContext;
[Fact]
public async Task FindSimilarContent_ShouldReturnClosestMatches()
{
// Arrange — seed curriculum content with embeddings
var mathContent = CurriculumContent.Create(
title: "Introduction to Fractions",
subject: Subject.Mathematics,
gradeLevel: GradeLevel.Create(3),
content: "A fraction represents a part of a whole...",
embedding: GenerateEmbedding(seed: 42));
var scienceContent = CurriculumContent.Create(
title: "The Solar System",
subject: Subject.Science,
gradeLevel: GradeLevel.Create(3),
content: "Our solar system consists of...",
embedding: GenerateEmbedding(seed: 99));
var relatedMathContent = CurriculumContent.Create(
title: "Adding Fractions",
subject: Subject.Mathematics,
gradeLevel: GradeLevel.Create(3),
content: "To add fractions with the same denominator...",
embedding: GenerateSimilarEmbedding(seed: 42, noise: 0.1f));
_db.CurriculumContents.AddRange(
mathContent, scienceContent, relatedMathContent);
await _db.SaveChangesAsync();
// Act — search for content similar to "Introduction to Fractions"
var queryEmbedding = mathContent.Embedding;
var results = await _db.CurriculumContents
.OrderBy(c => c.Embedding!.CosineDistance(queryEmbedding))
.Take(2)
.ToListAsync();
// Assert
results.Should().HaveCount(2);
results[0].Title.Should().Be("Introduction to Fractions");
results[1].Title.Should().Be("Adding Fractions");
// Science content should NOT be in top 2
results.Should().NotContain(c => c.Subject == Subject.Science);
}
private static Vector GenerateEmbedding(int seed)
{
var random = new Random(seed);
var values = Enumerable.Range(0, 1536)
.Select(_ => (float)(random.NextDouble() * 2 - 1))
.ToArray();
return new Vector(values);
}
private static Vector GenerateSimilarEmbedding(
int seed, float noise)
{
var random = new Random(seed);
var noiseRandom = new Random(seed + 1);
var values = Enumerable.Range(0, 1536)
.Select(_ => (float)(random.NextDouble() * 2 - 1)
+ (float)(noiseRandom.NextDouble() * noise))
.ToArray();
return new Vector(values);
}
}
I once had a bug where the cosine distance ordering was inverted — closest results were last instead of first. A mock would never have caught that. The Testcontainers-based test caught it immediately.
Testing Domain Event Dispatch
Our ApplicationDbContext dispatches domain events after SaveChanges (as implemented in Part 5). Test that the dispatch actually happens:
[Collection("Database")]
public class DomainEventDispatchTests
{
[Fact]
public async Task SaveChanges_ShouldDispatchDomainEvents()
{
// Arrange
var dispatcher = Substitute.For<IDomainEventDispatcher>();
var container = new PostgreSqlBuilder()
.WithImage("pgvector/pgvector:pg16")
.Build();
await container.StartAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(container.GetConnectionString(),
npgsql => npgsql.UseVector())
.Options;
var dbContext = new ApplicationDbContext(
options, dispatcher, TimeProvider.System);
await dbContext.Database.MigrateAsync();
var child = Child.Create(
name: "Test",
dateOfBirth: new DateOnly(2019, 1, 1),
gradeLevel: GradeLevel.Create(2),
parentId: Guid.NewGuid());
var lesson = LessonBuilder.Create()
.WithSubject(Subject.Mathematics)
.WithDifficultyScore(0.5m)
.Build();
var result = LessonResult.Create(8, 10, TimeSpan.FromMinutes(10));
child.CompleteLesson(lesson, result);
dbContext.Children.Add(child);
// Act
await dbContext.SaveChangesAsync();
// Assert
await dispatcher.Received(1)
.DispatchAsync(Arg.Is<IReadOnlyList<IDomainEvent>>(
events => events.Any(e => e is LessonCompletedEvent)));
// Cleanup
await dbContext.DisposeAsync();
await container.DisposeAsync();
}
}
Database Cleanup with Respawn
When tests share a database (via the collection fixture), data from one test can leak into another. Respawn resets the database between tests without dropping and recreating it — much faster than running migrations again:
public class DatabaseFixture : IAsyncLifetime
{
private Respawner _respawner = null!;
// ... container setup from above ...
public async Task InitializeAsync()
{
await _postgres.StartAsync();
// ... DbContext setup, migrations ...
_respawner = await Respawner.CreateAsync(
_postgres.GetConnectionString(),
new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
SchemasToInclude = ["public"],
TablesToIgnore = ["__EFMigrationsHistory"]
});
}
public async Task ResetDatabaseAsync()
{
await _respawner.ResetAsync(_postgres.GetConnectionString());
}
}
Each test class calls fixture.ResetDatabaseAsync() in its constructor or a [Fact] setup. This gives you test isolation without the overhead of spinning up a new container for each test.
Presentation Tests with WebApplicationFactory
For the API layer, I want to test the full HTTP pipeline. Not just the controller logic — the routing, model binding, authentication middleware, exception handling, and content negotiation. WebApplicationFactory<Program> gives us an in-memory test server that exercises the entire pipeline.
Custom WebApplicationFactory
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
namespace KidsLearn.Api.Tests;
public class KidsLearnApiFactory : WebApplicationFactory<Program>,
IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("pgvector/pgvector:pg16")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove the real DbContext registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(
DbContextOptions<ApplicationDbContext>));
if (descriptor != null) services.Remove(descriptor);
// Add test database
services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString(),
npgsql => npgsql.UseVector()));
// Replace Gemini with a fake
var geminiDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(ILessonGenerator));
if (geminiDescriptor != null)
services.Remove(geminiDescriptor);
services.AddSingleton<ILessonGenerator,
FakeLessonGenerator>();
// Replace auth with test auth
services.AddAuthentication("Test")
.AddScheme<TestAuthSchemeOptions,
TestAuthHandler>("Test", _ => { });
});
}
public async Task InitializeAsync()
{
await _postgres.StartAsync();
// Run migrations
using var scope = Services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
await db.Database.MigrateAsync();
}
public new async Task DisposeAsync()
{
await base.DisposeAsync();
await _postgres.DisposeAsync();
}
}
The FakeLessonGenerator returns deterministic lessons — no actual Gemini API calls in tests. The TestAuthHandler lets us simulate authenticated requests without real JWTs. These replacements are key to making API tests fast and reliable.
Testing Full User Flows
Here’s the test I’m most proud of — a full learning flow that exercises the complete stack:
public class LearningFlowTests(KidsLearnApiFactory factory)
: IClassFixture<KidsLearnApiFactory>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task CompleteFlow_CreateChild_GenerateLesson_Complete_CheckProgress()
{
// Step 1: Create a child
var createChildRequest = new
{
name = "Emma",
dateOfBirth = "2019-03-10",
gradeLevel = 3
};
var createResponse = await _client.PostAsJsonAsync(
"/api/children", createChildRequest);
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var child = await createResponse.Content
.ReadFromJsonAsync<ChildResponse>();
child.Should().NotBeNull();
var childId = child!.Id;
// Step 2: Generate a lesson
var generateRequest = new
{
subject = "Mathematics",
topic = "Fractions"
};
var lessonResponse = await _client.PostAsJsonAsync(
$"/api/children/{childId}/lessons/generate", generateRequest);
lessonResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var lesson = await lessonResponse.Content
.ReadFromJsonAsync<LessonResponse>();
lesson.Should().NotBeNull();
lesson!.Exercises.Should().NotBeEmpty();
// Step 3: Complete the lesson
var completeRequest = new
{
lessonId = lesson.Id,
answers = lesson.Exercises.Select(e => new
{
exerciseId = e.Id,
answer = e.CorrectAnswer // cheat for testing
}).ToArray()
};
var completeResponse = await _client.PostAsJsonAsync(
$"/api/children/{childId}/lessons/{lesson.Id}/complete",
completeRequest);
completeResponse.StatusCode.Should().Be(HttpStatusCode.OK);
// Step 4: Check progress
var progressResponse = await _client.GetAsync(
$"/api/children/{childId}/progress");
progressResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var progress = await progressResponse.Content
.ReadFromJsonAsync<ProgressResponse>();
progress.Should().NotBeNull();
progress!.SubjectMasteries.Should().ContainKey("Mathematics");
progress.SubjectMasteries["Mathematics"].Score
.Should().BeGreaterThan(0);
progress.CompletedLessons.Should().Be(1);
}
[Fact]
public async Task CreateChild_WithInvalidData_ShouldReturn400()
{
var request = new
{
name = "", // invalid
dateOfBirth = "2019-03-10",
gradeLevel = 15 // invalid
};
var response = await _client.PostAsJsonAsync(
"/api/children", request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await response.Content
.ReadFromJsonAsync<ValidationProblemDetails>();
problem!.Errors.Should().ContainKey("Name");
problem.Errors.Should().ContainKey("GradeLevel");
}
[Fact]
public async Task GetChild_Unauthorized_ShouldReturn401()
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Remove("Authorization");
var response = await client.GetAsync(
$"/api/children/{Guid.NewGuid()}");
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
}
This single test catches integration bugs that unit tests never would: serialization issues, routing misconfiguration, middleware ordering problems, DI registration errors. When the CreateChild endpoint changed its response shape in a refactor, this test caught it immediately — the unit tests for the handler still passed because they didn’t test HTTP serialization.
The trade-off is speed. Each test class that uses KidsLearnApiFactory takes 5-8 seconds for the first test (starting the container and running migrations). I keep the API test count small — 15-20 tests covering critical flows. If I need to test every edge case, I do it at the domain or application layer.
Architecture Enforcement with NetArchTest
This is the section that will save your project from slowly degrading into a ball of mud. Clean Architecture only works if the dependency rules are maintained. One developer adds a using KidsLearn.Infrastructure to a domain entity, and the whole thing starts unraveling.
NetArchTest lets you write tests that verify your architecture rules programmatically. They run in CI alongside your functional tests. If someone violates the Dependency Rule, the build fails.
The Complete Architecture Test Suite
using FluentAssertions;
using NetArchTest.Rules;
using System.Reflection;
namespace KidsLearn.Architecture.Tests;
public class ArchitectureTests
{
private static readonly Assembly DomainAssembly =
typeof(Domain.Learning.Entities.Child).Assembly;
private static readonly Assembly ApplicationAssembly =
typeof(Application.Learning.Commands.GenerateLessonCommand).Assembly;
private static readonly Assembly InfrastructureAssembly =
typeof(Infrastructure.Persistence.ApplicationDbContext).Assembly;
private static readonly Assembly ApiAssembly =
typeof(Api.Program).Assembly;
// ── Domain Layer Rules ──
[Fact]
public void Domain_ShouldNotReference_Application()
{
Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn("KidsLearn.Application")
.GetResult()
.IsSuccessful.Should().BeTrue(
"Domain must not depend on Application layer");
}
[Fact]
public void Domain_ShouldNotReference_Infrastructure()
{
Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn("KidsLearn.Infrastructure")
.GetResult()
.IsSuccessful.Should().BeTrue(
"Domain must not depend on Infrastructure layer");
}
[Fact]
public void Domain_ShouldNotReference_Api()
{
Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn("KidsLearn.Api")
.GetResult()
.IsSuccessful.Should().BeTrue(
"Domain must not depend on Presentation layer");
}
[Fact]
public void Domain_ShouldNotReference_EntityFramework()
{
Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn("Microsoft.EntityFrameworkCore")
.GetResult()
.IsSuccessful.Should().BeTrue(
"Domain must not depend on any ORM");
}
// ── Application Layer Rules ──
[Fact]
public void Application_ShouldNotReference_Infrastructure()
{
Types.InAssembly(ApplicationAssembly)
.ShouldNot()
.HaveDependencyOn("KidsLearn.Infrastructure")
.GetResult()
.IsSuccessful.Should().BeTrue(
"Application must not depend on Infrastructure layer");
}
[Fact]
public void Application_ShouldNotReference_Api()
{
Types.InAssembly(ApplicationAssembly)
.ShouldNot()
.HaveDependencyOn("KidsLearn.Api")
.GetResult()
.IsSuccessful.Should().BeTrue(
"Application must not depend on Presentation layer");
}
// ── Infrastructure Layer Rules ──
[Fact]
public void Infrastructure_ShouldNotReference_Api()
{
Types.InAssembly(InfrastructureAssembly)
.ShouldNot()
.HaveDependencyOn("KidsLearn.Api")
.GetResult()
.IsSuccessful.Should().BeTrue(
"Infrastructure must not depend on Presentation layer");
}
// ── Convention Rules ──
[Fact]
public void CommandHandlers_ShouldBeInternal()
{
Types.InAssembly(ApplicationAssembly)
.That()
.HaveNameEndingWith("Handler")
.And()
.AreNotAbstract()
.Should()
.NotBePublic()
.GetResult()
.IsSuccessful.Should().BeTrue(
"Handlers should be internal — only MediatR needs to find them");
}
[Fact]
public void Commands_ShouldBeSealed()
{
Types.InAssembly(ApplicationAssembly)
.That()
.HaveNameEndingWith("Command")
.Should()
.BeSealed()
.GetResult()
.IsSuccessful.Should().BeTrue(
"Commands should be sealed — they're data contracts, not inheritance hierarchies");
}
[Fact]
public void Queries_ShouldBeSealed()
{
Types.InAssembly(ApplicationAssembly)
.That()
.HaveNameEndingWith("Query")
.Should()
.BeSealed()
.GetResult()
.IsSuccessful.Should().BeTrue(
"Queries should be sealed");
}
[Fact]
public void DomainEntities_ShouldNotHavePublicSetters()
{
var entityTypes = Types.InAssembly(DomainAssembly)
.That()
.Inherit(typeof(Domain.Common.Entity))
.GetTypes();
foreach (var type in entityTypes)
{
var publicSetters = type.GetProperties(
BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.SetMethod?.IsPublic == true)
.Select(p => p.Name)
.ToList();
publicSetters.Should().BeEmpty(
$"{type.Name} has public setters: " +
$"{string.Join(", ", publicSetters)}. " +
"Domain entities must encapsulate state changes.");
}
}
[Fact]
public void DomainEvents_ShouldBeSealed()
{
Types.InAssembly(DomainAssembly)
.That()
.ImplementInterface(typeof(Domain.Common.IDomainEvent))
.Should()
.BeSealed()
.GetResult()
.IsSuccessful.Should().BeTrue();
}
[Fact]
public void Validators_ShouldResideInApplicationLayer()
{
Types.InAssembly(ApplicationAssembly)
.That()
.HaveNameEndingWith("Validator")
.Should()
.ResideInNamespaceContaining("Application")
.GetResult()
.IsSuccessful.Should().BeTrue();
}
}
I run these in CI as part of the regular test suite. The test names are descriptive enough that when one fails, the error message tells you exactly what rule was violated and why it matters. When a new developer added using Microsoft.EntityFrameworkCore to a domain entity to use [Owned] attribute, the Domain_ShouldNotReference_EntityFramework test caught it in the PR build.
The DomainEntities_ShouldNotHavePublicSetters test is my favorite convention rule. It enforces encapsulation at the architecture level. No more “I’ll just make this setter public for convenience” — the build won’t let you.
Running Architecture Tests in CI
Add them to your GitHub Actions workflow:
- name: Run Architecture Tests
run: dotnet test tests/KidsLearn.Architecture.Tests
--configuration Release
--logger "trx;LogFileName=arch-tests.trx"
--results-directory ./test-results
- name: Run Unit Tests
run: dotnet test tests/KidsLearn.Domain.Tests tests/KidsLearn.Application.Tests
--configuration Release
--logger "trx;LogFileName=unit-tests.trx"
- name: Run Integration Tests
run: dotnet test tests/KidsLearn.Infrastructure.Tests tests/KidsLearn.Api.Tests
--configuration Release
--logger "trx;LogFileName=integration-tests.trx"
I run architecture tests first. They’re the fastest (no Docker containers, no database) and if they fail, there’s no point running the rest. Fast feedback.
Test Data with Bogus
Real tests need realistic data. Hardcoding “Test User” and “test@example.com” in every test makes them fragile and unreadable. Bogus generates realistic, randomized test data that makes your tests both more robust and more readable.
Custom Fakers for Kids Learn
using Bogus;
using KidsLearn.Domain.Learning.Entities;
using KidsLearn.Domain.Learning.ValueObjects;
namespace KidsLearn.Tests.Common.Fakers;
public sealed class ChildFaker : Faker<Child>
{
public ChildFaker()
{
CustomInstantiator(f =>
{
var name = f.Name.FirstName();
var dateOfBirth = f.Date.BetweenDateOnly(
new DateOnly(2014, 1, 1),
new DateOnly(2022, 12, 31));
var gradeLevel = GradeLevel.Create(
f.Random.Int(1, 6));
return Child.Create(
name: name,
dateOfBirth: dateOfBirth,
gradeLevel: gradeLevel,
parentId: f.Random.Guid());
});
}
public ChildFaker WithGradeLevel(int grade)
{
CustomInstantiator(f => Child.Create(
name: f.Name.FirstName(),
dateOfBirth: f.Date.BetweenDateOnly(
new DateOnly(2014, 1, 1),
new DateOnly(2022, 12, 31)),
gradeLevel: GradeLevel.Create(grade),
parentId: f.Random.Guid()));
return this;
}
public ChildFaker WithMastery(
Subject subject, int score)
{
FinishWith((f, child) =>
child.SetMasteryForTesting(
subject, MasteryLevel.Create(score)));
return this;
}
}
public sealed class LessonFaker : Faker<Lesson>
{
private static readonly string[] MathTopics =
["Addition", "Subtraction", "Fractions", "Geometry",
"Multiplication", "Division", "Patterns", "Measurement"];
private static readonly string[] ScienceTopics =
["Solar System", "Animals", "Plants", "Weather",
"Human Body", "Matter", "Energy", "Ecosystems"];
public LessonFaker()
{
CustomInstantiator(f =>
{
var subject = f.PickRandom<Subject>();
var topics = subject == Subject.Mathematics
? MathTopics : ScienceTopics;
return LessonBuilder.Create()
.WithSubject(subject)
.WithTopic(f.PickRandom(topics))
.WithDifficultyScore(
Math.Round(f.Random.Decimal(0.1m, 0.95m), 2))
.WithExercises(f.Random.Int(5, 12))
.Build();
});
}
public LessonFaker WithSubject(Subject subject)
{
CustomInstantiator(f => LessonBuilder.Create()
.WithSubject(subject)
.WithTopic(f.Lorem.Word())
.WithDifficultyScore(f.Random.Decimal(0.1m, 0.95m))
.WithExercises(f.Random.Int(5, 12))
.Build());
return this;
}
}
public sealed class LearningSessionFaker : Faker<LearningSession>
{
public LearningSessionFaker()
{
CustomInstantiator(f =>
{
var totalExercises = f.Random.Int(5, 12);
var correctAnswers = f.Random.Int(0, totalExercises);
var timeSpent = TimeSpan.FromMinutes(
f.Random.Int(3, 30));
return LearningSession.Create(
childId: f.Random.Guid(),
lessonId: f.Random.Guid(),
result: LessonResult.Create(
correctAnswers, totalExercises, timeSpent),
completedAt: f.Date.Recent(30));
});
}
public LearningSessionFaker WithHighPerformance()
{
CustomInstantiator(f =>
{
var totalExercises = f.Random.Int(8, 12);
var correctAnswers = f.Random.Int(
(int)(totalExercises * 0.85), totalExercises);
return LearningSession.Create(
childId: f.Random.Guid(),
lessonId: f.Random.Guid(),
result: LessonResult.Create(
correctAnswers, totalExercises,
TimeSpan.FromMinutes(f.Random.Int(5, 15))),
completedAt: f.Date.Recent(7));
});
}
public LearningSessionFaker WithLowPerformance()
{
CustomInstantiator(f =>
{
var totalExercises = f.Random.Int(5, 10);
var correctAnswers = f.Random.Int(
0, (int)(totalExercises * 0.4));
return LearningSession.Create(
childId: f.Random.Guid(),
lessonId: f.Random.Guid(),
result: LessonResult.Create(
correctAnswers, totalExercises,
TimeSpan.FromMinutes(f.Random.Int(15, 35))),
completedAt: f.Date.Recent(7));
});
}
}
Using these fakers in tests makes the intent clear:
[Fact]
public void AdaptiveEngine_ShouldDecreaseDifficulty_ForStrugglingChild()
{
var child = new ChildFaker()
.WithGradeLevel(3)
.WithMastery(Subject.Mathematics, 45)
.Generate();
var sessions = new LearningSessionFaker()
.WithLowPerformance()
.Generate(5);
var difficulty = _engine.CalculateNextDifficulty(
child.SubjectMasteries[Subject.Mathematics],
RecentPerformance.FromSessions(sessions));
difficulty.Score.Should().BeLessThan(0.45m);
}
Compare that to a test where you manually construct every object with magic numbers. The faker-based test reads like a specification: “For a struggling child in grade 3 with 45% math mastery and 5 low-performance sessions, the adaptive engine should decrease difficulty.”
AutoFixture for Edge Cases
Bogus is great for realistic data. AutoFixture is great for “I don’t care about the data, just give me a valid instance.” I use both:
[Theory, AutoData]
public void MasteryLevel_Increase_ShouldNeverExceed100(
[Range(0, 100)] int startScore,
[Range(1, 100)] int increaseAmount)
{
var mastery = MasteryLevel.Create(startScore);
var result = mastery.Increase(increaseAmount);
result.Score.Should().BeInRange(0, 100);
}
AutoFixture with [AutoData] generates random values for parameters. Combine this with [Range] attributes and you get property-based-ish testing without pulling in a full property-based testing framework. For pure domain logic, this catches edge cases that example-based tests miss.
The Test Project Structure
Here’s how I organize the test projects to mirror the production code:
tests/
├── KidsLearn.Domain.Tests/ # Pure unit tests
│ ├── Learning/
│ │ ├── Entities/
│ │ │ ├── ChildTests.cs
│ │ │ └── LessonTests.cs
│ │ ├── ValueObjects/
│ │ │ ├── MasteryLevelTests.cs
│ │ │ ├── GradeLevelTests.cs
│ │ │ └── DifficultyScoreTests.cs
│ │ └── Services/
│ │ └── AdaptiveDifficultyEngineTests.cs
│ └── KidsLearn.Domain.Tests.csproj
│
├── KidsLearn.Application.Tests/ # Unit tests with mocks
│ ├── Learning/
│ │ ├── Commands/
│ │ │ ├── GenerateLessonHandlerTests.cs
│ │ │ └── CompleteLessonHandlerTests.cs
│ │ └── Queries/
│ │ ├── GetChildProgressHandlerTests.cs
│ │ └── GetLessonHandlerTests.cs
│ ├── Validators/
│ │ └── GenerateLessonCommandValidatorTests.cs
│ └── KidsLearn.Application.Tests.csproj
│
├── KidsLearn.Infrastructure.Tests/ # Integration tests
│ ├── Persistence/
│ │ ├── ChildEntityConfigurationTests.cs
│ │ ├── VectorSearchTests.cs
│ │ └── DomainEventDispatchTests.cs
│ ├── DatabaseFixture.cs
│ └── KidsLearn.Infrastructure.Tests.csproj
│
├── KidsLearn.Api.Tests/ # API integration tests
│ ├── Endpoints/
│ │ ├── LearningFlowTests.cs
│ │ └── ChildEndpointsTests.cs
│ ├── KidsLearnApiFactory.cs
│ ├── FakeLessonGenerator.cs
│ └── KidsLearn.Api.Tests.csproj
│
├── KidsLearn.Architecture.Tests/ # Architecture rules
│ ├── ArchitectureTests.cs
│ └── KidsLearn.Architecture.Tests.csproj
│
└── KidsLearn.Tests.Common/ # Shared test utilities
├── Fakers/
│ ├── ChildFaker.cs
│ ├── LessonFaker.cs
│ └── LearningSessionFaker.cs
├── Builders/
│ └── LessonBuilder.cs
└── KidsLearn.Tests.Common.csproj
Each test project references only what it needs:
- Domain.Tests references Domain only (no Application, no Infrastructure)
- Application.Tests references Domain + Application + NSubstitute
- Infrastructure.Tests references all production projects + Testcontainers
- Api.Tests references everything + WebApplicationFactory
- Architecture.Tests references all production assemblies (to inspect them) + NetArchTest
- Tests.Common contains shared fakers and builders, referenced by other test projects
This structure mirrors the dependency rules of the production code itself. Domain tests don’t even have a reference to Infrastructure — they couldn’t accidentally test database behavior if they tried.
Honest Assessment: What Works and What Doesn’t
After six months of maintaining this test suite for Kids Learn, here’s my honest assessment.
What works beautifully:
- Domain tests are a joy. Fast, stable, and they catch real bugs. When I refactored the mastery calculation algorithm, 12 domain tests told me exactly which edge cases I’d broken. Fixed in 20 minutes.
- Architecture tests have prevented at least 3 dependency violations in PRs. Worth every line of code.
- The testing pyramid feels natural. Most of my 200+ tests are domain unit tests. About 40 are application tests with mocks. About 25 are infrastructure integration tests. About 15 are API tests.
What’s painful:
- Testcontainers startup time. The first test in an integration test run takes 5-8 seconds to start the PostgreSQL container. I run integration tests separately from unit tests to keep the feedback loop fast for domain changes.
- Mocking
IApplicationDbContextfor complex queries is tedious. I keep going back and forth on whether to mock it or just use a real database for query handler tests. Currently I mock for simple lookups and use Testcontainers for anything involving LINQ translation. - Test data management. Even with Bogus fakers, setting up the right state for integration tests involves a lot of boilerplate. I’ve started using a
TestDataBuilderthat chains fluently, but it’s still verbose. - Docker requirement. Testcontainers needs Docker running. Some developers on the team use machines where Docker isn’t always available. I added a
[DockerAvailable]trait to skip integration tests gracefully when Docker isn’t running, but it means those developers don’t get integration test feedback locally.
The numbers:
- 210 total tests
- Domain tests: ~120, run in <1 second
- Application tests: ~40, run in <2 seconds
- Integration tests: ~25, run in ~30 seconds (container startup included)
- API tests: ~15, run in ~20 seconds
- Architecture tests: ~12, run in <1 second
- Total CI pipeline time for all tests: ~90 seconds
Is Clean Architecture worth it for testing? Absolutely. The fact that 75% of my tests need zero infrastructure, run in under a second, and catch the majority of bugs is exactly the payoff the architecture promises. The integration tests are harder, but that’s inherent to testing database behavior — Clean Architecture doesn’t make that worse, and the clear boundaries make it easier to know which tests go where.
What’s Next
In Part 7, we’ll wrap up the series with the production chapter: deploying Kids Learn with Docker Compose, setting up health checks and structured logging with Serilog, monitoring with OpenTelemetry, and a retrospective on what I’d do differently if I started the project again. After six posts of building, it’s time to ship — and be honest about the trade-offs we made along the way.
This is Part 6 of a 7-part series on Clean Architecture with .NET 10. Part 1: The Domain Layer | Part 2: Value Objects and Rich Domain Models | Part 3: The Application Layer with MediatR | Part 4: Infrastructure with EF Core and PostgreSQL | Part 5: The Presentation Layer | Part 6: Testing (you are here) | Part 7: Production Deployment and Retrospective