MediatR: How to Quickly Test Your Handlers with Unit Tests

Pierre Belin
Pierre Belin
MediatR: How to Quickly Test Your Handlers with Unit Tests
Table of Contents
Table of Contents

Ensuring code quality and reliability is crucial in software development. MediatR, a popular library for the in-process request/response pattern, simplifies component communication but requires thorough testing. Unit testing with MediatR offers a swift way to validate development, and contrary to popular belief, it speeds up development by detecting bugs early.

This article will guide you through quickly and efficiently writing unit tests for MediatR handlers. By understanding the importance of testing MediatR and how it helps catch issues early, you'll contribute to a more robust and maintainable codebase.

What should test a unit test?

To understand how to quickly test a handler, let's take the GoatQueryHandler example from the previous article.

public class GoatQueryHandler(IGoatRepository goatRepository) : IResultRequestHandler<GetGoatQuery, GetGoatResponse>
{
    public async Task<Result<InternalError, GetGoatResponse>> Handle(GetGoatQuery request, CancellationToken cancellationToken)
    {
        var goat = await goatRepository.GetById(request.Id, cancellationToken);
        if (goat is null)
            return new NotFoundError(request.Id);
        
        return new GetGoatResponse();
    }
}

If you haven't read it yet, read it first to understand how to create the IResultRequestHandler interface and use the Result pattern.

Improving Error Handling with the Result Pattern in MediatR
The integration of the Result pattern in MediatR is a sophisticated technique that enhances error handling and operational feedback in applications utilizing the MediatR library for in-process messaging. If you read articles about MediatR and its integration into .NET, you’ve probably already seen a…

The basic mistake when building a test would be to create a test that builds a GoatQueryHandler and calls the Handle method to check that it's working properly.

Why is this a mistake?

By doing so, we get into the implementation details of the GoatQueryHandler by instantiating the class. The point is not to test the handler, but the mediator's behavior when it receives a GetGoatQuery message. This nuance completely changes the way tests are built.

Of course, when testing the Handle method, you might think that the behavior is identical, but it's not!

But why?

There's one point we haven't yet covered in the articles, and that's IPipelineBehavior.

An IPipelineBehavior adds behavior between the moment a message is sent to the mediator and the moment it returns a response. This behavior can have an impact on the handling of a message, in particular by checking in our example that the Id of GetGoatQuery is greater than 0.

If we pass directly into the GoatQueryHandler, we could create a GetGoatQuery with Id equal to 0, which would produce a result, whereas it is not possible to obtain this case with the entire pipeline. Tests will completely miss this logic, which can lead to a mismatch between test results and real behavior. And that's unthinkable.

To build an effective unit test on a handler, you need to wire up the mediator. In this way, the test is not dependent on the implementation, but corresponds to end-to-end behavior. This is the correct definition of a unit test, i.e. a test for a behavior and not for a class.

The aim of our test is to build a different GetGoatQuery and see what response is returned by the GoatQueryHandler.

To do this, we need to test this line: var response = await mediator.Send(query);

Building the test

The first step is to register the handler to ensure that it is available on the mediator.

var services = new ServiceCollection();
var serviceProvider = services
    .AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(GoatQueryHandler).Assembly))
    .BuildServiceProvider();   

And that's all it takes to build the first test to check if the Id doesn't exist.

[Fact]
public async Task ShouldReturnNotFound_WhenGoatIdDoesNotExist()
{
    // Arrange
    var services = new ServiceCollection();
    var serviceProvider = services
        .AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(GoatQueryHandler).Assembly))
        .BuildServiceProvider();    
    
    var mediator = serviceProvider.GetRequiredService<IMediator>();

    var query = new GetGoatQuery(1);

    // Act
    var response = await mediator.Send(query);

    // Assert
    // TODO: Validate the Result
}

The assertion is not complex to define, and for this we need to take the return from the handler: Result<InternalError, GetGoatResponse>.

If the Id doesn't exist, the response must be of type NotFoundError, which derives from the basic InternalError class. In the Result class, this is a failure that is checked in this way:

[Fact]
public async Task ShouldReturnNotFound_WhenGoatIdDoesNotExist()
{
    ...
    // Assert
    response.IsSuccess.Should().BeFalse();
    response.Error.Should().BeOfType<NotFoundError>();
}

Inject external components

A final step is missing before the test is functional, which can be checked by running it. The GoatQueryHandler class injection declaration is missing.

System.InvalidOperationException
Unable to resolve service for type 'GoatReview.MediatR.IGoatRepository' while attempting to activate 'GoatReview.MediatR.GoatQueryHandler'.

Indeed, the repository required in the handler has not been declared in the ServiceProvider, which prevents the class from being built.

The mistake would be to define the real implementation with all that goes with it, i.e. the classes relating to a database context. As the test is a unit test, it must not contain any references to components external to the application, such as databases, caching systems, messaging queues, etc.

To solve this problem, we'll need to use a double.

Image from Blog Arolla : https://www.arolla.fr/blog/2020/05/test-doubles/#Type_de_test_doubles
Image from Blog Arolla : https://www.arolla.fr/blog/2020/05/test-doubles/#Type_de_test_doubles

A double is a class that imitates the behavior of a real class to reproduce the cases we need for our tests. It is mainly used on external components to avoid having to integrate this external logic into unit tests that validate internal logic.

⚠️
The article won't go into the nuances between each type of double, to avoid straying from the subject. The rest of the article will use the notion of Mock to represent all double types.

In our case, the IGoatRepository interface represents data access to an existing database to retrieve application data that could be implemented in this way.

public class GoatRepository : IGoatRepository
{
    private readonly DbContext _dbContext;

    public GoatRepository(DbContext dbContext)
    {
        _dbContext = dbContext;
    }
    public Task<MediatR.Goat?> GetById(int goatId, CancellationToken cancellationToken)
    {
        return _dbContext.Goats.FirstOfDefault(g => g.Id == goatId);
    }
}

The question might be: why don't we mock the DbContext instead?

If you're asking this question, which I'm sure you've never tried, it's just so complicated to do... And above all, it's external to our handler.

It may be counter-intuitive, but we couldn't care less about the way a repository's logic returns data. What we want to test are the repository's return cases, not its operation, which will be tested in other tests such as component tests.

To test the case where the GetById function of the IGoatRepository returns a null value, simply implement it by forcing the return to null.

public class MockGoatRepository : IGoatRepository
{
    public Task<Goat?> GetById(int goatId, CancellationToken cancellationToken)
    {
        return Task.FromResult<Goat?>(null);
    }
}

All that remains is to modify the dependency injection to integrate the mock.

var serviceProvider = services
    .AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(GoatQueryHandler).Assembly))
    .AddScoped<IGoatRepository, MockGoatRepository>()
    .BuildServiceProvider();    

The complete final test goes green, which is exactly what we wanted to validate.

[Fact]
public async Task ShouldReturnNotFound_WhenGoatIdDoesNotExist()
{
    var services = new ServiceCollection();
    var serviceProvider = services
        .AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(GoatQueryHandler).Assembly))
        .AddScoped<IGoatRepository, MockGoatRepository>()
        .BuildServiceProvider();    
    
    var mediator = serviceProvider.GetRequiredService<IMediator>();

    var query = new GetGoatQuery(1);
    var response = await mediator.Send(query);

    response.IsSuccess.Should().BeFalse();
    response.Error.Should().BeOfType<NotFoundError>();
}

Integrating a fixture

Now that the failure test is working, all that remains is to test the case where the goat exists in the database and returns a GetGoatResponse, which is as follows:

[Fact]
public async Task ShouldReturnResponse_WhenGoatIdExists()
{
    var services = new ServiceCollection();
    var serviceProvider = services
        .AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(GoatQueryHandler).Assembly))
        .AddScoped<IGoatRepository, MockGoatRepository>()
        .BuildServiceProvider();    
    
    var mediator = serviceProvider.GetRequiredService<IMediator>();
    var goatId = 1;
    var query = new GetGoatQuery(goatId);
    var response = await mediator.Send(query);

    response.IsSuccess.Should().BeTrue();
    response.Value.Should().BeOfType<GetGoatResponse>();
    var result = response.Value.As<GetGoatResponse>();
    result.Id.Should.Be(goatId);
}

We can quickly see that we're duplicating the creation of the ServiceProvider, which could be pooled in a GoatFixture class.

public class GoatFixture
{
    public async Task<Result<InternalError, GetGoatResponse>> Send(GetGoatQuery query)
    {
        var services = new ServiceCollection();
        var serviceProvider = services
            .AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(GoatQueryHandler).Assembly))
            .AddScoped<IGoatRepository, MockGoatRepository>()
            .BuildServiceProvider();    
        
        var mediator = serviceProvider.GetRequiredService<IMediator>();
        var response = await mediator.Send(query);
        return response;
    }
}

The fixture could be called directly in the constructor of the MediatRTests class for use in all tests. The xUnit lifecycle calls the constructor before each test, ensuring that the ServiceProvider is rebuilt for all tests.

public class MediatRTests
{
    private readonly GoatFixture _fixture;
    public MediatRTests()
    {
        _fixture = new GoatFixture();
    }
    
    [Fact]
    public async Task ShouldReturnNotFound_WhenGoatIdDoesNotExist()
    {
        var query = new GetGoatQuery(1);
        var response = await _fixture.Send(query);

        response.IsSuccess.Should().BeFalse();
        response.Error.Should().BeOfType<NotFoundError>();
    }
    
    [Fact]
    public async Task ShouldReturnResponse_WhenGoatIdExists()
    {
        var goatId = 1;
        var query = new GetGoatQuery(goatId);
        var response = await _fixture.Send(query);

        response.IsSuccess.Should().BeTrue();
        response.Value.Should().BeOfType<GetGoatResponse>();
        var result = response.Value.As<GetGoatResponse>();
        result.Id.Should.Be(goatId);
    }
}

However, the ShouldReturnResponse_WhenGoatIdExists test still produces an error Expected response.IsSuccess to be true, but found False, which is totally normal, as the valid test still uses MockGoatRepository which returns null instead of a Goat.

To solve this problem, there are two ways of improving the mock.

Either create a new mock to handle this case, i.e. return a Goat object.

public class ExistingGoatRepository(int goatId) : IGoatRepository
{
    public Task<Goat?> GetById(int goatId, CancellationToken cancellationToken)
    {
        return Task.FromResult<Goat?>(new Goat(goatId, "GoatName"));
    }
}

Or we decide to enhance the mock to handle both cases easily by forcing the test caller to fill the Goat object in the MockGoatRepository to produce all possible Goat types including the null value.

public class MockGoatRepository : IGoatRepository
{
    public Goat? Goat = null;
    public Task<Goat?> GetById(int goatId, CancellationToken cancellationToken)
    {
        return Task.FromResult<Goat?>(Goat);
    }
}

Both methods have their advantages and disadvantages, so you can decide whether to use one or the other.

The first solution allows you to have a class label that explains the exact behavior of the mock, adding to its readability. What's more, it also makes it possible to handle cases where throw exceptions is required. On the other hand, it requires several implementations of an interface, generally to use a method, which is restrictive when a repository interface contains several functions.

The second solution allows you to have a single, more substantial implementation. It will contain several properties corresponding to the objects to be returned by the repository, in addition to having to declare them in the test.

Assuming we use the first solution, we end up with 2 mock integrations.

public class NullGoatRepository : IGoatRepository // old MockGoatRepository with explicit name
{
    public Task<Goat?> GetById(int goatId, CancellationToken cancellationToken)
    {
        return Task.FromResult<Goat?>(null);
    }
}

public class ExistingGoatRepository(int goatId) : IGoatRepository
{
    public Task<MediatR.Goat?> GetById(int goatId, CancellationToken cancellationToken)
    {
        return Task.FromResult<MediatR.Goat?>(new MediatR.Goat(goatId, "GoatName"));
    }
}

Rather than building a MockGoatRepository at the declaration of injected services, it becomes a fixture property that can be redefined according to test requirements.

public class GoatFixture
{
    private IGoatRepository _goatRepository = new NullGoatRepository();

    public async Task<Result<InternalError, GetGoatResponse>> Send(GetGoatQuery query)
    {
        var services = new ServiceCollection();
        var serviceProvider = services
            .AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(GoatQueryHandler).Assembly))
            .AddScoped<IGoatRepository>(_ => _goatRepository)
            .BuildServiceProvider();    
        
       ....
    }

    public GoatFixture WithNotExistingGoat()
    {
        _goatRepository = new NullGoatRepository();
        return this;
    }
    
    public GoatFixture WithExistingGoat(int goatId)
    {
        _goatRepository = new ExistingGoatRepository(goatId);
        return this;
    }
}

Before sending a message to the mediator, each test must define which mock it needs to validate its test.

[Fact]
public async Task ShouldReturnNotFound_WhenGoatIdDoesNotExist()
{
    ...
    var response = await _fixture
        .WithNotExistingGoat()
        .Send(query);
    ...
}

[Fact]
public async Task ShouldReturnResponse_WhenGoatIdExists()
{
    ...
    var response = await _fixture
        .WithExistingGoat(goatId)
        .Send(query);
    ...
}

And perfect, we quickly tested our 2 cases.

Conclusion

Unit testing with MediatR is a swift and efficient way to validate your development and ensure the quality of your code. By focusing on testing the mediator's behavior rather than the handler's implementation, you can create more effective tests that cover the entire pipeline, including any IPipelineBehavior. To build an effective unit test, wire up the mediator and use doubles, such as mocks, to simulate the behavior of external components without introducing external logic into your unit tests.

The article demonstrated how to create a unit test for the GoatQueryHandler, covering cases where the goat ID does not exist or exists in the database. By using a GoatFixture class, you can pool the creation of the `ServiceProvider` and easily define which mock is needed for each test. This approach allows you to quickly test various cases and ensure that your MediatR handlers are functioning as expected.

By following these guidelines and understanding the importance of unit testing with MediatR, you can contribute to a more robust and maintainable codebase, ultimately saving time and effort in the long run.



Join the conversation.

Great! Check your inbox and click the link
Great! Next, complete checkout for full access to Goat Review
Welcome back! You've successfully signed in
You've successfully subscribed to Goat Review
Success! Your account is fully activated, you now have access to all content
Success! Your billing info has been updated
Your billing was not updated