Unit Testing Clean Architecture Use Cases in .NET
Learn to unit test Clean Architecture use cases in .NET with xUnit and NSubstitute. Simple, friendly guide with diagrams, tables, and real code.
Imagine you run a small tiffin service. Every morning you take orders, cook the food, pack the boxes, and send them out. Before you serve a real customer, you want to be sure each step works. You do not cook a full meal just to check if the order-taking step is correct. You practice that one step alone, with a pretend customer and a pretend order slip.
That practice with a pretend customer is exactly what a unit test for a use case is. You take one job your app does, put a pretend helper next to it, and check that it behaves correctly. No real kitchen. No real customer. Just one step, tested carefully.
This guide shows you how to unit test use cases in a Clean Architecture .NET app. We use xUnit as the test runner and NSubstitute to make the pretend helpers. We are on .NET 10, which is the current LTS release, so everything here is fresh and ready to use.
What is a use case?
A use case is one job your app can do. Here are some examples:
- Register a new user
- Place an order
- Cancel a booking
- Send a welcome email
In Clean Architecture, each use case lives in the Application layer. It holds the business rules for that one job. It does not know about web requests. It does not know about SQL. It only knows the rule, like "a user must have a valid email before we save them."
Because the use case is pure rules, it is the easiest and most valuable thing to test.
Notice the dotted line. The Application layer does not call the database directly. It calls an interface. The real database code sits in Infrastructure and fills in that interface at runtime. This one design choice is what makes unit testing easy.
Why interfaces make testing easy
When your use case depends on an interface instead of a real class, you can swap the real thing for a pretend thing during a test. The use case never notices the difference.
Think of a power socket. Your phone charger does not care what is behind the wall. It only cares about the shape of the socket. You can plug it into your home socket or a fake test socket on a workbench. Same shape, different source.
How a use case gets its helpers
Steps
Use case
asks for IUserRepository
Interface
the agreed shape
Real class
used in production
Fake class
used in tests
A use case we will test
Let's look at a small, real use case: registering a user. The rules are simple.
- The email must not already be taken.
- If it is free, we create the user and save it.
- We return the new user's id.
Here is the code. Notice it depends on two interfaces, handed in through the constructor.
public sealed class RegisterUserHandler
{
private readonly IUserRepository _users;
private readonly IEmailSender _email;
public RegisterUserHandler(IUserRepository users, IEmailSender email)
{
_users = users;
_email = email;
}
public async Task<Result<Guid>> Handle(RegisterUserCommand cmd)
{
var existing = await _users.FindByEmail(cmd.Email);
if (existing is not null)
{
return Result<Guid>.Failure("Email already in use.");
}
var user = new User(cmd.Email, cmd.Name);
await _users.Add(user);
await _email.SendWelcome(user.Email);
return Result<Guid>.Success(user.Id);
}
}This class never names a real database. It only knows IUserRepository and IEmailSender. That is our window to test it.
Setting up the test project
First we make a test project and add the tools. Run these commands from your solution folder.
dotnet new xunit -n Application.Tests
dotnet add Application.Tests package NSubstitute
dotnet add Application.Tests package FluentAssertions
dotnet add Application.Tests reference ../Application/Application.csprojHere is what each tool does.
| Tool | Job | Why we like it |
|---|---|---|
| xUnit | Runs the tests | Clean, modern, the common choice in .NET |
| NSubstitute | Makes fake helpers | Easy to read syntax for mocks |
| FluentAssertions | Checks results | Reads like an English sentence |
A quick note on licensing, because it matters for real projects. Moq is still free, but a past telemetry incident made some teams move away. MediatR and MassTransit are now commercially licensed for larger companies. NSubstitute, xUnit, and FluentAssertions remain free and open. So the stack in this guide is safe to use without a license worry.
The shape of a good unit test
Every good unit test follows three steps. We call it AAA: Arrange, Act, Assert.
- Arrange: build the fake helpers and tell them how to behave.
- Act: call the one method you want to test.
- Assert: check that the result and the side effects are correct.
Keep each test to one behavior. If a test checks two different things, split it into two tests. Small tests are easier to read and easier to fix when they break.
Writing the first test: the happy path
The happy path is the case where everything goes right. The email is free, so the user gets created. Let's write it.
public class RegisterUserHandlerTests
{
private readonly IUserRepository _users = Substitute.For<IUserRepository>();
private readonly IEmailSender _email = Substitute.For<IEmailSender>();
[Fact]
public async Task Handle_WhenEmailIsFree_CreatesUser()
{
// Arrange
_users.FindByEmail("[email protected]")
.Returns((User?)null);
var handler = new RegisterUserHandler(_users, _email);
var cmd = new RegisterUserCommand("[email protected]", "Asha");
// Act
var result = await handler.Handle(cmd);
// Assert
result.IsSuccess.Should().BeTrue();
await _users.Received(1).Add(Arg.Any<User>());
await _email.Received(1).SendWelcome("[email protected]");
}
}Read it slowly. We told the fake repository to return null when asked for that email, which means "this email is free." Then we called Handle. Then we checked three things: the result was a success, the user was added exactly once, and the welcome email was sent exactly once.
Received(1) is the magic part. It asks the fake, "were you called exactly one time?" This lets us check side effects without a real database or a real email server.
Writing the second test: the sad path
The sad path is when a rule blocks the action. Here, the email is already taken. The use case must refuse and must not create a user.
[Fact]
public async Task Handle_WhenEmailTaken_Fails()
{
// Arrange
var taken = new User("[email protected]", "Asha");
_users.FindByEmail("[email protected]").Returns(taken);
var handler = new RegisterUserHandler(_users, _email);
var cmd = new RegisterUserCommand("[email protected]", "Asha");
// Act
var result = await handler.Handle(cmd);
// Assert
result.IsSuccess.Should().BeFalse();
await _users.DidNotReceive().Add(Arg.Any<User>());
await _email.DidNotReceive().SendWelcome(Arg.Any<string>());
}This time we made the fake return an existing user. We expect a failure, and we expect that nothing was saved and no email was sent. DidNotReceive() is the opposite of Received(). Together these two methods let you prove what your code did and did not do.
State testing vs behavior testing
There are two things you can check in a test. It helps to know the difference.
| Style | What it checks | Example |
|---|---|---|
| State testing | The value that comes back | result.IsSuccess is true |
| Behavior testing | The calls that were made | Add was called once |
For use cases, you usually need both. The returned value tells you the answer. The recorded calls tell you the work was done. A test that checks only the return value can miss a missing save. A test that checks only the calls can miss a wrong answer.
What to test and what to skip
You cannot test everything, and you should not try. Focus your energy where the value is. Use cases hold business rules, so they earn the most test attention.
Where to put your testing effort
Steps
Business rules
test hard
Edge cases
test hard
Plain getters
skip mostly
Framework code
trust it
Test the things that make a decision: calculations, validations, branches, and the "if this then that" rules. Skip plain property getters and setters. Do not test the .NET framework or the database driver. Those are already tested by the people who built them.
Handle async correctly
Most use cases are async. Two small habits keep async tests safe.
First, make your test method async Task, never async void. An async void test can finish before the work is done, and a failure may slip past unseen.
Second, always await the call you are testing. If you forget the await, the test may pass even when the real code throws an error. Here is the safe shape.
[Fact]
public async Task Handle_SavesUser_Async()
{
_users.FindByEmail(Arg.Any<string>()).Returns((User?)null);
var handler = new RegisterUserHandler(_users, _email);
var result = await handler.Handle(
new RegisterUserCommand("[email protected]", "Ravi"));
result.IsSuccess.Should().BeTrue();
await _users.Received(1).Add(Arg.Any<User>());
}The flow below shows what happens behind the scenes when this test runs.
Common mistakes to avoid
New testers often slip on the same few things. Keep this list near you.
| Mistake | Why it hurts | Fix |
|---|---|---|
| Testing private methods | They change often, tests break a lot | Test through the public method |
| One giant test | Hard to read, hard to fix | One behavior per test |
| Real database in a unit test | Slow and flaky | Use a fake through the interface |
Forgetting await | Hidden failures pass | Always await the call |
| Checking too many things | Unclear what broke | Keep asserts focused |
Another trap is testing the mock instead of your code. If your test only proves that the fake returned what you told it to return, you tested nothing useful. Always make sure the real assertion is about your use case's decision.
A quick word on naming
Good test names save you time later. A clear name tells you what broke without opening the file. A simple pattern works well: Method_Condition_Result. For example, Handle_WhenEmailTaken_Fails. When that test goes red, the name alone tells the whole story.
Putting it all together
Here is the full loop you follow for each use case. Pick a use case. List its rules. Write one test per rule, both the happy and the sad path. Use fakes for every interface. Check both the result and the calls. Keep each test small.
Do this for every use case, and your Application layer becomes a safe place to change. You can refactor, rename, and improve, and the tests will catch any rule you break by accident. That is the real payoff: not the tests themselves, but the courage they give you to change code without fear.
References and further reading
- Unit Testing Clean Architecture Use Cases — Milan Jovanovic
- NSubstitute official documentation
- xUnit getting started guide
- Effective Testing Strategies for Clean Architecture in .NET 10 — Tech Edu Byte
- A practical guide to unit testing in Clean Architecture — Raisa Tech
Quick recap
- A use case is one job your app does, and it lives in the Application layer.
- Use cases depend on interfaces, so you can swap real helpers for fakes in tests.
- Use xUnit to run tests and NSubstitute to build the fakes. Both are free and safe to use.
- Follow AAA: Arrange the fakes, Act by calling the method, Assert the result and the calls.
- Write a happy path test and a sad path test for every rule.
- Use
Received(1)to prove a call happened andDidNotReceive()to prove it did not. - Always make async tests
async Taskand alwaysawaitthe call. - Test business rules and edge cases. Skip plain getters and framework code.
- Name tests clearly with
Method_Condition_Resultso a red test tells its own story.
Related Posts
Build a Clean Architecture .NET App: A Hands-On PlaceOrder Tutorial
Build a Clean Architecture .NET 10 app from an empty solution to a working POST /orders minimal API. Four projects, one use case, EF Core, step by step.
Testcontainers: Integration Testing Using Docker in .NET
A friendly .NET 10 guide to Testcontainers: spin up real databases in Docker for trustworthy integration tests, with xUnit, WebApplicationFactory, and clean cleanup.
Creating Data-Driven Tests With xUnit (.NET 10)
A friendly .NET 10 guide to data-driven tests in xUnit: Theory, InlineData, MemberData, ClassData, strongly typed TheoryData, and xUnit v3 tips.
Testcontainers Best Practices for .NET Integration Testing (.NET 10)
A friendly .NET 10 guide to Testcontainers: real databases in Docker, shared container fixtures, clean test isolation, faster CI, and the mistakes to avoid.
How to Test API Integrations Using WireMock.Net in .NET 10
A beginner-friendly .NET 10 guide to testing API integrations with WireMock.Net: stub HTTP responses, simulate errors and delays, and write reliable tests.
ASP.NET Core Integration Testing Best Practices (.NET 10)
A friendly .NET 10 guide to ASP.NET Core integration testing: WebApplicationFactory, real databases with Testcontainers, clean test isolation, and CI tips.