Assuming you’ve written some units tests, you might still be unsure if your app works as expected. Even if you’ve tested tested all pieces, when you put them together, something may go wrong. It is especially true in case of unit tests which are using test-doubles in every place where external dependencies are used. Even though you’ve replaced production database with SqlLite or other in-memory database in tests, I still recommend verifying if it works with “real” database. I am going to show that it is quite easy - at least in my simple scenario.

How much integration tests we need

At a basic level we can divide tests on three groups: unit, integration and end-to-end. Depending on the project characteristics, you can decides which tests will prevail. This decision doesn’t have to be global; every part of the application can have different proportions. Fortunatelly, we have predefined guidelines expressed in form on test shapes which can help in deciding. The most commonly know are:

  • Test pyramide
  • Inverted test pyramid, aka ice cream cone
  • Honeycomb

Without any context, you cannot recommend any of them as the one who rule them all. In scenarios where you are refactoring legacy code you want be sure that all processes work as expected and as they did before you started changing anything. This means you probably want to start at a higher level e.g. with integration tests. In other case, when you want to cover all edge cases of algorithm, unit tests might be more effecient, especially in terms of execution time. Nevertheless, it is worth preparing integration tests at least for “happy path” scenarios for places where your units are used in a wider context.

First and foremost, consider which tests and how many tests give you a sense of safety and help you sleep well.

Here, I am going to focus on a simple scenario, without delving into which strategy to apply, but rather which tools to use to make test code writing easier.

Xunit

It is important to understand the xUnit assumptions. Fo example, compared to nUnit, in xUnit, there are no explicit methods executing before and after tests. A new instance of test class is created for every test case (test method), so the constructor is where we insert tests setup code. In Dispose() method from implemented the IDisposable interface you can specify what should happen after the test in the cleanup.

Parallelism is enabled by default but can be disabled it you specify that. Every test class is treated as a collection, and collections run in parallel. Tests within the same test class will not be executed simultaneously, and you cannot assume that methods execute in the order they are written in class.

Considering the information above, a rule of thumb may be to make test collections small to shorten the test execution time.

To share the same dependencies between test cases, you can use IClassFixture<T> interface. It allows you to share object instances across tests in a single class (class with test cases). For each such class being shared for the test class, you have to create separate fixture class so test class can implement more IClassFixture<T> interfaces. One fixture cannot take dependencies on other fixture because you have no control over the fixtures’ creation order. To solve such cases, a new class encapsulating both fixtures should be created to manage the object creation.

To treat two or more test classes as one collection you can use the CollectionDefinition attribute. xUnit treats collection fixtures the same way as class fixtures but their lifetime is longer. They are created before any tests are run in our test classes in the collection and will not be cleaned up until all test classes in the collection have finished running.

For more details, I recommend visiting official documentation.

How to test with real dependencies

Probably the most popular dependency you have to take care of is the database. The goal is to configure it easily not only for you but also for the rest of the team. That’s where testcontainers shine. You can create a portable solution working on any machine with Docker installed, which also is near-production when containers are configured consistently. Each test suite can run in an isolated environment with cleanup after tests.

For more details visit testcontainers documentation for .NET.

Code example introduction

My SimpleTodo is here again. The app uses EF Core with PostgreSQL and that information will be important soon in case of app configuration on the test server.

Important Nuget packages in project

  • Microsoft.AspNetCore.Mvc.Testing - Creates a test server that can host the appplication in-memory and handle HTTP requests. Configure dependency injection container with dependencies already used by application.
  • Microsoft.NET.Test.Sdk - Enables discovery and execution of tests.
  • xunit - The chosen testing tool.
  • xunit.runner.visualstudio - Allows you to run tests in Visual Studio. In case of using Rider, it supports out of the box.
  • Shouldly - Improves assertions by providing custom extension methods and giving more readable error messages when assertions fail.

Setup

We need to use WebApplicationFactory<Program> class for bootstrapping the application in-memory. Entry point Program class is not accessible in top-level statement approach, so you can create a partial class in the Program.cs file.

public abstract class SimpleTodoAppFactory : WebApplicationFactory<Program>
{
    // more code soon..    
}

By default, the app in the test server runs with all dependencies registered in DI container, so it will try to connect to the database specified in the app settings. We want to replace connections string there with one prepared specifically for tests.

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureTestServices(services =>
    {
        services.RemoveDbContext<TodoDbContext>();
        services.AddDbContext<TodoDbContext>(c =>
        {
            c.UseNpgsql("<connection_string_for_tests>");
        });
    });
}

To achive this, you need to first remove the registered DbContext and then register a new one.

Now it’s time to focus more on testcontainers. We want to create PostgreSQL container and then connect to it.

First, let’s install the dedicated nuget packages:

  • Testcontainers
  • Testcontainers.PostgreSql
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
    .WithImage("postgres:latest")
    .WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
    .WithCleanUp(true)
    .Build();

Similarly to how we do it in a docker-compose file, we do it here in code but with friendly fluent api methods. It’s only definition, and next, we want to start the container to have access to its connection string, which will be used in the newly registered DbContext in the DI container mentioned earlier.

Implement the IAsyncLifetime interface, which will force you to implement InitialiseAsync and DisposeAsync. In those methods, the container start and stop will be done, so it executes every time we build and stop the host.

public async Task InitializeAsync()
{
    await _dbContainer.StartAsync();
}

public new async Task DisposeAsync()
{
    await _dbContainer.DisposeAsync();
}

Finally, we have it prepared and we can start writing tests. Here is the whole solution:

public class SimpleTodoAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
        .WithImage("postgres:latest")
        .WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
        .WithCleanUp(true)
        .Build();
        
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.RemoveDbContext<TodoDbContext>();
            services.AddDbContext<TodoDbContext>(c =>
            {
                c.UseNpgsql(_dbContainer.GetConnectionString());
            });
        });
    }

    public async Task InitializeAsync()
    {
        await _dbContainer.StartAsync();
    }

    public new async Task DisposeAsync()
    {
        await _dbContainer.DisposeAsync();
    }
}

public static class ServiceCollectionExtensions
{
    public static void RemoveDbContext<T>(this IServiceCollection services) where T : DbContext
    {
        var descriptor = services.SingleOrDefault(x => x.ServiceType == typeof(DbContextOptions<T>));
        if (descriptor != null)
        {
            services.Remove(descriptor);
        }
    }
}

Some tests

Now, with the infrastructure part ready, some tests may be written. The examples are trivial but they expose some problems that may occur in our tests.

public sealed class TodoServiceTests : IClassFixture<SimpleTodoAppFactory>
{
    private readonly IServiceScope _serviceScope;

    public TodoServiceTests(SimpleTodoAppFactory appFactory)
    {
        _serviceScope = appFactory.Services.CreateScope();
    }
    
    [Fact]
    public async Task ShouldSaveNewTodo()
    {
        // Given
        var sut = GetDependency<ITodoService>();

        // When
        var addedTodo = await CreateTodo(sut);
            
        // Then
        var savedTodo = await sut.Get(addedTodo.Id);
        savedTodo!.Title.ShouldBe(addedTodo.Title);
        savedTodo.Description.ShouldBe(addedTodo.Description);
        
        var all = await sut.Get(2);
        all.Count.ShouldBe(1);
    }
    
    [Fact]
    public async Task ShouldUpdateTodo()
    {
        // Given
        var sut = GetDependency<ITodoService>();
        var addedTodo = await CreateTodo(sut);
        var todoToUpdate = new UpdateTodoDto
        {
            Id = addedTodo.Id,
            Title = "Test title 2",
            Description = "Test description 2"
        };
    
        // When
        await sut.Update(todoToUpdate);
        
        // Then
        var updatedTodo = await sut.Get(addedTodo.Id);
        updatedTodo!.Title.ShouldBe(todoToUpdate.Title);
        updatedTodo.Description.ShouldBe(todoToUpdate.Description);
        
        var all = await sut.Get(2);
        all.Count.ShouldBe(1);
    }

    private async Task<TodoDto> CreateTodo(ITodoService service)
    {
        var todoToCreate = new CreateTodoDto
        {
            Title = "Test title 1",
            Description = "Test description 1"
        };
        var id = await service.Add(todoToCreate);
        var todo = await service.Get(id);

        return todo!;
    }
    
    private T GetDependency<T>() where T : class
    {
        return _serviceScope.ServiceProvider.GetRequiredService<T>();
    }
}

IClassFixture is used to share SimpleTodoAppFactory between tests to avoid creating a host and, as a result, the database container for every test case because, in terms of execution time, it is quite an expensive operation.

When you run these tests, one of them should fail. The reason is the last section of this failing test case where we check how many entities are in database after operation execution.

var all = await sut.Get(2);
all.Count.ShouldBe(1);

In both cases, only one entity is added or modified, so we may want to ensure than only this one entity exists. If it is good practice or not it is another subject, but here it is only to highlight a potential problem. The problem is that if you need such an assertion, you need to ensure the database is cleared after every test case. Both tests operate on the same host and the same database; they run one after another, so always one of them will have the saved entity from previous one.

A few solutions may be applied. Using Respawn nuget package seems to be the easiest and the most elegant way. Authors defines it as “intelligent database cleaner for integration tests” and how this intelligence works can be read on Jimmy Bogard blog.

We use it here to reset the database to the state it was before the first test execution, giving us a clean state for the next one.

public class SimpleTodoAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    // rest of the code omitted for brevity
    
    private Respawner _respawner = null!;
    private DbConnection _connection = null!;
        
    public async Task InitializeAsync()
    {
        await _dbContainer.StartAsync();
        
        var dbContext = GetDbContext<TodoDbContext>(Services);
        _connection = dbContext.Database.GetDbConnection();
        await _connection.OpenAsync();
        
        _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
        {
            DbAdapter = DbAdapter.Postgres,
            SchemasToInclude =
            [
                "todo"
            ]
        });
    }

    public async Task ResetDatabase()
    {
        await _respawner.ResetAsync(_connection);
    }

    public new async Task DisposeAsync()
    {
        await _dbContainer.DisposeAsync();
        _respawner.
        await _connection.CloseAsync();
    }
    
    private static T GetDbContext<T>(IServiceProvider services) where T : DbContext
        => services
            .CreateScope()
            .ServiceProvider
            .GetRequiredService<T>();
}

Two new field appeared: one for storing Respawn, which resets the database, and the second for storing the connection to the database. When InitializeAsync runs, the database container is started, then the database connection is established and passed to Respawn. We also specify that it operates on PostgreSQL to know which adapter to use, and because the application has a schema named todo, we include it. By default, you probably use a schema named public. When the test host is disposed if, the connection is also closed. A new method called ResetDatabase is publicly accessible for test classes, allowing them to reset the database when needed.

public sealed class TodoServiceTests : IClassFixture<SimpleTodoAppFactory>, IAsyncLifetime
{
    private readonly SimpleTodoAppFactory _appFactory;
    private readonly IServiceScope _serviceScope;

    public TodoServiceTests(SimpleTodoAppFactory appFactory)
    {
        _appFactory = appFactory;
        _serviceScope = appFactory.Services.CreateScope();
    }
    
    // rest of the code omitted for brevity
    
    public Task InitializeAsync() => Task.CompletedTask;

    public async Task DisposeAsync() => await _appFactory.ResetDatabase();
}

In test classes, we also implement IAsyncLifetime and now in the Dispose method, after every test, we reset the database to its initial state, solving our previous problem with assertions.

Summary

As you see, there are plenty of tools that do a lot of work for you. Thanks to testcontainer, you do not have to install a locally database. Respawn resets the database for you instead of preparing custom scripts or even doing one big transaction with a rollback after test finalization.