Update 29.01.2024I have adapted the sample code to .NET Core 8 and the latest NuGet packages. Furthermore, the code is now on GitLab instead of GitHub.
Are integration tests useful?
The question of whether integration tests are useful or even necessary comes up again and again in our training sessions. Our clear answer is: yes, integration tests are an essential part of a healthy test strategy. And integration tests with Docker are a very simple solution for many challenges.
Definition of
An integration test is a test that tests a class or method including its dependencies. Mocks are not used initially, but the real dependencies are used. The reason: If the dependencies are mocked away in a test, we still do not know at the end whether the integration of the individual components is successful. It is therefore essential to write real integration tests against the real dependencies. This is where the big "but" comes in: but I can't address the database in the test. Yes, that's exactly the point. There must be a certain number of tests that run against the database used in production. Replacing the real database technology used with an in-memory solution does not correspond to the concept of an integration test either.
Strategy
Our clear recommendation is to start the integration tests with tests that also test the real dependencies. Once a certain number of these are available and confidence that the integration will be successful increases, there is no reason not to add tests against dummies. However, this should only be the second step. As long as the real integration tests run quickly and are easy to create, there is no reason why dummies should not be used in integration tests.
And I already hear the next "but": but how am I supposed to provide the database with defined initial values for each test? With Docker! By the way, I'm using "database" here as a proxy for resource access. Whether the software system actually requires a database or other resources such as the file system, messaging systems, etc. plays a subordinate role for further consideration.
Container to the rescue
There is a very elegant way to provide required infrastructure such as databases, messaging solutions, etc. in test and production: Docker Container. For example, if an application requires a MongoDB database, this can be made available very easily using Docker. This is not only possible in production but also during tests. The project helps with this TestContainers.
This project provides classes with which Docker containers can be easily started in the test. For .NET, the NuGet package Test container be added to the test project.
Example
Under the following link you will find a GitLab repo with an example project that demonstrates the ideas presented here. The code shown here is taken from this example.
https://gitlab.com/ccd-akademie-gmbh/testcontainers
This sample application provides customer data from a MongoDB database via HTTP. New customer data can also be stored there. This is a constructed example to demonstrate the procedure for integration tests with test containers. Therefore, please focus on the test code and less on the implementation.
Clean code training
Dates of the individual training days:
Closed company courses
We conduct all seminars as closed company courses for you.
If you are interested or have any questions please contact us.
Basis for integration tests with Docker
I have created a base class for the integration tests that starts a MongoDB container before a test and terminates it after the test:
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using NUnit.Framework;
namespace testcontainerexample.tests;
public class IntegrationTestsBase
{
private IContainer? _testcontainers;
private readonly int _mongoDbPort = 27017;
[SetUp]
public async Task Setup() {
Config.Load(".env");
var containerBuilder = new ContainerBuilder()
.WithImage("docker.io/mongo")
.WithPortBinding(_mongoDbPort, assignRandomHostPort: true)
.WithEnvironment("MONGO_INITDB_ROOT_USERNAME", "test")
.WithEnvironment("MONGO_INITDB_ROOT_PASSWORD", "test")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(_mongoDbPort));
_testcontainers = containerBuilder.Build();
await _testcontainers.StartAsync();
Config.MongoDb.Port = _testcontainers.GetMappedPublicPort(_mongoDbPort);
Config.MongoDb.Host = _testcontainers.Hostname;
}
[TearDown]
public async Task TeardownBase() {
await _testcontainers!.StopAsync();
await _testcontainers.DisposeAsync();
}
}
The NUnit Attribute SetUp causes the method marked in this way to be executed before each test. The same applies to TearDownThe method attributed in this way is executed after each test. The result of the code shown above is that a Docker container with the "mongo" image is provided for each test. After each test method has been run, the container is discarded. This achieves maximum test isolation. Each individual test runs on a "fresh" instance of MongoDB.
However, this also means that the database is always completely empty. If a test is to read existing data from the database, it must first be written to the database. Examples will follow later.
In addition to MongoDB, many other ready-made test containers are available, e.g. for
- MS SQL Server
- MySQL
- Postgres
- RabbitMQ
- Kafka
ASP.NET Core 8 integration tests
For the integration tests of the ASP.NET Controller it is necessary to install the NuGet package Microsoft.AspNetCore.Mvc.Testing to be integrated. This allows the controllers to be tested via HTTP without having to run through the browser. The complete logic that leads to the call of a controller method within ASP.NET is therefore tested. The application is started for this purpose. For this to work, the class WebApplicationFactory the Program class can be made available as a type.
_webAppFactory = new WebApplicationFactory();
_httpClient = _webAppFactory.CreateDefaultClient();
Newfangled stuff
And there is a small pitfall here: recently, the Main method no longer needs to be explicitly included in a class definition. The compiler does this implicitly. However, the class Program implicit internal. So that the Program class is visible in the test project, a little trick is required: add a public Partial Class is added. Alternatively, you can implement the class as usual. Rider also offers an option for conversion.
public partial class Program { }
Tests
After these preparations, integration tests can now be written that find a real MongoDB instance.
using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using Newtonsoft.Json;
using NUnit.Framework;
using testcontainerexample.providers;
namespace testcontainerexample.tests;
[TestFixture]
public class CustomerControllerTests : IntegrationTestsBase
{
private WebApplicationFactory _webAppFactory = null!
private HttpClient _httpClient = null!
[SetUp]
public void Setup2() {
_webAppFactory = new WebApplicationFactory();
_httpClient = _webAppFactory.CreateDefaultClient();
}
[TearDown]
public async Task Teardown() {
await _webAppFactory.DisposeAsync();
_httpClient.Dispose();
}
[Test]
public async Task Get_all_on_empty_database_returns_empty_result() {
var response = await _httpClient.GetAsync("customer");
var stringResult = await response.Content.ReadAsStringAsync();
Assert.That(stringResult, Is.EqualTo("[]"));
}
[Test]
public async Task Customers_can_be_added_to_database() {
var content = JsonConvert.SerializeObject(TestData.Customers.Customer1);
var stringContent = new StringContent(content, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("customer", stringContent);
var stringResult = await response.Content.ReadAsStringAsync();
Assert.That(stringResult, Is.EqualTo("Success. Id = 0001"));
}
[Test]
public async Task Added_customers_can_be_retrieved() {
var customerProvider = new CustomerProvider();
await customerProvider.Add(TestData.Customers.Customer1);
await customerProvider.Add(TestData.Customers.Customer2);
var response = await _httpClient.GetAsync("customer");
var stringResult = await response.Content.ReadAsStringAsync();
Assert.That(stringResult, Is.EqualTo("[{\"id\":\"0001\",\"name\":\"Peter\"},{\"id\":\"0002\",\"name\":\"Paul\"}]"));
}
}
Implementation of the controller
A very simple controller is used for this example:
using Microsoft.AspNetCore.Mvc;
using testcontainerexample.contracts;
using testcontainerexample.domain;
namespace testcontainerexample.Controllers;
[ApiController]
[Route("[controller]")]
public class CustomerController : ControllerBase
{
private readonly ILogger _logger;
private readonly Interactors _interactors;
public CustomerController(ILogger logger, Interactors interactors) {
_logger = logger;
_interactors = interactors;
}
[HttpGet]
public Task<List> Get() {
_logger.LogDebug("Get all customers");
return _interactors.GetAllCustomers();
}
[HttpPost]
public async Task Post([FromBody] Customer customer) {
_logger.LogDebug("Post customer '{Name}'", customer.Name);
var customerId = await _interactors.AddCustomer(customer);
return $ "Success. Id = {customerId}";
}
}
The class Interactors is also very simple. It simply forwards the calls to the class CustomerProvider continue.
using testcontainerexample.contracts;
using testcontainerexample.providers;
namespace testcontainerexample.domain;
public class Interactors
{
private readonly CustomerProvider _customerProvider;
public Interactors(CustomerProvider customerProvider) {
_customerProvider = customerProvider;
}
public Task<List> GetAllCustomers() {
return _customerProvider.GetAll();
}
public async Task AddCustomer(Customer customer) {
return await _customerProvider.Add(customer);
}
}
using MongoDB.Driver;
using testcontainerexample.contracts;
namespace testcontainerexample.providers;
public class CustomerProvider : MongoDbProviderBase
{
private const string CustomersCollection = "customers";
public async Task<List> GetAll() {
var seminars = _database.GetCollection(CustomersCollection);
var asyncCursor = await seminars.FindAsync(_ => true);
var result = await asyncCursor.ToListAsync();
return result;
}
public async Task Add(Customer customer) {
var customers = _database.GetCollection(CustomersCollection);
await customers.InsertOneAsync(customer);
return customer.Id;
}
}
Related articles
In the article TDD vs. test-first you will find a definition of the various test categories and information on the test pyramid.
In our training Clean Code Developer Tests you can find out more details about testing. If you are interested in learning how an application should be structured so that it can be easily tested automatically, the seminars Clean Code Developer Basics and Clean Code Developer Advanced the right thing for you.
Conclusion
Integration tests are an essential part of an intact test strategy. If you only rely on unit tests of the smallest units, you will not receive any feedback on whether the interaction of these small units works correctly. Replacing all dependencies with dummies also prevents real integration tests.
In the case of legacy code, integration tests are an essential strategy in order to be able to put the code under test at all. Unit tests are often not possible here because the smallest units are often too large and have too many responsibilities. Integration tests are therefore essential on greenfield and brownfield sites.