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:
- Creating
SqlServer
container for a test case - Run migration to have a correct structure
- Execute Insert SQL
- Execute Select SQL
- 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
- Define the port you want to expose with
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?
- 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.
- 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;
}
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.