2024-09-04-the-best-way-for-integrations-tests-with-testcontainers
Testing is important. Test are more important. There are multiple ways to categorize tests like [Unit, Integration, E2E] or [BlackBox, WhiteBox] or [Smoke, Regression] etc. In some cases Unit Tests are enough, because writing integration test are simples in some cases. When? For example when you use Sql or when you have a dependency to an external service like Apache Kafka
The whole project is in this repository in
In one of the previous post I have described how to configure EntityFramework migration. 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 it noMy idea it not to create a complex scenario, but to show you how to create a docker container for integration test purposes. So I will focus on the most common scenario for .NET like App + EntityFramework + SqlServer. So the scenario will contain:
- Creating
SqlServercontainer for a test case - Run migration to have a correct structure
- Execute Insert SQL
- Execute Select SQL
- Test unique constraint in that scenario
The simples 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 to 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 a port can be assigned to only one container you need to:
- Define the port you want to expose with
.WithPortBinding(1433, true) - and later get public port with
container.GetMappedPublicPort(1433)Then testcontainers will take care about ports.
- Define the port you want to expose with
So when it works, let see on out 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. IF you don’t believe you can clone the repo and run it on your environemnt. As I mentioned at the beggining that approach has some vulnerabilities. I mean for each test new container is created. So when we execute: docker ps.
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 suggest to use xUnit, so I am going to describe how to achieve it. There is a possibility to share resources between tests in one class. I know that theory tells us that tests should be independent. In most cases it is true, but sometime 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 ses it is the same code as previous, but in a separate class.
2. 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. Fantascic.
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;
}
As you can see it is much easier.
Summary
It is everything for today. Is is useful for you? Would you like to add or ask anything? Let me know in the comment below. See you next time.
