|
Unit Testing Clean Architecture Use Cases
Read on: my website / Read time: 7 minutes
|
|
|
|
The .NET Weekly is brought to you by:
Introducing Depot Cache, the powerful way to make incremental builds up to 10x faster with tools like Bazel, Turborepo, Gradle, Pants, and sccache. Run it anywhere—from CI/CD pipelines to local development—and say goodbye to rebuilding from scratch. To celebrate the launch, Depot is offering your first month on any plan completely free—sign up here.
|
|
|
|
Writing tests is a crucial part of my daily work. Over the years, I've learned that good tests can make or break a project.
One project I worked on remains the best example of this. It was a large, complex system with many moving parts. We had a requirement that code coverage must be above 90%.
Code coverage doesn't directly translate to good tests, but it's a good starting point. It's up to you to write quality tests that cover the most critical parts of your system.
Today, I want to share my approach to testing Clean Architecture use cases in .NET.
|
|
|
Why Testing Matters
I've seen many projects fail because of poor testing practices. The codebase grows, changes become risky, and developers lose confidence in their deployments. This is especially true for Clean Architecture projects, where we need to ensure our use cases work correctly.
Testing isn't just about catching bugs. It's about having confidence in your code. When I make changes, I want to know immediately if I've broken something. Good tests give me that confidence.
|
|
|
Understanding Different Testing Approaches
Before diving into the specific examples, let's talk about testing types. In my experience, there are three main types of tests you'll write:
- Unit tests focus on testing individual components in isolation. They're fast, reliable, and help you catch issues early. I write these tests first, and they make up the majority of my test suite.
- Integration tests verify that different components work together correctly. They're slower but essential for testing database operations or external services.
- End-to-end tests check the entire system flow. They're the slowest but provide confidence that everything works together.
For this article, we'll focus on unit tests. They're the foundation of a solid test suite and the most common type you'll write.
|
|
|
Breaking Down Our Use Case
Looking at our ReserveBookingCommandHandler class, we have a typical Clean Architecture use case. It handles apartment booking reservations with several business rules:
- The apartment must exist
- The booking dates must not overlap with existing bookings
- A new booking should be created if all checks pass
This is a perfect example for unit testing because it has clear inputs, outputs, and dependencies we can mock.
internal sealed class ReserveBookingCommandHandler(
IApartmentRepository apartmentRepository,
IBookingRepository bookingRepository,
IDateTimeProvider dateTimeProvider) : ICommandHandler<ReserveBookingCommand, Guid>
{
public async Task<Result<Guid>> Handle(
ReserveBookingCommand request,
CancellationToken cancellationToken)
{
var apartment = await apartmentRepository.GetByIdAsync(request.ApartmentId, cancellationToken);
if (apartment is null)
{
return Result.Failure<Guid>(ApartmentErrors.NotFound);
}
var duration = DateRange.Create(request.StartDate, request.EndDate);
if (await bookingRepository.IsOverlappingAsync(apartment, duration, cancellationToken))
{
return Result.Failure<Guid>(BookingErrors.Overlap);
}
var booking = Booking.Create(
apartment,
duration,
dateTimeProvider.UtcNow);
bookingRepository.Add(booking);
return booking.Id;
}
}
|
|
|
Setting Up Our Test Environment
The test class setup shows the standard approach I use for all my handler tests. Let's break it down:
public class ReserveBookingCommandHandlerTests
{
private readonly ReserveBookingCommandHandler _handler;
private readonly IApartmentRepository _apartmentRepository;
private readonly IBookingRepository _bookingRepository;
private readonly IDateTimeProvider _dateTimeProvider;
private static readonly Guid ApartmentId = Guid.NewGuid();
private static readonly DateTime UtcNow = DateTime.UtcNow;
public ReserveBookingCommandHandlerTests()
{
_apartmentRepository = Substitute.For<IApartmentRepository>();
_bookingRepository = Substitute.For<IBookingRepository>();
_dateTimeProvider = Substitute.For<IDateTimeProvider>();
_dateTimeProvider.UtcNow.Returns(UtcNow);
_handler = new ReserveBookingCommandHandler(
_apartmentRepository,
_bookingRepository,
_dateTimeProvider);
}
}
I'm using NSubstitute to create mocks of our dependencies. Each test starts with fresh mocks, preventing test interference. The static fields provide consistent values across all tests.
Notice how I mock IDateTimeProvider . This is crucial for testing time-dependent code. Never use DateTime.UtcNow directly in your production code - it makes testing much harder.
|
|
|
Testing the Not Found Scenario
Our first test verifies the behavior when an apartment doesn't exist:
[Fact]
public async Task Handle_WhenApartmentDoesNotExist_ShouldReturnNotFoundError()
{
var command = new ReserveBookingCommand(
ApartmentId,
new DateOnly(2024, 1, 1),
new DateOnly(2024, 1, 5));
_apartmentRepository.GetByIdAsync(ApartmentId, Arg.Any<CancellationToken>())
.Returns((Apartment?)null);
var result = await _handler.Handle(command, default);
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(ApartmentErrors.NotFound);
}
This test follows the Arrange-Act-Assert pattern:
- Arrange: Set up the command and mock the repository to return
null
- Act: Call the handler
- Assert: Verify we get the correct error
I use FluentAssertions because it provides clear, readable assertions and better error messages than the standard Assert class.
|
|
|
Handling Booking Conflicts
The overlap test ensures we can't double-book apartments:
[Fact]
public async Task Handle_WhenBookingOverlaps_ShouldReturnOverlapError()
{
var command = new ReserveBookingCommand(
ApartmentId,
new DateOnly(2024, 1, 1),
new DateOnly(2024, 1, 5));
var apartment = new Apartment { Id = ApartmentId };
_apartmentRepository.GetByIdAsync(ApartmentId, Arg.Any<CancellationToken>())
.Returns(apartment);
_bookingRepository.IsOverlappingAsync(apartment, Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
.Returns(true);
var result = await _handler.Handle(command, default);
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(BookingErrors.Overlap);
}
Here, we verify the overlap check works correctly. Notice how we:
- Mock the apartment repository to return a valid apartment
- Mock the booking repository to indicate an overlap
- Verify we get the overlap error
|
|
|
Testing Successful Bookings
The happy path test ensures everything works when all conditions are met:
[Fact]
public async Task Handle_WhenValidRequest_ShouldCreateBooking()
{
var command = new ReserveBookingCommand(
ApartmentId,
new DateOnly(2024, 1, 1),
new DateOnly(2024, 1, 5));
var apartment = new Apartment { Id = ApartmentId };
_apartmentRepository.GetByIdAsync(ApartmentId, Arg.Any<CancellationToken>())
.Returns(apartment);
_bookingRepository.IsOverlappingAsync(apartment, Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
.Returns(false);
var result = await _handler.Handle(command, default);
result.IsSuccess.Should().BeTrue();
await _bookingRepository.Received(1)
.Add(Arg.Is<Booking>(b =>
b.Id == result.Value &&
b.ApartmentId == ApartmentId));
}
This test is more complex because we need to:
- Set up multiple mocks
- Verify the success result
- Check that the booking was added with correct properties
NSubstitute's Received() method lets us verify the Add method was called exactly once with the right booking.
|
|
|
Verifying Exception Handling
Testing exception scenarios is crucial for robust code:
[Fact]
public async Task Handle_WhenRepositoryThrowsOverlapException_ShouldPropagateException()
{
var command = new ReserveBookingCommand(
ApartmentId,
new DateOnly(2024, 1, 1),
new DateOnly(2024, 1, 5));
var apartment = new Apartment { Id = ApartmentId };
_apartmentRepository.GetByIdAsync(ApartmentId, Arg.Any<CancellationToken>())
.Returns(apartment);
_bookingRepository.IsOverlappingAsync(apartment, Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
.Throws<BookingOverlapException>();
var act = () => _handler.Handle(command, default);
await act.Should().ThrowAsync<BookingOverlapException>();
}
This test ensures exceptions propagate correctly. We:
- Set up the scenario
- Make the repository throw an exception
- Verify the exception bubbles up
FluentAssertions makes testing async exceptions clean and readable.
|
|
|
Understanding Test Coverage Limitations
When we look back at our booking overlap test, there's an important distinction to make. Our unit test verifies that our command handler behaves correctly when the booking repository reports an overlap. However, it doesn't verify that the overlap detection logic itself works correctly.
Consider what we're actually testing:
_bookingRepository.IsOverlappingAsync(apartment, Arg.Any<DateRange>(), Arg.Any<CancellationToken>())
.Returns(true);
We're simply telling our mock repository to return true . This gives us confidence that our command handler correctly handles the overlap scenario, but it does not tell us whether our actual overlap detection logic works correctly.
This is where integration tests become essential. An integration test for this scenario would:
- Insert real bookings into a test database
- Attempt to create overlapping bookings
- Verify that the overlap detection works with real data
The combination of unit and integration tests provides complete coverage:
- Unit tests verify the business logic flow
- Integration tests verify the actual overlap detection logic
This example highlights why we need different types of tests. Unit tests are excellent for verifying behavior and logic flows, but they can't verify the correctness of complex business rules that depend on real data interactions.
|
|
|
Summary
Unit testing Clean Architecture use cases requires careful thought about dependencies and behavior. Here are the key points:
- Mock all external dependencies
- Test both success and failure scenarios
- Verify exception handling logic
- Use descriptive test names
- Follow the Arrange-Act-Assert pattern
Good tests act as documentation. They show how the code should behave and catch issues before they reach production. Invest time in writing good tests - your future self will thank you.
Want to dive deeper into testing Clean Architecture applications? I cover this and much more in my Pragmatic Clean Architecture course. You'll learn how to write effective unit tests, integration tests, and end-to-end tests that give you real confidence in your system.
Writing quality tests is a skill that improves with practice and understanding. Take the time to write them well.
That's all for today.
See you next week.
|
|
|
Whenever you're ready, there are 3 ways I can help you:
|
|
Pragmatic Clean Architecture: This comprehensive course will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture. Join 3,600+ engineers |
The REST APIs course will launch next month!
REST APIs in ASP .NET Core: You will learn how to build production-ready REST APIs using the latest ASP .NET Core features and best practices. Join the waitlist to get a special launch discount.
|
|
|
You received this email because you subscribed to our list. You can unsubscribe at any time.
Update your profile | Dragiše Cvetkovića 2, Niš, - 18000
|
|
|
|
|