DEV Community

Cover image for Getting started with integration testing for your Minimal API
Pierre Bouillon for This is Learning

Posted on • Edited on

Getting started with integration testing for your Minimal API

Minimal APIs have been around for quite some times now and have considerably reduced the amount of boilerplate code needed for some projects.

However, good practices are still to be enforced and even tho the approach on creating API might have evolved, integration testing has not disappeared.

In this post, we will see, step by step, how to bring integration testing to your ASP.NET (7) minimal API in different scenarios.


Overview

  1. Our minimal API
  2. Setup and tooling
  3. Testing a simple call
  4. Testing the content of the response
  5. Testing using custom services
  6. Using fixtures

Our minimal API

You can find the source code of this article on GitHub here

Let's start by creating our app:



# Create our solution
dotnet new sln

# Create our minimal API and add it to our solution
dotnet new webapi -minimal --no-openapi -o Api
dotnet sln add Api


Enter fullscreen mode Exit fullscreen mode

Open your solution in the editor of your choice and replace the content of the Program.cs by the following, to get rid of the boilerplate:



var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.Run();


Enter fullscreen mode Exit fullscreen mode

Creating our endpoint

Our minimal API will be dealing with TODO items.

First, let's add our model:



public record TodoItem(Guid Id, string Title, bool IsDone);


Enter fullscreen mode Exit fullscreen mode

Our service won't have much logic nor code:



public interface ITodoItemService
{
    TodoItem AddTodoItem(string title);
}

internal class TodoItemService : ITodoItemService
{
    private readonly List<TodoItem> _todoItems = new();

    public TodoItem AddTodoItem(string title)
    {
        var todoItem = new TodoItem(Guid.NewGuid(), title, false);
        _todoItems.Add(todoItem);

        return todoItem;
    }
}


Enter fullscreen mode Exit fullscreen mode

With our endpoint and the service registration, our Program.cs now looks like this:



var builder = WebApplication.CreateBuilder(args);

+ builder.Services.AddTransient<ITodoItemService, TodoItemService>();

var app = builder.Build();

+ app.MapPost(
+     "/api/todo-items", 
+     (TodoItemCreationDto dto, ITodoItemService todoItemService) 
+         => todoItemService.AddTodoItem(dto.Title));

app.Run();

+ public record TodoItemCreationDto(string Title);
+ public record TodoItem(Guid Id, string Title, bool IsDone);

+ public interface ITodoItemService { /* ... */ }
+ internal class TodoItemService : ITodoItemService { /* ... */ }


Enter fullscreen mode Exit fullscreen mode

We're all set!

Setup and tooling

Now that our API has an endpoint that can be called, we can add our integration test project.

For our example, I will use xUnit but feel free to use any testing framework you are familiar with:



dotnet new xunit -n Api.IntegrationTests
dotnet sln add Api.IntegrationTests
dotnet add Api.IntegrationTests reference Api


Enter fullscreen mode Exit fullscreen mode

We will also need to access the ASP.NET Core framework from this project. We can do so by adding the Microsoft.AspNetCore.Mvc.Testing NuGet to our project:



dotnet add Api.IntegrationTests package Microsoft.AspNetCore.Mvc.Testing


Enter fullscreen mode Exit fullscreen mode

The first change we need to make is in the Api.IntegrationTests.csproj where we need to specify that we will be using the Web SDK for this test project:



- <Project Sdk="Microsoft.NET.Sdk">
+ <Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>...</PropertyGroup>
  <ItemGroup>...</ItemGroup>

</Project>


Enter fullscreen mode Exit fullscreen mode

We're good to go!

Testing a simple call

Now that our test project is created, we would like to:

  • Initialize the application
  • Create an HttpClient to interact with it
  • Make a call to our endpoint to create a TodoItem
  • Ensure that the response is our TodoItem

Let's break this down.

Creating our test

First, let's create our test in a new test file:



namespace Api.IntegrationTests;

public class TodoItemCreationEndpointTest
{
    [Fact]
    public async Task CreatingATodoItemShouldReturnIt() { }
}


Enter fullscreen mode Exit fullscreen mode

Creating the application can be done using the WebApplicationFactory to further create a client from it:



// Arrange
var payload = new TodoItemCreationDto("My todo");
await using var application = new WebApplicationFactory<Program>();
using var client = application.CreateClient();


Enter fullscreen mode Exit fullscreen mode

Using this client, we can send the payload on our endpoints route:



// Act
var result = await client.PostAsJsonAsync("/api/todo-items", payload);


Enter fullscreen mode Exit fullscreen mode

And finally, from this response we can test its status code:



// Assert
Assert.Equal(HttpStatusCode.OK, result.StatusCode);


Enter fullscreen mode Exit fullscreen mode

Our test should now look like this:



[Fact]
public async Task CreatingATodoItemShouldReturnIt()
{
    // Arrange
    var payload = new TodoItemCreationDto("My todo");
    await using var application = new WebApplicationFactory<Program>();
    using var client = application.CreateClient();

    // Act
    var result = await client.PostAsJsonAsync("/api/todo-items", payload);

    // Assert
    Assert.Equal(HttpStatusCode.OK, result.StatusCode);
}


Enter fullscreen mode Exit fullscreen mode

Let's run this and ... everything blows up!

Failing test

Tweaking our API to access it

By default, when compiling our Program.cs file, our Program class will have the the private modifier and therefore not being accessible from our testing assembly.

In fact, the Program class we were using in our WebApplicationFactory was not ours as the intellisense shows us:

Program class intellisense

To fix that, we simply need to add one line at the end of our Program.cs file to indicate ASP.NET to generate this as a public class:



// ...
public partial class Program { }


Enter fullscreen mode Exit fullscreen mode

A quick look at the intellisense will confirm that our modification has worked and that we are now using our own Program:

Fixed Program class intellisense

And the test now passes:

Passing test

Testing the content of the response

Having the response's status code is a good thing but testing the actual content would be better.

To do so, we can simply deserialize the response:



[Fact]
public async Task CreatingATodoItemShouldReturnIt()
{
    // Arrange
    var payload = new TodoItemCreationDto("My todo");
    await using var application = new WebApplicationFactory<Program>();
    using var client = application.CreateClient();

    // Act
    var result = await client.PostAsJsonAsync("/api/todo-items", payload);
+   var content = await result.Content.ReadFromJsonAsync<TodoItem>();

    // Assert
    Assert.Equal(HttpStatusCode.OK, result.StatusCode);
+   Assert.Equal("My todo", content?.Title);
+   Assert.False(content?.IsDone);
}


Enter fullscreen mode Exit fullscreen mode

Testing using custom services

Our testing journey is going great but there is a small issue here.

For new, we are using the TodoItemService through the provided ITodoItemService and this service might rely on another service that you might not want to call (such as an email provider, a SaaS where you must pay at each call, etc.).

It would be great if we could customize the dependency injection container so that it will be using our own dependency and not the application's one.

We can do so by changing the service configuration using the WithWebHostBuilder extension method:



// 👇 Test Double for our `ITodoItemService`
internal class TestTodoItemService : ITodoItemService
{
    public TodoItem AddTodoItem(string title)
        => new(Guid.Empty, "test", false);
}

public class TodoItemCreationEndpointTest
{
    [Fact]
    public async Task CreatingATodoItemShouldReturnIt()
    {
        // Arrange
        var payload = new TodoItemCreationDto("My todo");
        await using var application = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(builder => builder.ConfigureServices(services =>
            {
                // 👇 Registration of our Test Double
                services.AddScoped<ITodoItemService, TestTodoItemService>();
            }));

        using var client = application.CreateClient();

        // Act
        var result = await client.PostAsJsonAsync("/api/todo-items", payload);
        var content = await result.Content.ReadFromJsonAsync<TodoItem>();

        // Assert
        Assert.Equal(HttpStatusCode.OK, result.StatusCode);
        // 👇 We should now be using our Test Double response
        Assert.Equal("test", content?.Title);
        Assert.False(content?.IsDone);
    }
}


Enter fullscreen mode Exit fullscreen mode

Using fixtures

For now we only have a simple test but we might want to have a more complex scenario, that is split across multiple tests.

In order to keep the same context from test to test, we can take advantage of xUnit fixtures.

Overview

There is two kind of fixtures:

  • The class fixture which is to be used "when you want to create a single test context and share it among all the tests in the class, and have it cleaned up after all the tests in the class have finished."

  • The collection fixture which is to be used "when you want to create a single test context and share it among tests in several test classes, and have it cleaned up after all the tests in the test classes have finished."

I will leave up to you the decision of using one or the other and for our irrelevant example we will use the class fixture.

Implementing the IClassFixture

The interface IClassFixture accepts up to one type parameter, which is the type of the fixture. A fixture having a type parameter must also have a constructor with one parameter of the same type.

Here is how we could write a class fixture sharing our WebApplicationFactory<Program>:



public abstract class IntegrationTestBase : IClassFixture<WebApplicationFactory<Program>>
{
    // 👇 Parameter matching the type of the `IClassFixture`
    public IntegrationTestBase(WebApplicationFactory<Program> factory) { }
}


Enter fullscreen mode Exit fullscreen mode

Let's take advantage of this behavior to share the same HttpClient across our tests:



public abstract class IntegrationTestBase : IClassFixture<WebApplicationFactory<Program>>
{
    protected readonly HttpClient Client;

    public IntegrationTestBase(WebApplicationFactory<Program> factory)
        => Client = factory
            .WithWebHostBuilder(builder => builder.ConfigureServices(services =>
            {
                services.AddScoped<ITodoItemService, TestTodoItemService>();
            }))
            .CreateClient();
}


Enter fullscreen mode Exit fullscreen mode

And finally, let's refactor our former test by using our fixture:



- public class TodoItemCreationEndpointTest
+ public class TodoItemCreationEndpointTest : IntegrationTestBase
{
+   public TodoItemCreationEndpointTest(WebApplicationFactory<Program> factory) 
+       : base(factory) { }

    [Fact]
    public async Task CreatingATodoItemShouldReturnIt()
    {
        // Arrange
        var payload = new TodoItemCreationDto("My todo");
-       await using var application = new WebApplicationFactory<Program>()
-           .WithWebHostBuilder(builder => builder.ConfigureServices(services =>
-           {
-               services.AddScoped<ITodoItemService, TestTodoItemService>();
-           }));

-       using var client = application.CreateClient();

        // Act
-       var result = await client.PostAsJsonAsync("/api/todo-items", payload);
+       var result = await Client.PostAsJsonAsync("/api/todo-items", payload);
        var content = await result.Content.ReadFromJsonAsync<TodoItem>();

        // Assert
        Assert.Equal(HttpStatusCode.OK, result.StatusCode);
        Assert.Equal("test", content?.Title);
        Assert.False(content?.IsDone);
    }
}


Enter fullscreen mode Exit fullscreen mode

And that's it!

Final words

In this post, we saw how to get started with integration testing in you ASP.NET Minimal API by creating a very simple ASP.NET Minimal API and successively:

  • Creating a test project for it, adapted to the web nature of our API
  • Querying it from an HttpClient and making assertions on the result
  • Lifting the creation of the HttpClient in a IClassFixture in order for all tests to share the same client

In real life applications, you might need some more advanced concepts (handling authentication, etc.) and, in that case, I highly encourage you checking other resources such as the official .NET documentation on this topic or this article by Twilio.

If you are working with an Entity Framework DbContext, you might also want to have a look at their documentation on how to use a test one for your fixture.

In any case, I hope that this has been a good introduction that can help you to strengthen your testing strategy and will be a nice addition to your unit tests suites.

As always, happy coding!



Photo by Hans Reniers on Unsplash

Top comments (0)