Search code examples
.nettestingintegration-testingxunit.net-8.0

How to overwrite connection strings in WebApplicationFactory?


I have an API that I would like to make integration tests for. I have a Program.cs and a custom made Startup.cs which is basically a static class containing extension methods to WebApplicationBuilder to mimic the .NET behavior prior to .NET 6.

// Program.cs
WebApplication.CreateBuilder(args)
    .ConfigureLogging()
    .ConfigureServices()
    .Build()
    .Initialize()
    .ConfigurePipeline()
    .Run();

// Startup.cs
public static class Startup
{
    public static WebApplicationBuilder ConfigureLogging(this WebApplicationBuilder builder)
    {
        // Configure logging
        return builder;
    }

    public static WebApplicationBuilder ConfigureServices(this WebApplicationBuilder builder)
    {
        var services = builder.Services;
        var configuration = builder.Configuration;

        services.AddPooledDbContextFactory<AppDbContext>((serviceProvider, options) =>
        {
            var connectionString = configuration.GetConnectionString("Database");
            var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
            dataSourceBuilder.EnableDynamicJson();
            options.UseNpgsql(dataSourceBuilder.Build());
        });

        return builder;
    }

    public static WebApplication ConfigurePipeline(this WebApplication app)
    {
        // Register middleware
        app.UseRouting();
        app.MapControllers();
        return app;
    }

    public static WebApplication Initialize(this WebApplication app)
    {
        // Resolve DbContext and call .Migrate()
        return app;
    }
}

What I am trying to do is create a custom WebApplicationFactory<> and override some stuff in there, such as the database that will be used. I am using the Testcontainers.PostgreSql NuGet package and my factory looks like this:

public class MyFactory : WebApplicationFactory<IAssemblyMarker>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgreSqlContainer = new PostgreSqlBuilder()
        .WithUsername("username")
        .WithPassword("password")
        .WithDatabase("mydb")
        .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureLogging(logging =>
        {
            logging.ClearProviders();
        });

        // If I use ConfigureTestServices, the test first calls ConfigureTestServices,
        // then ConfigureServices, and then Startup.ConfigureServices (the extension method)
        //builder.ConfigureTestServices(services =>
        builder.ConfigureServices(services =>
        {
            var connectionString = _postgreSqlContainer.GetConnectionString();

            services.RemoveAll<AppDbContext>();
            services.RemoveAll<IDbContextFactory<AppDbContext>>();
            services.RemoveAll<IDataTypeBuilder>();

            services.AddSingleton<IDataTypeBuilder, NpgsqlDataTypeBuilder>();
            services.AddPooledDbContextFactory<AppDbContext>(options =>
            {
                var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString)
                {
                    Name = "IntegrationTests App"
                };
                options.UseNpgsql(dataSourceBuilder.Build());
            });
        });
    }

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

    async Task IAsyncLifetime.DisposeAsync()
    {
        await _postgreSqlContainer.StopAsync();
    }
}

The problem is, calling _myFactory.CreateClient() will always throw an exception because the connection string in my appsettings.json is defined for my local PostgreSQL container, which is naturally down for the integration tests. I did try setting breakpoints and I have a question: Why does my test method (after calling CreateClient) enter the WebApplicationFactory<>.ConfigureServices (or ConfigureTestServices - I can't seem to find what the difference between the two is) and then the Startup.ConfigureServices? What happens then is that the PostgreSqlContainer connection string doesn't play a role in my tests at all, but the test methods try to connect to my database that is defined in my appsettings.json.

What is the correct fix and workaround for this? Could the custom Startup class be the problem (since Startup.ConfigureServices is a custom extension method)?


Solution

  • So, I managed to find a "workaround" (no idea if it should be done this way, though). I believe because my AppDbContext is registered using AddPooledDbContextFactory<AppDbContext> that I have not removed all service registrations using services.RemoveAll<> in my WebApplicationFactory.ConfigureWebHost inside builder.ConfigureTestServices / builder.ConfigureServices (I still do not know what the difference between the two would be), so the AppDbContext is registered in my custom Startup class with pre-existing appsettings.json configuration and AFTER that, I try to re-register the DbContextFactory in my MyFactory.

    So, my workaround so far is to remove ConfigureTestServices and ConfigureServices from my MyFactory : WebApplicationFactory altogether and call ConfigureAppConfiguration which allows me to remove any IConfiguration sources and define my own Dictionary<> which would have my custom (integration-test specific) values for the app configuration.

    builder.ConfigureAppConfiguration((ctx, builder) =>
    {
        var configuration = new Dictionary<string, string?>
        {
            ["ConnectionStrings:Database"] = _postgreSqlContainer.GetConnectionString(),
        };
    
        builder.Sources.Clear();
        builder.AddInMemoryCollection(configuration);
    });
    

    The result of doing this is that when my Startup.ConfigureServices is called by my Program class, the IConfiguration it uses has ONLY the key-value pairs that I have defined above, so the connection string that it tries to use is basically pre-set by my integration test MyFactory.

    I will mark this question as Answered. In case anyone has a better answer or explanation in the future, feel free to post them here.