Running Unit Tests in Docker

Serhii Korol - Sep 24 '23 - - Dev Community

Hello everyone. In this article, I'll show you how to run unit tests more quickly and conveniently. We won't create an application with some logic; we'll make only a project affiliated with unit tests.

Let's create a console application:

dotnet new console -n TestsInDockerSample
Enter fullscreen mode Exit fullscreen mode

The Program.cs class you can delete, it no need. We'll begin by adding NuGet packages, and you should add all of these:

<PackageReference Include="JetBrains.Annotations" Version="2023.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0-preview-23424-02" />
<PackageReference Include="Npgsql" Version="8.0.0-preview.4" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.5.0" />
<PackageReference Include="xunit" Version="2.5.2-pre.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.2-pre.3">
Enter fullscreen mode Exit fullscreen mode

The JetBrains.Annotations package isn't mandatory, and it needed more for IDE and convenient debugging.
For the implementation, we'll start with adding a fixture class. Please add DbFixture.cs class to the root folder of your project. Also, paste this code, and I'll explain what is going on there:

public sealed class DbFixture : IAsyncLifetime
{
    private readonly INetwork _network = new NetworkBuilder().Build();

    private readonly PostgreSqlContainer _postgreSqlContainer;

    private readonly IContainer _flywayContainer;

    public DbFixture()
    {
        _postgreSqlContainer = new PostgreSqlBuilder()
            .WithImage("postgres:latest")
            .WithNetwork(_network)
            .WithNetworkAliases(nameof(_postgreSqlContainer))
            .Build();

        _flywayContainer = new ContainerBuilder()
            .WithImage("flyway/flyway:latest")
            .WithResourceMapping("migrate/", "/flyway/sql/")
            .WithCommand("-url=jdbc:postgresql://" + nameof(_postgreSqlContainer) + "/")
            .WithCommand("-user=" + PostgreSqlBuilder.DefaultUsername)
            .WithCommand("-password=" + PostgreSqlBuilder.DefaultPassword)
            .WithCommand("-connectRetries=3")
            .WithCommand("migrate")
            .WithNetwork(_network)
            .DependsOn(_postgreSqlContainer)
            .WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(new MigrationCompleted()))
            .Build();
    }

    public DbConnection DbConnection => new NpgsqlConnection(((PostgreSqlContainer)_postgreSqlContainer).GetConnectionString());

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

    public Task DisposeAsync()
    {
        return Task.CompletedTask;
    }

    private sealed class MigrationCompleted : IWaitUntil
    {
        public Task<bool> UntilAsync(IContainer container)
        {
            return Task.FromResult(TestcontainersStates.Exited.Equals(container.State));
        }
    }
Enter fullscreen mode Exit fullscreen mode

So, let's figure it out. As you may have noticed, the main class is derived from IAsyncLifetime. This is part of XUnit. This interface contains only two methods that need to be implemented:

public interface IAsyncLifetime
{
    Task InitializeAsync();
    Task DisposeAsync();
}
Enter fullscreen mode Exit fullscreen mode

InitializeAsync is responsible for initializing something. In our case, this is launching a container. And upon completion, we clean the resources. We use two containers. The first is the Postgres database, and the second is the Flyway container that performs the migration. As you might guess, this class waits for the migration to complete.

In the next step, you should create a new folder called “migrations”. And put it in these SQL commands. They need migration and seeding at the same time.

CREATE TABLE users
(
    id         SERIAL PRIMARY KEY,
    username   VARCHAR(50) NOT NULL,
    email      VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE users ADD COLUMN age INT;
INSERT INTO users (username, email, age) VALUES ('john_doe', 'john@example.com', 30);
Enter fullscreen mode Exit fullscreen mode

And another remark: go to your cproj file and add these rows. It needs to detect this folder.

<ItemGroup>
    <None Include="migrate/*.sql" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

And finally, let's add a test. You need to create a new class named UserTests. Paste this code:

public sealed class UserTests : IClassFixture<DbFixture>, IDisposable
{
    private readonly DbConnection _dbConnection;

    public UserTests(DbFixture db)
    {
        _dbConnection = db.DbConnection;
        _dbConnection.Open();
    }

    public void Dispose()
    {
        _dbConnection.Dispose();
    }

    [Fact]
    public void UsersTableContainsJohnDoe()
    {
        // Arrange
        using var command = _dbConnection.CreateCommand();
        command.CommandText = "SELECT username, email, age FROM users;";

        // Act
        using var dataReader = command.ExecuteReader();

        // Assert
        Assert.True(dataReader.Read());
        Assert.Equal("john_doe", dataReader.GetString(0));
        Assert.Equal("john@example.com", dataReader.GetString(1));
        Assert.Equal(30, dataReader.GetInt32(2));
    }
}
Enter fullscreen mode Exit fullscreen mode

If you run this simple test, you can see how Docker creates new images with the database, and after finishing the test, they are disposed of.

And as a bonus, let's add the pipeline for GitHub Actions.

name: .NET

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    - name: Setup .NET
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: 7.0.x
    - name: Restore dependencies
      run: dotnet restore
    - name: Build
      run: dotnet build --no-restore
    - name: Test
      run: dotnet test --no-build --verbosity normal
Enter fullscreen mode Exit fullscreen mode

If your action passed successfully, then go to GitHub and check this out:

result

The test was passed, and it took 33 seconds. For this test, it is more than enough. However, tests run automatically without your attending.

That's all. Have a nice day, and happy coding.

Source code: HERE

Buy Me A Beer

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .