12

I am trying to add some integration tests for a aspnetcore v6 webapi following the docs - https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-6.0#aspnet-core-integration-tests.

My webapi database is SQLServer. I want the tests to be run against an actual SQLServer db and not in-memory database. I came across dotnet-testcontainers - https://github.com/HofmeisterAn/dotnet-testcontainers and thinking of using this so I do not need to worry about the resetting the db as the container is removed once test is run.

So this is what I plan to do:

  1. Start-up a SQLServer testcontainer before the test web host is started. In this case, the test web host is started using WebApplicationFactory. So the started wen host has a db to connect with. Otherwise the service start will fail.
  2. Run the test. The test would add some test data before its run.
  3. Then remove the SQLServer test container along with the Disposing of test web host.

This way the I can start the test web host that connects to a clean db running in a container, run the tests.

Does this approach sound right? OR Has someone used dotnet-testcontainers to spin up a container for their application tests and what approach worked.

4
  • Your idea seems feasible, please try it out and post your question when you have a problem. Commented Feb 7, 2022 at 8:26
  • 1
    It's a doable approach, which indeed will allow your tests to perform db state mutations and not care about the cleanup logic. Just keep in mind that it all will be quite slow, if you try to start container per test and got plenty of tests to run. I personally would rather try to implement some sort of db pooling (with use of testcontainers probably) and use library like Reseed or Respawn to initialize and clean the dbs before/after test execution. Commented Feb 8, 2022 at 18:07
  • Yes, spinning the docker container with every test or set of tests will be slow I guess. Best is to start SQL server container once and keep it running. Just wondering if I put this start SQL server container logic in my initialize TestServer class (which spins up the web host for testing and will be used by various tests) how would I handle the starting of test container once only and not every time the web host is initialized? Commented Feb 9, 2022 at 22:40
  • I've added a WeatherForecast example that covers a couple of best practices (incl. a persistence layer with an MSSQL database, EF and a few way to run tests) Commented Oct 20, 2022 at 12:35

2 Answers 2

14

I wrote about this approach here.

You basically need to create a custom WebApplicationFactory and replace the connection string in your database context with the one pointing to your test container.

Here is an example, that only requires slight adjustments to match the MSSQL docker image.

public class IntegrationTestFactory<TProgram, TDbContext> : WebApplicationFactory<TProgram>, IAsyncLifetime
    where TProgram : class where TDbContext : DbContext
{
    private readonly TestcontainerDatabase _container;

    public IntegrationTestFactory()
    {
        _container = new TestcontainersBuilder<PostgreSqlTestcontainer>()
            .WithDatabase(new PostgreSqlTestcontainerConfiguration
            {
                Database = "test_db",
                Username = "postgres",
                Password = "postgres",
            })
            .WithImage("postgres:11")
            .WithCleanUp(true)
            .Build();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.RemoveProdAppDbContext<TDbContext>();
            services.AddDbContext<TDbContext>(options => { options.UseNpgsql(_container.ConnectionString); });
            services.EnsureDbCreated<TDbContext>();
        });
    }

    public async Task InitializeAsync() => await _container.StartAsync();

    public new async Task DisposeAsync() => await _container.DisposeAsync();
}

And here are the extension methods to replace and initialize your database context.

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

    public static void EnsureDbCreated<T>(this IServiceCollection services) where T : DbContext
    {
        var serviceProvider = services.BuildServiceProvider();

        using var scope = serviceProvider.CreateScope();
        var scopedServices = scope.ServiceProvider;
        var context = scopedServices.GetRequiredService<T>();
        context.Database.EnsureCreated();
    }
}
Sign up to request clarification or add additional context in comments.

2 Comments

Hi. How can I handle this solution in modular monolith architecture. In that case, I have multiple DbContexts and I have a problem on running migrations in each module. Or maybe you create multiple instances of WebApplicationFactory for each module separately ?
Hi - this is really helpful! I just have one question. I'm not sure if this changed in .NET Core 7, but in my 7 project, ConfigureWebhost seems to happen before the InitializeAsync method from IAsyncLifetime (which starts the test container) is called. This means that when I ask the container for its connection string in ConfigureWebhost, the container isn't started and throws an error. Do you have any insight on that? Thanks again!
4

There are another two ways to leverage Testcontainers for .NET in-process into your ASP.NET application and even a third way out-of-process without any dependencies to the application.

1. Using .NET's configuration providers

A very simple in-process setup passes the database connection string using the environment variable configuration provider to the application. You do not need to mess around with the WebApplicationFactory. All you need to do is set the configuration before creating the WebApplicationFactory instance in your tests.

The example below passes the HTTPS configuration incl. the database connection string of a Microsoft SQL Server instance spun up by Testcontainers to the application.

Environment.SetEnvironmentVariable("ASPNETCORE_URLS", "https://+");
Environment.SetEnvironmentVariable("ASPNETCORE_Kestrel__Certificates__Default__Path", "certificate.crt");
Environment.SetEnvironmentVariable("ASPNETCORE_Kestrel__Certificates__Default__Password", "password");
Environment.SetEnvironmentVariable("ConnectionStrings__DefaultConnection", _mssqlContainer.ConnectionString);
_webApplicationFactory = new WebApplicationFactory<Program>();
_serviceScope = _webApplicationFactory.Services.GetRequiredService<IServiceScopeFactory>().CreateScope();
_httpClient = _webApplicationFactory.CreateClient();

This example follows the mentioned approach above.

2. Using .NET's hosted service

A more advanced approach spins up the dependent database and seeds it during the application start. It not just helps writing better integration tests, it integrates well into daily development and significantly improves the development experience and productivity.

Spin up the dependent container by implementing IHostedService:

public sealed class DatabaseContainer : IHostedService
{
  private readonly TestcontainerDatabase _container = new TestcontainersBuilder<MsSqlTestcontainer>()
    .WithDatabase(new DatabaseContainerConfiguration())
    .Build();

  public Task StartAsync(CancellationToken cancellationToken)
  {
    return _container.StartAsync(cancellationToken);
  }

  public Task StopAsync(CancellationToken cancellationToken)
  {
    return _container.StopAsync(cancellationToken);
  }

  public string GetConnectionString()
  {
    return _container.ConnectionString;
  }
}

Add the hosted service to your application builder configuration:

builder.Services.AddSingleton<DatabaseContainer>();
builder.Services.AddHostedService(services => services.GetRequiredService<DatabaseContainer>());

Resolve the hosted service and pass the connection string to your database context:

builder.Services.AddDbContext<MyDbContext>((services, options) =>
{
  var databaseContainer = services.GetRequiredService<DatabaseContainer>();
  options.UseSqlServer(databaseContainer.GetConnectionString());
});

This example uses .NET's hosted service to leverage Testcontainers into the application start. By overriding the database context's OnModelCreating(ModelBuilder), this approach even takes care of creating the database schema and seeding data via Entity Framework while developing and testing.

3. Running inside a container

In some use cases, it might be necessary or a good approach to run the application out-of-process and inside a container. This increases the level of abstractions and removes the direct dependencies to the application. The services are only available through their public API (e.g. HTTP(S) endpoint).

The configuration follows the same approach as 1. Use environment variables to configure the application running inside a container. Testcontainers builds the necessary container image and takes care of the container lifecycle.

_container = new TestcontainersBuilder<TestcontainersContainer>()
  .WithImage(Image)
  .WithNetwork(_network)
  .WithPortBinding(HttpsPort, true)
  .WithEnvironment("ASPNETCORE_URLS", "https://+")
  .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Path", _certificateFilePath)
  .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Password", _certificatePassword)
  .WithEnvironment("ConnectionStrings__DefaultConnection", _connectionString)
  .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(HttpsPort))
  .Build();

This example sets up all necessary Docker resources to spin up a throwaway out-of-process test environment.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.