[FollowUp] Using Testcontainers in integration tests for ASP.NET Core Web API

In one of my last blog posts, I wrote about integration testing with ASP.NET Core:

[HOWTO] Implement integration tests for ASP.NET Core Web API with AntiForgery token validation

The author discusses the importance of integration tests in ASP.NET Core Web APIs, citing benefits, such as real-life-like REST requests, serialization and deserialization, server application startup code execution, etc. Furthermore, the Microsoft.AspNetCore.Mvc.Testing library is highlighted. Despite challenges, the author promotes maintaining similarity to production during tests. The text then explores code bases and integration tests.…

Read more

There I used SQLite file database to run integration tests against. In the meantime I hit several times the limitations of SQLite and therefore decided to get rid of it. Especially because the translation of complex Linq queries involving decimal types into SQL lead to unexpected exceptions.

While searching for alternatives I came across the following approaches:

Mocking, faking or stubbing either the repository layer or DbContext and DbSet was not an option for me as this would lead to loosing an important part of the path that I want to test with my integration tests – namely the Linq to SQL translation. The EF Core in-memory database provider is not recommended and does not get the new features of EF Core. LocalDB has less limitations compared to SQLite, however it’s only supported on Windows and the build pipelines are executed on Linux agents and I would like to be OS independent with the CI/CD pipelines.

Last but not least, I checked out Testcontainers for .NET and went with that approach. The simple setup, the good documentation and above all the fact that the integration tests can be executed against an MSSQL server fully convinced me. The only little drawback is that Docker must be installed and running in the local development environment – but I can live with that and the error messages in the event that Docker is not running are a sufficient indication for the developer. Continuous integration is supported out of the box in my case as Microsoft-hosted Azure DevOps agents come with Docker pre-installed!

Inspired by Getting started with Testcontainers for .NET and by ASP.NET Core Blazor – Testcontainers for .NET, I ended up with the following code adjustments based on the snippets in the blog post mentioned in the beginning.

CustomWebApplicationFactoryExtensions.cs

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using Microsoft.Extensions.DependencyInjection;
using ExampleSolution.Api.Tests.Integration.Helpers;
 
namespace ExampleSolution.Api.Tests.Integration.Extensions;
 
public static class CustomWebApplicationFactoryExtensions
{
    public static async Task<InitialData> InitializeDatabaseAsync(this CustomWebApplicationFactory factory)
        await using var scope = factory.Services.CreateAsyncScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 
        // await dbContext.Database.EnsureDeletedAsync() not used
        // since then EnsureCreatedAsync() recreates the database every time, making integration tests run take too long
        await dbContext.Database.EnsureCreatedAsync();
 
        // Delete all entities in reverse dependency order
        await dbContext.Users.ExecuteDeleteAsync();
        await dbContext.Tenants.ExecuteDeleteAsync();

#pragma warning disable EF1001 // Internal EF Core API usage. // just for test purpose
        await dbContext.GetDependencies().StateManager.ResetStateAsync();
#pragma warning restore EF1001 // Internal EF Core API usage.
 
        var initialTenant = new Tenant
        {
            Name = "Initial Tenant",
        };
        await dbContext.Tenants.AddAsync(initialTenant);
        await dbContext.SaveChangesAsync();
 
        var initialUser = new User
        {
            DisplayName = "Integration Test-User",
            Email = "integration.test-user@example.com",
        };
 
        await dbContext.Users.AddAsync(initialUser);
        await dbContext.SaveChangesAsync();
 
        return new InitialData(initialTenant.Id, initialUser.Id);
    }
 
    public class InitialData
    {
        public InitialData(int internalTenantId, int internalUserId)
        {
            InternalUserId = internalUserId;
            InternalTenantId = internalTenantId;
        }
 
        public int InternalUserId { get; }
        public int InternalTenantId { get; }
    }
}

CustomWebApplicationFactory.cs

using System.Data.Common;
using System.Globalization;
using System.Net.Http.Headers;
using System.Security.Claims;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.MsSql;
using ExampleSolution.Tests.Integration.Options;

namespace ExampleSolution.Api.Tests.Integration.Helpers;

/// <summary>
/// see http://www.tiernok.com/posts/2021/mocking-oidc-logins-for-integration-tests/
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    private readonly MsSqlContainer _msSqlContainer;

    public CustomWebApplicationFactory()
    {
        _msSqlContainer = new MsSqlBuilder()
            .WithPortBinding(1433, true)
            .Build();

        _msSqlContainer.StartAsync().GetAwaiter().GetResult();
    }

    public HttpClient CreateLoggedInClient<T>(WebApplicationFactoryClientOptions options, string role, int internalTenantId, int internalUserId)
        where T : ImpersonatedAuthHandler
    {
        options.BaseAddress = new Uri("https://localhost");
        return CreateLoggedInClient<T>(options, list =>
        {
            list.Add(new Claim(ClaimTypes.Role, role));
            list.Add(new Claim("internalTenantId", internalUserId.ToString(CultureInfo.InvariantCulture)));
            list.Add(new Claim("internalUserId", internalUserId.ToString(CultureInfo.InvariantCulture)));
        });
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            var dbContextDescriptor = services.SingleOrDefault(
                d => d.ServiceType ==
                     typeof(DbContextOptions<ApplicationDbContext>));

            if (dbContextDescriptor is not null)
            {
                services.Remove(dbContextDescriptor);
            }

            var dbConnectionDescriptor = services.SingleOrDefault(
                d => d.ServiceType ==
                     typeof(DbConnection));

            if (dbConnectionDescriptor is not null)
            {
                services.Remove(dbConnectionDescriptor);
            }

            services.AddSingleton<DbConnection>(_ =>
            {
                var connection =
                    new SqlConnection(
                        $"server={_msSqlContainer.Hostname},{_msSqlContainer.GetMappedPublicPort(1433)};user id={MsSqlBuilder.DefaultUsername};password={MsSqlBuilder.DefaultPassword};database={MsSqlBuilder.DefaultDatabase};Encrypt=false");

                return connection;
            });

            services.AddDbContext<ApplicationDbContext>((container, optionsBuilder) =>
            {
                var connection = container.GetRequiredService<DbConnection>();
                optionsBuilder.UseSqlServer(connection);
            });

            var antiForgery = services.SingleOrDefault(
                d => d.ServiceType == typeof(IAntiforgery));

            if (antiForgery is not null)
            {
                services.Remove(antiForgery);
            }

            services.AddTransient<IAntiforgery, TrueIfTestSchemeAntiForgery>();
        });

        base.ConfigureWebHost(builder);
    }

    private HttpClient CreateLoggedInClient<T>(WebApplicationFactoryClientOptions options, Action<List<Claim>> configure)
        where T : ImpersonatedAuthHandler
    {
        var client = WithWebHostBuilder(builder =>
                builder.ConfigureTestServices(services =>
                {
                    // configure the intercepting provider
                    services.AddTransient<IAuthenticationSchemeProvider, TestAuthenticationSchemeProvider>();

                    services.AddAuthentication(TestAuthenticationSchemeProvider.SchemeName)
                        .AddScheme<ImpersonatedAuthenticationSchemeOptions, T>(
                            TestAuthenticationSchemeProvider.SchemeName, configureOptions =>
                            {
                                configureOptions.Configure = configure;
                            });
                }))
            .CreateClient(options);

        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue(scheme: TestAuthenticationSchemeProvider.SchemeName);

        return client;
    }

    public override async ValueTask DisposeAsync()
    {
        await _msSqlContainer.DisposeAsync();

        await base.DisposeAsync();
    }
}

Instead of using SQLite, the MsSqlContainer gets built and started in the constructor. Port 1433 (default SQL Server port) gets exposed to a random port of the host. In line 72 the connection string to the database get built based on the default values of the MsSqlContainer. In DisposeAsync the container gets disposed – or in other words gets destroyed.

TenantEndpointsTests.cs

Instead of implementing interface IClassFixture<CustomWebApplicationFactory> I annotated all integration test classes with [Collection("CustomWebApplicationFactoryCollection")].

using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using ExampleSolution.Api.Tests.Integration.Extensions;
using ExampleSolution.Api.Tests.Integration.Helpers;

namespace ExampleSolution.Api.Tests.Integration;

[Trait("Category", "IntegrationTests")]
[Collection("CustomWebApplicationFactoryCollection")]
public class TenantEndpointsTests
{
    private readonly CustomWebApplicationFactory _factory;

    /// <summary>
    /// Test setup
    /// </summary>
    /// <param name="factory">Custom web application factory</param>
    public TenantEndpointsTests(CustomWebApplicationFactory factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task GetTenantConfiguration_AsUnauthenticatedUser_ReturnsUnauthorized()
    {
        var client = _factory.CreateClient();
 
        var response = await client.GetAsync("/api/Tenant/configuration");
 
        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }
 
    [Fact]
    public async Task GetTenantConfiguration_AsUserInRoleAdministrator_ReturnsForbidden()
    {
        var client = _factory.CreateLoggedInClient<ImpersonatedAuthHandler>(
            new WebApplicationFactoryClientOptions { AllowAutoRedirect = false },
            "Administrator",
            0,
            0);
 
        var response = await client.GetAsync("/api/Tenant/configuration");
 
        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
    }
 
    [Fact]
    public async Task GetTenantConfiguration_AsUserInRoleArbitrary_ReturnsTenantConfiguration()
    {
        var initialData = _factory.InitializeDatabase();
 
        var client = _factory.CreateLoggedInClient<ImpersonatedAuthHandler>(
            new WebApplicationFactoryClientOptions { AllowAutoRedirect = false },
            "Arbitrary",
            initialData.InternalTenantId,
            initialData.InternalUserId);
 
        var response = await client.GetAsync("/api/Tenant/configuration");
 
        Assert.NotNull(response);
        response.EnsureSuccessStatusCode();
 
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.NotNull(response.Content);
        Assert.NotNull(response.Content.Headers.ContentType);
        Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString());
 
        var tenantConfiguration = await response.Content.ReadFromJsonAsync<JsonNode>();
        Assert.NotNull(tenantConfiguration);
        var tenantId = tenantConfiguration["Id"]!.GetValue<int>();
        Assert.True(tenantId > 0);
        Assert.Equal("Initial Tenant", tenantConfiguration["Name"]!.GetValue<string>());
    }
 
    [Fact]
    public async Task CreateTenant_AsUserInRoleAdministrator_CreatesTenant()
    {
        var initialData = _factory.InitializeDatabase();
 
        var client = _factory.CreateLoggedInClient<ImpersonatedAuthHandler>(
            new WebApplicationFactoryClientOptions { AllowAutoRedirect = false },
            "Administrator",
            initialData.InternalTenantId,
            initialData.InternalUserId);
 
        var tenantCreateRequest = new
        {
            Name = "Arbitrary Tenant",
        };
 
        var response = await client.PostAsJsonAsync("/api/Tenant", tenantCreateRequest);
 
        Assert.NotNull(response);
        response.EnsureSuccessStatusCode();
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.NotNull(response.Content);
        Assert.NotNull(response.Content.Headers.ContentType);
        Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString());
 
        var tenant = await response.Content.ReadFromJsonAsync<JsonNode>();
        Assert.NotNull(tenant);
        var tenantId = tenant["Id"]!.GetValue<int>();
        Assert.Equal($"https://localhost/api/Tenant/{tenantId}", response.Headers.Location?.ToString());
        Assert.True(tenantId > 0);
        Assert.Equal("Arbitrary Tenant", tenant["Name"]!.GetValue<string>());
    }
}

CustomWebApplicationFactoryCollection.cs

Setting DisableTestParallelization property of xUnit collection behavior to true in combination with the usage of ICollectionFixture ensures that the in-memory web server and the database container are bootstrapped only once and reused by all tests.

For more details regarding ICollectionFixture see Shared Context between Tests > xUnit.net.

[assembly: CollectionBehavior(DisableTestParallelization = true)]
namespace ExampleSolution.Api.Tests.Integration.Helpers;

[CollectionDefinition("CustomWebApplicationFactoryCollection")]
public class CustomWebApplicationFactoryCollection : ICollectionFixture<CustomWebApplicationFactory>
{
    // This class has no code, and is never created. Its purpose is simply
    // to be the place to apply [CollectionDefinition] and all the
    // ICollectionFixture<> interfaces.
}

That’s it. As you can see, there is not that much to change to make use of Testcontainers in your integration tests and avoiding the limitations of SQLite.

Leave a comment

Website Powered by WordPress.com.

Up ↑