Getting started with Testcontainers for .NET

  • .NET
  • PostgreSQL
Get the code

Testcontainers is a testing library that provides easy and lightweight APIs for bootstrapping integration tests with real services wrapped in Docker containers. Using Testcontainers, you can write tests that talk to the same type of services you use in production, without mocks or in-memory services.

NoteIf you are new to Testcontainers, please read What is Testcontainers, and why should you use it? to learn more about it.

Let us look at how we can use Testcontainers to test a .NET application using a Postgres database.

Create a solution file with a source and test project

Create a .NET source and test projects from your terminal or favorite IDE.

$ dotnet new sln -o TestcontainersDemo
$ cd TestcontainersDemo
$ dotnet new classlib -o CustomerService
$ dotnet sln add ./CustomerService/CustomerService.csproj
$ dotnet new xunit -o CustomerService.Tests
$ dotnet sln add ./CustomerService.Tests/CustomerService.Tests.csproj
$ dotnet add ./CustomerService.Tests/CustomerService.Tests.csproj reference ./CustomerService/CustomerService.csproj

Once the projects are created, add the Npgsql dependency to the source project as follows:

dotnet add ./CustomerService/CustomerService.csproj package Npgsql

Now you should have the following dependencies in the source and test projects.

Source project
<PackageReference Include="Npgsql" Version="7.0.4" />
Test project
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"/>

We have added Npgsql, a Postgres ADO.NET Data Provider for talking to the Postgres database, and using xUnit for testing our service.

Implement business logic

We are going to create a CustomerService class to manage customer details.

First, let us create a Customer type as follows:

namespace Customers;

public readonly record struct Customer(long Id, string Name);

Create a DbConnectionProvider class to hold ADO.NET connection parameters (connection string) and create a method to get a database connection, as follows:

using System.Data.Common;
using Npgsql;

namespace Customers;

public sealed class DbConnectionProvider
{
    private readonly string _connectionString;

    public DbConnectionProvider(string connectionString)
    {
        _connectionString = connectionString;
    }

    public DbConnection GetConnection()
    {
        return new NpgsqlConnection(_connectionString);
    }
}

Create a CustomerService class and add the following code:

namespace Customers;

public sealed class CustomerService
{
    private readonly DbConnectionProvider _dbConnectionProvider;

    public CustomerService(DbConnectionProvider dbConnectionProvider)
    {
        _dbConnectionProvider = dbConnectionProvider;
        CreateCustomersTable();
    }

    public IEnumerable<Customer> GetCustomers()
    {
        IList<Customer> customers = new List<Customer>();

        using var connection = _dbConnectionProvider.GetConnection();
        using var command = connection.CreateCommand();
        command.CommandText = "SELECT id, name FROM customers";
        command.Connection?.Open();

        using var dataReader = command.ExecuteReader();
        while (dataReader.Read())
        {
            var id = dataReader.GetInt64(0);
            var name = dataReader.GetString(1);
            customers.Add(new Customer(id, name));
        }

        return customers;
    }

    public void Create(Customer customer)
    {
        using var connection = _dbConnectionProvider.GetConnection();
        using var command = connection.CreateCommand();

        var id = command.CreateParameter();
        id.ParameterName = "@id";
        id.Value = customer.Id;

        var name = command.CreateParameter();
        name.ParameterName = "@name";
        name.Value = customer.Name;

        command.CommandText = "INSERT INTO customers (id, name) VALUES(@id, @name)";
        command.Parameters.Add(id);
        command.Parameters.Add(name);
        command.Connection?.Open();
        command.ExecuteNonQuery();
    }

    private void CreateCustomersTable()
    {
        using var connection = _dbConnectionProvider.GetConnection();
        using var command = connection.CreateCommand();
        command.CommandText = "CREATE TABLE IF NOT EXISTS customers (id BIGINT NOT NULL, name VARCHAR NOT NULL, PRIMARY KEY (id))";
        command.Connection?.Open();
        command.ExecuteNonQuery();
    }
}

Let us understand what is going on in the CustomerService class.

  • _dbConnectionProvider.GetConnection() gets a database connection using ADO.NET.

  • CreateCustomersTable() method creates the customers table if it does not already exist.

  • GetCustomers() method fetches all rows from the customers table, populates data into Customer objects, and returns a list of Customer objects.

  • Create(Customer) method inserts a new customer record into the database.

Now let us see how we can test the CustomerService logic using Testcontainers.

Add Testcontainers dependencies

Before writing Testcontainers-based tests, let’s add Testcontainers PostgreSql module dependency to the test project as follows:

dotnet add ./CustomerService.Tests/CustomerService.Tests.csproj package Testcontainers.PostgreSql

Now your test project should have Testcontainers.PostgreSql dependency added as follows:

Test project
<PackageReference Include="Testcontainers.PostgreSql" Version="3.3.0" />

As we are using a Postgres database for our application, we added the Testcontainers Postgres module as a test dependency.

Write test using Testcontainers

Create a CustomerServiceTest class in the test project with the following code:

using Testcontainers.PostgreSql;

namespace Customers.Tests;

public sealed class CustomerServiceTest : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:15-alpine")
        .Build();

    public Task InitializeAsync()
    {
        return _postgres.StartAsync();
    }

    public Task DisposeAsync()
    {
        return _postgres.DisposeAsync().AsTask();
    }

    [Fact]
    public void ShouldReturnTwoCustomers()
    {
        // Given
        var customerService = new CustomerService(new DbConnectionProvider(_postgres.GetConnectionString()));

        // When
        customerService.Create(new Customer(1, "George"));
        customerService.Create(new Customer(2, "John"));
        var customers = customerService.GetCustomers();

        // Then
        Assert.Equal(2, customers.Count());
    }
}

Let us understand the code in our CustomerServiceTest class.

  • Declare PostgreSqlContainer by passing the Docker image name postgres:15-alpine to the Postgres builder.

  • The Postgres container is started using xUnit.net’s IAsyncLifetime interface, which executes InitializeAsync immediately after the test class has been created.

  • ShouldReturnTwoCustomers() test initializes CustomerService, insert two customer records into the database, fetch all the existing customers, and assert the number of customers.

  • Finally, the Postgres container is disposed in the DisposeAsync() member, which gets executed after the test method is executed.

Now let’s run the tests as follows:

dotnet test

By running the customer service test, you can see in the output that Testcontainers pulled the Postgres Docker image from DockerHub if it’s not already available locally, started the container, and executed the test.

Voila! You have your first Testcontainers-based test running.

Conclusion

We have explored how to use Testcontainers for .NET library to test a .NET application using a Postgres database.

We have seen how writing an integration test using Testcontainers is very similar to writing a unit test that you can run from your IDE. Also, any of your teammates can clone the project and run tests without installing Postgres on their computers.

In addition to Postgres, Testcontainers provides dedicated modules for many commonly used SQL databases, NoSQL databases, messaging queues, etc. You can use Testcontainers to run any containerized dependency for your tests!

You can explore more about Testcontainers at https://www.testcontainers.com/.