post-thumb

The best way for integrations tests with Testcontainers

Testing is important. Automated tests are more important. There are multiple ways to categorize tests like [Unit, Integration, E2E]. When it is possible I recommend using UnitTests, but in some cases they are not enough. In some situations writing integration test is much simpler and faster. When? For example when you need to use any external service like Apache Kafka or sql.

Info

The whole project is in this repository in GitHub: CodePruner.TestContainerExamples

In one of the previous post I have described [how to configure EntityFramework migration](«{ relref “./2024-09-10-how-to-configure-entity-framework-with-migrations-tutorial.md”}»). We will use that code in today example, because in our environments we need a bit more then just pure database. We expect it will have schema applied. So let’s begin our adventure.

The case for today

My idea is not to create a complex scenario, but to show you how to write integration test faster, without pain in the arse. So I will focus on the most common scenario for .NET like App + EntityFramework + SqlServer. So the scenario will contain:

  1. Creating SqlServer container for a test case
  2. Run migration to have a correct structure
  3. Execute Insert SQL
  4. Execute Select SQL
  5. Test unique constraint in that scenario

The simplest way of configuring TestContainers

We will start with the simplest, but not optimal way of using testcontainers, but be calm. We will improve it later. Let’s assume you have already created a test project. Then we need to add nuget package:

dotnet add package Testcontainers

Add code for initialize container with SqlServer:

    private async Task<string> InitSql()
    {
        var password = "yourStrong(!)Password";
        var container = new ContainerBuilder()
            .WithImage("mcr.microsoft.com/mssql/server:2019-CU18-ubuntu-20.04")
            .WithPortBinding(1433, true)
            .WithEnvironment("ACCEPT_EULA", "Y")
            .WithEnvironment("MSSQL_PID", "Express")
            .WithEnvironment("MSSQL_SA_PASSWORD", password)
            .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
            .Build();

        await container.StartAsync();

        var connectionStringBuilder = new SqlConnectionStringBuilder()
        {
            UserID = "sa",
            Password = password,
            InitialCatalog = "test-database",
            DataSource = $"{container.Hostname},{container.GetMappedPublicPort(1433)}",
            TrustServerCertificate = true
        };

       return  connectionStringBuilder.ConnectionString;
    }
    private CodePrunerDbContext CreateDbContext(string connectionString)
    {
        var optionsBuilder = new DbContextOptionsBuilder<CodePrunerDbContext>();
        optionsBuilder.UseSqlServer(connectionString);

        var dbContext = new CodePrunerDbContext(optionsBuilder.Options);
        return dbContext;
    }

There are some interesting things to describe:

  • To set image you want to use invoke WithImage. In you case we use SqlServer. All de details how to configure the image you can find on DockerHub .
  • .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433)) - This line is very important, because it tells testcontainers when the container is ready. There are much more options. You can find other WaitStrategies here
  • Port binding - It is the part you have to care. Because there is a high chance you will create more than one container and the port you need will be used already. You need to:
    • Define the port you want to expose with .WithPortBinding(1433, true)
    • Get public port with container.GetMappedPublicPort(1433)
    • Then testcontainers will take care about correct port mapping

So when it works, let see on our test:

    [Fact]
    public async Task insert()
    {
        var connectionstring = await InitSql();
        await RunMigration(connectionstring);

        await using var dbContext = CreateDbContext(connectionstring);
        dbContext.Articles.Add(new Article
        {
            Id = Guid.NewGuid(),
            Url = "the-best-way-for-integrations-tests-with-testcontainers",
            Title = "The best way for integrations tests with testcontainers",
            Content = "Content..."
        });
        await dbContext.SaveChangesAsync();
    }
    [Fact]
    public async Task insert_and_select()
    {
        var connectionstring = await InitSql();
        await RunMigration(connectionstring);

        await using (var dbContextToSave = CreateDbContext(connectionstring))
        {
            dbContextToSave.Articles.Add(new Article
            {
                Id = Guid.NewGuid(),
                Url = "the-best-way-for-integrations-tests-with-testcontainers",
                Title = "The best way for integrations tests with testcontainers",
                Content = "Content..."
            });
            await dbContextToSave.SaveChangesAsync();
        }

        await using (var dbContextToRead = CreateDbContext(connectionstring))
        {
            Assert.Equal(1, dbContextToRead.Articles.Count());
        }
    }

    [Fact]
    public async Task try_double_insert_with_unique_constraint()
    {
        var connectionstring = await InitSql();
        await RunMigration(connectionstring);
        var url = "url-should-be-unique";
        await using (var dbContextToSave = CreateDbContext(connectionstring))
        {
            dbContextToSave.Articles.Add(new Article
            {
                Id = Guid.NewGuid(),
                Url = url,
                Title = "title one",
                Content = "Content one..."
            });
            await dbContextToSave.SaveChangesAsync();
            dbContextToSave.Articles.Add(new Article
            {
                Id = Guid.NewGuid(),
                Url = url,
                Title = "title two",
                Content = "Content two..."
            });
            var exception = await Assert.ThrowsAsync<DbUpdateException>(async () => await dbContextToSave.SaveChangesAsync());
            Assert.Contains("IX_Articles_Url", exception.InnerException!.Message);
        }
    }

All of them pass. You can clone the repo and run it on your own environment. As I mentioned at the beginning that approach has some vulnerabilities. I mean for each test new container is created. So when we execute docker ps the result it:

CONTAINER ID   IMAGE                                                   CREATED          PORTS
1cf171523b96   mcr.microsoft.com/mssql/server:2019-CU18-ubuntu-20.04   4 seconds ago    0.0.0.0:50557->1433/tcp
138497c29e35   mcr.microsoft.com/mssql/server:2019-CU18-ubuntu-20.04   18 seconds ago   0.0.0.0:50548->1433/tcp
ca0435e51f90   mcr.microsoft.com/mssql/server:2019-CU18-ubuntu-20.04   33 seconds ago   0.0.0.0:50542->1433/tcp
5abee30ab59e   testcontainers/ryuk:0.6.0                               33 seconds ago   0.0.0.0:50539->8080/tcp

You can see 3+1 containers running. One for each test. Now it is not a problem, but when you have more and more test you will have more and more containers. So do something to reduce amount of them.

Reduce number of containers in xUnit

I use xUnit, so I am going to describe how to achieve it with this test framework. There is a way to share resources between tests in one class. I know that theory tells us that tests should be independent. In the most cases it is true, but sometimes we need to sacrifice something to achieve something else. It is in that scenario. We sacrifice stateless for better time and lower memory usage. Ok, so how to do it?

  1. Create a FixtureClass. It is a normal class, but you need to prepare everything you want to share in constructor. Here is out example:
    public class DatabaseContainerFixture
    {
        public string ConnectionString { get; private set; } = "";
        public DatabaseContainerFixture()
        {
            InitSql().Wait();
            RunMigration().Wait();
        }
        private async Task InitSql()
        {
            var password = "yourStrong(!)Password";
            var container = new ContainerBuilder()
                .WithImage("mcr.microsoft.com/mssql/server:2019-CU18-ubuntu-20.04")
                .WithPortBinding(1433, true)
                .WithEnvironment("ACCEPT_EULA", "Y")
                .WithEnvironment("MSSQL_PID", "Express")
                .WithEnvironment("MSSQL_SA_PASSWORD", password)
                .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
                .Build();
    
            await container.StartAsync();
    
            var connectionStringBuilder = new SqlConnectionStringBuilder()
            {
                UserID = "sa",
                Password = password,
                InitialCatalog = "test-database",
                DataSource = $"{container.Hostname},{container.GetMappedPublicPort(1433)}",
                TrustServerCertificate = true
            };
    
            ConnectionString = connectionStringBuilder.ConnectionString;
        }
    
    
        private async Task RunMigration()
        {
            await using var dbContext = CreateDbContext();
            await dbContext.Database.EnsureCreatedAsync();
        }
    
        public CodePrunerDbContext CreateDbContext()
        {
            var optionsBuilder = new DbContextOptionsBuilder<CodePrunerDbContext>();
            optionsBuilder.UseSqlServer(ConnectionString);
    
            var dbContext = new CodePrunerDbContext(optionsBuilder.Options);
            return dbContext;
        }
    }
    

You can see it is the same code as previous, but in a separate class.

  1. Use the fixture. To do this, you need to add interface IClassFixture<DatabaseContainerFixture> and pass the fixture by constructor to the test class. Here is an example:
    public class CreateOneDatabaseTest(DatabaseContainerFixture fixture) : IClassFixture<DatabaseContainerFixture>
    {
        [Fact]
        public async Task insert()
        {
            await using var dbContext = fixture.CreateDbContext();
            dbContext.Articles.Add(new Article
            {
                Id = Guid.NewGuid(),
                Url = "the-best-way-for-integrations-tests-with-testcontainers",
                Title = "The best way for integrations tests with testcontainers",
                Content = "Content..."
            });
            await dbContext.SaveChangesAsync();
        }
    
        [Fact]
        public async Task insert_and_select()
        {
            await using (var dbContextToSave = fixture.CreateDbContext())
            {
                dbContextToSave.Articles.Add(new Article
                {
                    Id = Guid.NewGuid(),
                    Url = "example-url",
                    Title = "The best way for integrations tests with testcontainers",
                    Content = "Content..."
                });
                await dbContextToSave.SaveChangesAsync();
            }
    
            await using (var dbContextToRead = fixture.CreateDbContext())
            {
                Assert.Equal(1, dbContextToRead.Articles.Count(x=> x.Url == "example-url"));
            }
        }
    
        [Fact]
        public async Task try_double_insert_with_unique_constraint()
        {
            var url = "url-should-be-unique";
            await using var dbContextToSave = fixture.CreateDbContext();
            dbContextToSave.Articles.Add(new Article
            {
                Id = Guid.NewGuid(),
                Url = url,
                Title = "title one",
                Content = "Content one..."
            });
            await dbContextToSave.SaveChangesAsync();
            dbContextToSave.Articles.Add(new Article
            {
                Id = Guid.NewGuid(),
                Url = url,
                Title = "title two",
                Content = "Content two..."
            });
            var exception = await Assert.ThrowsAsync<DbUpdateException>(async () => await dbContextToSave.SaveChangesAsync());
            Assert.Contains("IX_Articles_Url", exception.InnerException!.Message);
        }
    }
    

Fantastic. It is time to run these test and check containers with docker ps.

CONTAINER ID   IMAGE                                                   CREATED          PORTS
2abd95cce2e5   mcr.microsoft.com/mssql/server:2019-CU18-ubuntu-20.04   17 seconds ago   0.0.0.0:62044->1433/tcp
243336dfd2b8   testcontainers/ryuk:0.6.0                               18 seconds ago   0.0.0.0:62041->8080/tcp

Success. There is only one container for test. Fantastic.

More reducing

You can reduce it even more by using with AssemblyFixture, but unfortunately it will be available in xUnit V3. Currently, at the moment of writing the article it is in beta .

Use testcontainers modules

As you can see in my previous examples I built ConnectionString manually. It works, but some of popular services like: SqlServer, Kafka, RabbitMQ, Redis, etc. there are ready to use modules. Here is an example for SqlServer:

    private async Task InitSql()
    {
        var container = new MsSqlBuilder()
        .WithImage("mcr.microsoft.com/mssql/server:2019-CU18-ubuntu-20.04")
        .Build();
        await container.StartAsync();


        var connectionStringBuilder = new SqlConnectionStringBuilder(container.GetConnectionString())
        {
            InitialCatalog = "test-database",
        };

        ConnectionString = connectionStringBuilder.ConnectionString;
    }
You can read a bit more about ready to use modules on Testcontainers modules page As you can see it is much easier.

Summary

Since I found out about testcontainers I write integrations tests more often. What is you approach? Is is useful for you? Would you like to add or ask anything? Let me know in the comment below.

See you next time.

comments powered by Disqus

Are you still here? Subscribe for more content!