Skip to main content
SEMastery
Testingintermediate

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.

11 min readUpdated December 3, 2025

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.

The layers of a Clean Architecture app, from the outside in

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

Use case
Interface
Real class
Fake class

Steps

1

Use case

asks for IUserRepository

2

Interface

the agreed shape

3

Real class

used in production

4

Fake class

used in tests

Dependencies arrive through the constructor, so we can swap them

A use case we will test

Let's look at a small, real use case: registering a user. The rules are simple.

  1. The email must not already be taken.
  2. If it is free, we create the user and save it.
  3. 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.csproj

Here is what each tool does.

ToolJobWhy we like it
xUnitRuns the testsClean, modern, the common choice in .NET
NSubstituteMakes fake helpersEasy to read syntax for mocks
FluentAssertionsChecks resultsReads 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.

The three steps every unit test follows
  • 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.

StyleWhat it checksExample
State testingThe value that comes backresult.IsSuccess is true
Behavior testingThe calls that were madeAdd 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.

Two ways to verify a use case did its job

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

Business rules
Edge cases
Plain getters
Framework code

Steps

1

Business rules

test hard

2

Edge cases

test hard

3

Plain getters

skip mostly

4

Framework code

trust it

Aim tests at decisions, not at plumbing

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.

What happens when an async use case test runs

Common mistakes to avoid

New testers often slip on the same few things. Keep this list near you.

MistakeWhy it hurtsFix
Testing private methodsThey change often, tests break a lotTest through the public method
One giant testHard to read, hard to fixOne behavior per test
Real database in a unit testSlow and flakyUse a fake through the interface
Forgetting awaitHidden failures passAlways await the call
Checking too many thingsUnclear what brokeKeep 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

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 and DidNotReceive() to prove it did not.
  • Always make async tests async Task and always await the call.
  • Test business rules and edge cases. Skip plain getters and framework code.
  • Name tests clearly with Method_Condition_Result so a red test tells its own story.

Related Posts