Guilherme Raduenz

ASP.NET Core Integration Tests

Examples in this GitHub repository.

Introduction

Testing has a huge impact on software development by helping to prevent bugs and ensure the quality of changes in the long term. Good tests, once written, make sure that you didn’t break anything existing with your code.

When mentoring new developers, if they ask me for advice on how to become better at coding, one thing I always recommend is to learn unit testing, understand and get familiar with the test framework, understand test writing practices, and understand what makes a test good. And! Review pull requests from more experienced developers; they are expected to write good tests, and you can learn from them.

As well as monitoring and observability, good testing ensures that, after your code is deployed, the software will work properly without requiring explicit user feedback.

Requirements

It would be good if you’re already familiar with Docker and Docker Compose and know what containers are, what those tools do, and what a docker-compose.yml file is. If that’s not your case, no worries; just go ahead!

If you’re a Linux or Mac user, you can install it using the official Docker documentation.

Instead, if you use Windows, I recommend you install WSL2 first, then go to the Docker documentation and install Docker Desktop. Links:

Sample ASP.NET Core project

In this guide, we are going to use whoof-aspnetcore as the project to be tested, a simple implementation of a pet vaccination REST API. Please open the project in github.dev and take a quick look at the project structure so you get familiar with it.

However, the most important thing at the moment is the BaseCrudController class, which is a generic base class for every controller containing some premade CRUD endpoints, which are the endpoints that we will test in our test project:

GET    /v1/pets
GET    /v1/pets/{id}
POST   /v1/pets
PUT    /v1/pets/{id}
DELETE /v1/pets/{id}

Also, the PetVaccinationController is a little bit different, omitting the GET / method and introducing GET /pet/{id} instead.

Creating the test project

Let’s take a look at some important points for the test project:

Docker Compose

First thing: we need a docker-compose.yml file at the repository’s root, containing all dependencies for our project. In this case, only a PostgreSQL instance is enough, like the one present in our project. Take in mind that some actual dependencies in the project are not present in the sample below:

 1version: "3.8"
 2
 3services:
 4
 5  postgres:
 6    image: postgres
 7    ports:
 8      - 5432:5432
 9    environment:
10      - POSTGRES_PASSWORD=whoof
11    volumes:
12      - postgres-data:/var/lib/postgresql/data
13
14volumes:
15  postgres-data:

Now, make use of the following commands to start your services or shut them down.

1# Start services
2docker-compose up -d
3
4# Shut down services
5docker-compose down

This is also great for debugging. Instead of installing every dependency on your computer and running the project, you can leave it to Docker Compose.

Connection string

The next step, after we have our docker-compose.yml file ready, is to set the connection string in both the API and test project appsettings.json files. See the example below:

 1{
 2+    "ConnectionStrings": {
 3+        "AppDbContext": "User ID=postgres;Password=whoof;Server=localhost;Port=5432;Database=whoof;Integrated Security=true;Pooling=true;Include Error Detail=true;"
 4+    },
 5    "Logging": {
 6        "LogLevel": {
 7            "Default": "Debug",
 8            "System": "Information",
 9            "Microsoft": "Information"
10        }
11    }
12}

Please note the connection string name is AppDbContext. In your application, where EF Core is configured, set that named connection string as well. It can be whatever name you want, they just have to match.

1// <IServiceCollection reference>
2.AddDbContext<AppDbContext>(options =>
3    options.UseNpgsql(configuration.GetConnectionString("AppDbContext"))
4)

⚠️ Npgsql is the .NET provider for PostgreSQL. If you use another database provider, please use its specific package and method.

WebApplicationFactory

WebApplicationFactory is a class in Microsoft.AspNetCore.Mvc.Testing package that enables us to create a real application instance to be used for testing. We can either use it directly in our tests or create an implementation by inheriting it with our own configuration. I prefer the second option, to create another class and do some configuration, like the example below, which is present in the project:

 1public class TestWebApplicationFactory<TProgram>
 2    : WebApplicationFactory<TProgram> where TProgram : class
 3{
 4    private readonly string _exclusiveDbName;
 5
 6    public TestWebApplicationFactory(string exclusiveDbName)
 7    {
 8        _exclusiveDbName = exclusiveDbName;
 9    }
10    
11    protected override void ConfigureWebHost(IWebHostBuilder builder)
12    {
13        var configuration = new ConfigurationBuilder()
14            .AddJsonFile("appsettings.json")
15            .AddJsonFile("appsettings.Testing.json")
16            .AddEnvironmentVariables()
17            .Build();
18
19        builder.UseConfiguration(configuration);
20        
21        builder.ConfigureServices(services =>
22        {
23            ReplaceDbConnectionString(services, configuration);
24        });
25
26        builder.UseEnvironment("Testing");
27    }
28
29    private void ReplaceDbConnectionString(IServiceCollection services, IConfiguration configuration)
30    {
31        var dbContextDescriptor = services.Single(
32            d => d.ServiceType ==
33                 typeof(DbContextOptions<AppDbContext>));
34
35        services.Remove(dbContextDescriptor);
36        
37        services.AddDbContext<AppDbContext>(options =>
38        {
39            var connstrBuilder = new DbConnectionStringBuilder();
40            connstrBuilder.ConnectionString = configuration["ConnectionStrings:AppDbContext"];
41            connstrBuilder["Database"] = _exclusiveDbName;
42            options.UseNpgsql(connstrBuilder.ConnectionString);
43        });
44    }
45}

See that:

Base test class

Another thing that will help us write integration tests is to have a base class with everything that is required in testing, from creating the test database, loading predefined data into it, doing dependency resolution, and removing the database after the test runs. Take a look at the project’s example:

 1public abstract class BaseControllerTests : IDisposable
 2{
 3    private static JsonSerializerOptions BuildJsonOptions()
 4    {
 5        var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
 6        jsonOptions.Converters.Add(new JsonStringEnumConverter());
 7
 8        return jsonOptions;
 9    }
10    
11    protected BaseControllerTests()
12    {
13        JsonOptions = BuildJsonOptions();
14        
15        ExclusiveDbName = $"whoof_{Guid.NewGuid()}";
16        TestWebApplicationFactory<Program> factory = new(ExclusiveDbName);
17        
18        HttpClient = factory.CreateClient();
19        ServiceScope = factory.Services.CreateScope();
20        DbContext = ServiceScope.ServiceProvider.GetRequiredService<AppDbContext>();
21        Mapper = ServiceScope.ServiceProvider.GetRequiredService<IMapper>();
22        
23        InitializeDatabase();
24    }
25
26    public JsonSerializerOptions JsonOptions { get; }
27    public string ExclusiveDbName { get; }
28    protected HttpClient HttpClient { get; }
29    protected IServiceScope ServiceScope { get; }
30    protected AppDbContext DbContext { get; }
31    protected IMapper Mapper { get; }
32    protected IServiceProvider ServiceProvider => ServiceScope.ServiceProvider;
33
34    private void InitializeDatabase()
35    {
36        DbContext.Database.EnsureDeleted();
37        DbContext.Database.EnsureCreated();
38        PreloadedData.Load(DbContext);
39    }
40
41    private void TeardownDatabase()
42    {
43        DbContext.Database.EnsureDeleted();
44    }
45
46    protected virtual void Dispose(bool disposing)
47    {
48        if (disposing)
49        {
50            TeardownDatabase();
51            HttpClient.Dispose();
52            ServiceScope.Dispose();
53        }
54    }
55
56    public void Dispose()
57    {
58        Dispose(true);
59        GC.SuppressFinalize(this);
60    }
61}

See that:

Patterns

With both classes created, we can now start writing tests. But before that, let’s quickly introduce some important matters.

Naming convention

To easily know what a test should do and what to expect, it’s important to define a naming convention for the test methods. In this project, the following naming convention is used: MethodName_StateInTest_ExpectedResult. Some examples:

AAA pattern

The AAA pattern is very popular in software testing, and it standardizes the test method structure with three steps: (A)rrange, (A)ct, and (A)ssert.

FluentAssertions

FluentAssertions is a NuGet package available to write better assertions in the Assert section of our tests, making them easier to write and read.

The first test method

To see everything in place, let’s see an example of a test method. Notice the base class usage, the naming convention, the AAA pattern, and the usage of FluentAssertions at the end.

 1public class PetsControllerTests : BaseControllerTests
 2{
 3    [Fact]
 4    public async Task GetByIdAsync_WhenPetExists_ReturnsAsExpected()
 5    {
 6        // Arrange
 7        var expectedPet = Mapper.Map<PetDto>(DbContext.Pets.First());
 8
 9        // Act
10        var actualPet = await HttpClient.GetFromJsonAsync<PetDto>(
11            $"/v1/pets/{expectedPet.Id}", JsonOptions
12        );
13
14        // Assert
15        actualPet.Should()
16            .NotBeNull().And
17            .BeEquivalentTo(expectedPet, c => c
18                .ExcludingBaseFields()
19                .ExcludingOwnershipFields());
20    }
21}

This test method makes sure the GetByIdAsync method returns a pet when it exists in the database. We get an arbitrary pet from the database, make the HTTP request to the API, then parse the returned JSON to the model that the API returns, and then we finally assert that the pet is the same that we asked for, except for some fields that aren’t returned or will not match.

More tests

You can take a look at the entire PetsControllerTests class in the repository, which has tests for most of the use cases of the Pets API (all methods with valid data, invalid data, nonexistent IDs, etc.).

See PetsControllerTests.cs on GitHub.

Conclusion

A summary of what we went through in this guide:

#dotnet #csharp #aspnetcore #xunit

Reply to this post by email ↪