Verdict
Yes
Test each business rule in a CQRS command handler by sending the command through the mediator, with an in-memory fake repository or a test database, and asserting the saved state; do not write handler tests that only check which mocked methods the handler called.
Why
- Blast radiususers
- Change frequencyregularly
- Detectabilityeventually
- Reversibilitywith-effort
- Test costmoderate
Test CQRS command handlers, because the write-side business rules run there. The typical case is a MediatR command handler in an ASP.NET Core API that loads an aggregate, applies a rule such as a pickup cutoff, and saves. Blast radius is users and Change frequency is regularly, because customers see a broken rule and handlers change with most features. Detectability is eventually, because a broken rule saves a plausible state without an error. Reversibility is with-effort, because wrong records need a repair script, and Test cost is moderate, because each test needs a fake repository or a test database.
| When | Decision | Why |
|---|---|---|
| The handler is a query handler that loads one read-model row by its ID and maps it to a DTO | Test minimally: one test against the production database engine | Reversibility falls to trivial, because a lookup by ID stores nothing |
| The command handler charges a card or issues a refund | Test mandatory: the main path, every known failure and the boundary amounts | Blast radius rises to money and Reversibility to costly |
| A query handler must filter records by tenant | Test mandatory: seed another tenant's records and assert that none come back | Blast radius rises to safety-or-legal and Detectability to never, because a missing filter raises no error |
| The command handler loads one aggregate, calls one aggregate method that has its own unit tests, and saves, and an endpoint test sends a distinct value in each command field and reads the saved record back | Do not unit test the command handler; the endpoint test covers it | Change frequency falls to rarely, Detectability to immediately and Reversibility to trivial, because the endpoint test fails in CI when the save is missing or a value lands in the wrong field |
| Only staff send the command, from an internal back-office tool | Test minimally: one main-path test per rule | Blast radius falls to internal and Detectability to same-day, because staff report wrong results that day |
| HTTP-level tests in CI already send each command with data that tells the outcomes apart | Do not add command handler tests; the HTTP-level tests cover the rules | Detectability moves to immediately and Reversibility to trivial, because a broken rule fails CI before merge |
| A query handler filters read-model rows by status or date range | Test: one database test per filter, with rows that only the right filter leaves out | Reversibility stays with-effort, unlike a lookup by ID: code acts on the plausible rows a wrong filter returns |
What breaks if you don't test
A reversed date comparison in a command handler books a pickup the carrier cannot serve, the endpoint returns 200, and alerts stay quiet. A customer asks about the parcel days later, and every booking made in between needs repair.
What you lose if you over-test
The common over-test mocks the repository and the event publisher, then verifies that Handle() called each one. Such a test breaks when you rename a repository method and checks no rule. A query handler tested against a mocked DbSet runs its LINQ in memory, which can compare strings case-sensitively where SQL Server does not (EF Core testing guidance).
How to test
Test each rule of a command handler through the mediator: resolve IMediator from the container that WebApplicationFactory builds, replace the repository with an in-memory fake or a test database, send the command, and assert the saved state. A command sent through the mediator also runs every pipeline behavior, so one rejected command per validator proves it is registered. Test query handlers against the production database engine: one test for a lookup by ID, one per filter. Keep one HTTP-level test per endpoint for routing and binding.
When the answer changes
- The handler moves money or decides which records the caller may see.
- HTTP-level tests in CI send every command with a distinct value in each field and read the saved record back.
Real incident + Code example
The validator that stopped running
On a logistics API I worked on, our MediatR ValidationBehavior ran each command's registered validators and passed the command on when none were. A refactor moved the shipment validators into a new project, while startup still scanned only the old one with AddValidatorsFromAssemblyContaining<CreateOrderValidator>(). The validator tests built the validator with new, the handler tests called Handle() directly, and both stayed green. One partner's integration sent the order date as the pickup date, so for six days the API accepted past pickups and the carrier's nightly import rejected 140 bookings, which support rebooked by hand. The regression test goes through the real container:
public class ScheduleShipmentPipelineTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly IServiceScope _scope;
public ScheduleShipmentPipelineTests(WebApplicationFactory<Program> factory)
=> _scope = factory.Services.CreateScope();
[Fact]
public async Task RejectsAPickupDateInThePast()
{
// Same container and pipeline behaviors as a request from the controller
var mediator = _scope.ServiceProvider.GetRequiredService<IMediator>();
var command = new ScheduleShipmentCommand(OrderId: 42, PickupDate: new DateOnly(2026, 1, 5));
await Assert.ThrowsAsync<ValidationException>(() => mediator.Send(command));
}
}
If the scan misses the validator again, this test fails.
Related questions
FAQ
- Should I test pure CQRS commands?
No, a command object that only carries data needs no test of its own. Test its validator and its handler by sending the command through the mediator.
- Is unit testing the thin read layer necessary?
No, a unit test with a mocked database misses what breaks in a thin read layer: the query and the mapping. Test each query handler against the production database engine instead: one test for a lookup by ID, one test per filter for a handler that filters rows.
- Should I test CQRS handlers or the whole request through the Web API?
Test the rules of a CQRS handler through the mediator, and give each endpoint one HTTP-level test for routing, binding and the status code.
- Should I mock the repository in a command handler test?
Yes, replace the repository in a command handler test with an in-memory fake that stores real aggregates, and assert the saved state.