MartinDrozdik.DDD.Testing
0.11.0
dotnet add package MartinDrozdik.DDD.Testing --version 0.11.0
NuGet\Install-Package MartinDrozdik.DDD.Testing -Version 0.11.0
<PackageReference Include="MartinDrozdik.DDD.Testing" Version="0.11.0" />
<PackageVersion Include="MartinDrozdik.DDD.Testing" Version="0.11.0" />
<PackageReference Include="MartinDrozdik.DDD.Testing" />
paket add MartinDrozdik.DDD.Testing --version 0.11.0
#r "nuget: MartinDrozdik.DDD.Testing, 0.11.0"
#:package MartinDrozdik.DDD.Testing@0.11.0
#addin nuget:?package=MartinDrozdik.DDD.Testing&version=0.11.0
#tool nuget:?package=MartinDrozdik.DDD.Testing&version=0.11.0
DDD Testing Library
Common test tooling for the DDD libraries. Provides test fixtures, utilities, and helpers to make testing your DDD applications easier and more consistent. Check the demo tests for examples of how to use it in real tests.
Installation
dotnet add package xunit.v3
dotnet add package MartinDrozdik.DDD.Testing
Also check this repos' DDD Claude Code plugin for better AI code generation.
Philosophy
- Write tests, not test infrastructure - Stop copy-pasting the same
WebApplicationFactoryboilerplate across every project. - Integration tests, not unit tests - Test the whole stack with real dependencies – reveals much more bugs. Unit tests still valuable tho.
- Composition over inheritance – Avoid the giant base test class pitfall.
- Consistent with the rest of the DDD stack - Same pragmatic approach, same "use what makes sense" attitude.
- xUnit - It has more stars than NUnit lmao.
You still have to write your own tests. But at least you don't have to write the boring parts.
Quick Start
Spin up your app in memory, make requests, assert things. Zero extra ceremony:
// 1. Subclass the builder for your Program.cs (once, in your test project)
public class MyAppBuilder : TestedAppBuilder<Program>
{
public MyAppBuilder(ITestOutputHelper output) : base(output)
{
// Add more configuration here
// Or leave it empty. Nobody's judging.
}
}
// 2. Use it in your tests
public class MyTests(ITestOutputHelper output) : IDisposable
{
private readonly TestedApp<Program> _app = new MyAppBuilder(output).Build();
// Or set specific extra config for this test class
private readonly TestedApp<Program> _app = new MyAppBuilder(output)
.WithOption<TestOptions>(e => e.SomeString, value)
.WithServices(services => services.AddAppOptions<TestOptions>())
.Build();
[Fact]
public async Task Something_works()
{
var client = _app.CreateClient();
var response = await client.GetAsync("/something");
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Something_other_works()
{
// Or use the builder directly here
var app = new MyAppBuilder(output).Build();
var client = app.CreateClient();
var response = await client.GetAsync("/something");
response.EnsureSuccessStatusCode();
}
public void Dispose() => _app.Dispose();
}
TestedApp & TestedAppBuilder
The TestedApp<TProgram> wraps WebApplicationFactory<TProgram> (yes, the standard one) and adds the things you always end up adding yourself anyway:
- xUnit log output – Your logs actually show up in the test runner. revoLuTiOnAry.
- You would be surprised how many people don't do this – and then wonder why they have no logs when their tests fail.
- Test endpoint injection – Register extra endpoints at test time without touching your production app.
- Config and options overrides – Change settings for tests without affecting production defaults.
- Disposable tracking – Attach anything that needs cleanup and it gets disposed with the factory.
- Mode minor goodies
Build it with the fluent TestedAppBuilder<TProgram>:
var app = new MyAppBuilder(output)
.With(builder => builder.UseSetting("SomeSetting", "SomeValue"))
.WithOption<DatabaseOptions>(o => o.ConnectionString, "Data Source=:memory:")
.WithServices(services => services.AddSingleton<IMyService, MockMyService>())
.WithEndpoints(endpoints => endpoints.MapGet("/test-only-route", () => "hello"))
.WithDisposable(() => Console.WriteLine("cleaned up, congrats"))
.WithEnvironment(AppEnvironments.Testing)
.WithUserAndRoles("testuser", ["Admin", "User"])
.WithClaimsPrincipal(...)
.WithTestingLogger(out var testingLogger)
.Build();
Each With* call stacks – multiple configs, multiple endpoints, multiple disposables, all applied in order. Readable. Composable. Not a pyramid of constructors.
Call Dispose() when you're done.
Testing environment
By default, the tests run in the "Testing" environment. You can change that via the .WithEnvironment("Development") builder method.
This is to strictly separate your test configuration from your development configuration. You can have different appsettings files, different DI registrations, etc., for "Testing" vs "Development".
Use the AppEnvironments.Testing constant or the classic app.Environment.IsTesting() to check if you're running in the Testing environment.
Smoke Tests
Free tests that verify the basics. Because "it starts and doesn't explode" is a valid and surprisingly often-failing test.
Verify the app starts and is healthy:
public class MyAppSmokeTests(ITestOutputHelper output)
: WebApplicationSmokeTests<Program>(new MyAppBuilder(output))
{
}
Verifies that your OpenAPI document is valid JSON or YAML:
public class MyOpenApiSmokeTests(ITestOutputHelper output)
: OpenApiSmokeTests<Program>(new MyAppBuilder(output))
{
protected override IEnumerable<OpenApiEndpoint> GetOpenApiEndpoints() =>
[
new OpenApiEndpoint("/openapi/v1.json", OpenApiType.Json),
new OpenApiEndpoint("/openapi/v1.yaml", OpenApiType.Yaml),
];
}
Tests your error handling is wired up correctly:
public class MyAppErrorHandlingTests(ITestOutputHelper output)
: ErrorHandlingTests<Program>(new MyAppBuilder(output))
{
}
EF Core Context Tests
Tests that your EF Core mappings actually work against a real database. Because "the migration ran" does not mean "the query works":
public class MyDbContextTests(ITestOutputHelper testOutputHelper) : SqlDbContextIntegrationTests<MyDbContext>, IDisposable
{
private readonly TestedApp<Program> _factory =
new DemoAppBuilder(testOutputHelper).Build();
public void Dispose()
{
_factory.Dispose();
}
protected override MyDbContext GetContext()
{
return _factory.GetScopedService<MyDbContext>();
}
}
Recurring Tasks
Background loops are off by default. Every IRecurringTask registered with AddRecurringTask is removed from the app that Build() produces, so a cleanup job cannot wander into the middle of an unrelated test.
The tasks themselves stay registered, only their loops are gone. Which means you can run one on demand.
using var app = new MyAppBuilder(output).Build();
await app.RunRecurringTaskAsync<CleanupTask>(TestContext.Current.CancellationToken);
Got your own BackgroundService that isn't a recurring task? WithoutHostedService<MyWorker>() drops it the same way.
Smoke testing a scheduled job
Test that the job is registered, has a valid schedule, and resolves with all its dependencies:
public class CleanupTaskSmokeTests(ITestOutputHelper output)
: RecurringTaskSmokeTests<Program, CleanupTask>(new MyAppBuilder(output))
{
}
Outbox
Tools and smoke tests for the transactional outbox. See the web outbox README for the full engine, retries, dead-lettering, etc.
Extensions:
// One batch, in a fresh scope. Returns an OutboxBatchResult: Found, Reserved, Released, Dispatched, MayHaveMore.
var delivered = (await app.ProcessOutboxAsync<MyAppDbContext>(TestContext.Current.CancellationToken)).Dispatched;
// Same but keep going until nothing is left...
var total = await app.DrainOutboxAsync<MyAppDbContext>(TestContext.Current.CancellationToken);
// Delete delivered messages older than Retention, as the retention task would
var deleted = await app.ApplyOutboxRetentionAsync<MyAppDbContext>(TestContext.Current.CancellationToken);
All are direct invocations of IOutboxProcessor<TContext>, not the job – no scheduling, no waiting. A failing handler still becomes a retry or a dead-letter, exactly as in production.
The messages themselves are ordinary rows in your context, which is how you check that enqueueing happened.
var message = await context.Set<OutboxMessage>().AsNoTracking().SingleAsync(cancellationToken);
Assert.Equal(InvoiceDraftedMessage.MessageType, message.MessageType);
// ...
Assert.Null(message.ProcessedAt); // enqueued, not yet delivered
Smoke testing the outbox
Free tests that verify the outbox is wired up correctly and can process messages. "it compiles" does not mean "it works".
public class MyAppOutboxSmokeTests(ITestOutputHelper output)
: OutboxSmokeTests<Program, MyAppDbContext>(new MyAppBuilder(output)) { }
Every context has an outbox of its own, so write one per context passed to AddOutbox.
A handler missing a constructor dependency doesn't fail at startup. It fails at 3 AM, inside the processor, where the failure is swallowed into a retry and then a dead-letter.
It never dispatches anything to avoid oppsy moments (like sending a real email).
Blobs
Tools and smoke tests for blob storage. See the web blob README for the store, the catalogue and the sweep.
InMemoryBlobStore swaps the file store for a dictionary, so tests never touch the disk:
using var app = new MyAppBuilder(output)
.WithInMemoryBlobStore()
.Build();
Extensions:
// One sweep, in a fresh scope, exactly as the scheduled task would
var result = await app.SweepBlobsAsync(TestContext.Current.CancellationToken);
Holding your own store to the contract
BlobStoreContractTests is the suite every IBlobStore has to pass. Derive from it to prove your own store is interchangeable with the ones that ship here:
public sealed class MyBlobStoreTests : BlobStoreContractTests
{
protected override IBlobStore CreateStore(TimeProvider timeProvider) => new MyBlobStore(timeProvider, ...);
}
Smoke testing blob storage
Free tests that verify blob storage is wired up correctly. Pass every container the application stores into:
public class MyAppBlobSmokeTests(ITestOutputHelper output)
: BlobSmokeTests<Program>(new MyAppBuilder(output), InvoiceScans.Container, Avatars.Container) { }
It stores nothing, don't worry.
Asserting on logs
WithTestingLogger registers a TestLogger that keeps everything the app logs in memory, so you can assert on it. The out overload hands it to you mid-chain – no lookup, no field:
using var app = new MyAppBuilder(output)
.WithTestingLogger(out var testingLogger)
.Build();
await app.CreateClient().GetAsync("/orders/does-not-exist");
// A 404 is the client's problem, not ours – it must not show up as an error
Assert.Empty(testingLogger.AtLeast(LogLevel.Error));
Assert.Contains(testingLogger.From("MyApp"), entry => entry.Level == LogLevel.Warning);
Pass your own instance with WithTestingLogger(testLogger) when you need it before the chain – e.g. to share one logger across two apps.
The same logger works for plain unit tests – For<T>() gives you an ILogger<T> recording into it:
var testLogger = new TestLogger();
var handler = new MyHandler(testLogger.For<MyHandler>());
await handler.HandleAsync(...);
Assert.Equal(LogLevel.Warning, testLogger.Last.Level);
Assertions
EqualityAssert
Testing equality implementations is tedious and it's easy to miss edge cases. EqualityAssert covers IEquatable<T>, IEqualityComparer<T>, and equality operators (==, !=) in one shot:
[Fact]
public void InvoiceNumber_equality_is_correct()
{
var a = InvoiceNumber.Create(2024, 1);
var b = InvoiceNumber.Create(2024, 1); // same value, different instance
var c = InvoiceNumber.Create(2024, 2); // different value
EqualityAssert.TestAllEqualityBehaviors(a, b, c);
}
Or test just the parts you care about:
EqualityAssert.TestEquatable(a, b, c); // IEquatable<T>
EqualityAssert.TestEqualityComparer(a, b, c); // IEqualityComparer<T>
EqualityAssert.TestEqualityOperators(a, b, c); // == and != operators
Covers symmetry, null/default comparisons, and hash code consistency. Basically everything you'd forget to test manually.
ResultAssert
If you're using Result<T, E> or UnitResult<E> from MartinDrozdik.DDD.Results, these extension methods give you readable assertions:
[Fact]
public void CreateInvoice_returns_success()
{
var result = Invoice.Create(InvoiceNumber.Create(2024, 1));
result.IsSuccess();
}
Enumeration ↔ .NET enum mappings
An Enumeration is a domain type, so an API contract carries a plain .NET enum instead.
Test the two-way mapping via prepared suite:
public class InvoiceStateMappingTests : EnumerationStructMappingTests<InvoiceState, InvoiceStateDto>;
Demo Tests
The demo test project shows all of this wired together with a real app. Check:
- DemoAppBuilder.cs – how to subclass
TestedAppBuilder - Smoke/* – smoke tests in practice
- Errors/TestProgramErrorHandlingTests.cs – error handling tests in practice
- Contexts/InvoiceDbContextTests.cs – EF Core integration tests in practice
- Invoices/InvoiceStateMappingTests.cs – enumeration mapping tests in practice
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- MartinCostello.Logging.XUnit.v3 (>= 0.7.1)
- MartinDrozdik.DDD.Web (>= 0.11.0)
- Microsoft.AspNetCore.Mvc.Testing (>= 10.0.14)
- Microsoft.Extensions.Logging.Debug (>= 10.0.14)
- Microsoft.Extensions.TimeProvider.Testing (>= 10.11.0)
- xunit.v3.assert (>= 4.0.2)
- xunit.v3.extensibility.core (>= 4.0.2)
- YamlDotNet (>= 18.1.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.11.0 | 106 | 10/31/2026 |
| 0.10.0 | 120 | 9/15/2026 |
| 0.9.1 | 135 | 8/31/2026 |
| 0.9.0 | 142 | 8/8/2026 |
| 0.8.3 | 126 | 8/7/2026 |
| 0.8.2 | 136 | 8/7/2026 |
| 0.8.1 | 145 | 8/1/2026 |
| 0.8.0 | 137 | 8/1/2026 |
| 0.7.1.3 | 137 | 7/28/2026 |
| 0.7.1.2 | 140 | 7/26/2026 |
| 0.7.1.1 | 157 | 5/24/2026 |
| 0.7.1 | 131 | 5/17/2026 |
| 0.7.0 | 135 | 5/16/2026 |
| 0.6.0 | 140 | 5/2/2026 |
| 0.5.3.2 | 141 | 4/21/2026 |
| 0.5.3.1 | 135 | 4/21/2026 |
| 0.5.3 | 137 | 4/19/2026 |
| 0.5.1 | 148 | 4/10/2026 |
| 0.5.0 | 140 | 4/6/2026 |
| 0.1.1 | 138 | 3/21/2026 |