建立測試用 Container 前先建立 Docker Image - 使用 FluentDocker

前一篇文章說明了在進行 Repository  時使用 Testcontainer  建立測試用 MS SQL Server  容器

如果另外一個專案並非使用 MS SQL Server  而是使用 MongoDB,而且為了測試需要必須要使用自己所打包好的 Docker Image  來建立容器,

Testcontainers 是有提供 Docker Image Build  的功能,但是我卻在建立 Docker Image  時一直出現錯誤,

在無法使用 Testcontainers 解決問題的情況下,我又想起了之前一直在用的 FluentDocker,FluentDocker  有支援 Docker Compose  功能,

於是這就是這篇文章所要說明的內容

使用 Testcontainers 建立 Docker Image

首先來看看 Testscontsiners  所提供的建立 Docker Image 功能

Testcontainers - Creating an image

var futureImage = new ImageFromDockerfileBuilder()
  .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty)
  .WithDockerfile("Dockerfile")
  .Build();

await futureImage.CreateAsync()
  .ConfigureAwait(false);

要使用 Testcontainers  建立 docker image 前要在 Project  裡先建立好一份 Dockerfile 檔案,

然後在程式裡指定 Dockerfile 的位置,指定 Docker Image  的名稱,執行 Build  與 CreateAsycnc 方法後就可以建立 Docker Image,

例如我有一個網站整合測試專案,而這個整合測試是需要使用到 RabbitMQ,使用 Testcontainers 建立 RabbitMQ 容器不是難事,

麻煩的是建立好的 RabbitMQ  容器是要有可以登入的帳號密碼、Virtual Host 以及眾多繁複的設定等,

如果要在 RabbitMQ  容器建立後再去做設定就會很麻煩,最好的方式就是在建立容器時就可以將這些設定給完成,讓整合測試可以順利完成,

我在專案裡準備好 Dockerfile [ Rabbitmq_Dockerfile ]  以及建立 docker image 時要加入的檔案

然後使用 Testcontsiners  建立 docker image

用這樣的方式就不必要事先建立好客製化的 RabbitMQ  容器映象檔,而是可以在程式裡從建立 Docker Image、使用剛完成的 Docker Image 直接建立 Docker Container。

 

建立客製化 MongoDB Docker Image 時出現了狀況

在另外的專案裡有使用了 MongoDB,所以在做 Repository 測試時就很直覺地想要使用 Testcontainers 去建立客製化的 MongoDB Docker Image,不過卻在這裡遇到了麻煩,

因為我也是希望可以先將 MongoDB 的測試環境在建立 Docker Image 時就可以準備好,然後就可以將 Container  建立好後就可以直接使用,

我參考了這一篇文章的內容,把建立 MongoDB Docker Image 所需要的檔案都給準備好,

Initialize MongoDB running on a Docker container | by Ivan Polovyi | FAUN — Developer Community

這個測試專案是使用 xUnit,所以在 MongoFixture.cs 裡進行 Docker Image 與 Container 的建立

就在以為一切都會順利,執行測試後卻看到這樣的結果

當下檢查是否沒有完成 MongoDB Docker Image  的建立呢?但是有看到 Docker Image 確實有建立

但怎麼會發生這種事情呢?是我所準備的 Dockerfile 和相關檔案有問題嗎?

所以我在 Terminal 裡直接下 docker build  指令建立 docker image

可以看到整個建置過程是很順利,而且也完成 docker image 的建立

直接用這個建立好的 MongoDB Docker Image  來建立 Container 看看,看看是不是可以正常運作

用 Mongo Compass 連連看…  看來一切正常呀…

嘗試了很多的方式都無法解決,最後我放棄使用 Testcontainers  建立 MongoDB Docker Image  的步驟,改用其他的方式試試看。

 

使用 FluentDocker  建立 Docker Image

我想到之前所使用 FluentDocker 也是有提供 Create Docker Image 的功能,所以我就改用 FluentDocker 來試試看

FluentDocker

FluentDocker 也是個不錯的工具,在我改用 Testcontainers 之就是使用 FluentDocker  在測試時建立相依使用的測試服務容器,它所提供建立容器的指令如下

      using (
        var container =
          new Builder().UseContainer()
            .UseImage("kiasaki/alpine-postgres")
            .ExposePort(5432)
            .WithEnvironment("POSTGRES_PASSWORD=mysecretpassword")
            .WaitForPort("5432/tcp", 30000 /*30s*/)
            .Build()
            .Start())
      {
        var config = container.GetConfiguration(true);
        Assert.AreEqual(ServiceRunningState.Running, config.State.ToServiceState());
      }

因為我的測試專案是 xUnit,所以我在專案裡直接安裝 Ductus.FluentDocker.xUnit

NuGet Gallery | Ductus.FluentDocker.XUnit 2.10.59

FluentDocker  所提供的建立 Container  方式如下,我們單純建立 docker image 的話只要使用 using(…)  中間的程式即可

using (var services = new Builder()

  .DefineImage("mariotoffia/nodetest").ReuseIfAlreadyExists()
  .FromFile("/tmp/Dockerfile")
  .Build().Start())
{
 // Container either build to reused if found in registry and started here.
}

接著就來改造我的 CreateMongoDockerImage 方法

執行測試…  卻看到這樣的結果

難道我就沒辦法在這個測試裡達成建立 docker image 與 container 的自動化步驟嗎?

 

改用 Docker Compose  來建立 Docker Image

是的,我想到 FluentDocker   是有支援 Docker Compose  的功能,然而 Testcontainers  卻沒有支援這個功能…

FluentDocker - Docker Compose Support

// 這是 FluentDocker 所給的範例

      var file = Path.Combine(Directory.GetCurrentDirectory(),
        (TemplateString) "Resources/ComposeTests/WordPress/docker-compose.yml");

      using (var svc = new Builder()
                        .UseContainer()
                        .UseCompose()
                        .FromFile(file)
                        .RemoveOrphans()
                        .Build().Start())
      {
        var installPage = await "http://localhost:8000/wp-admin/install.php".Wget();

        Assert.IsTrue(installPage.IndexOf("https://wordpress.org/", StringComparison.Ordinal) != -1);
        Assert.AreEqual(1, svc.Hosts.Count);
        Assert.AreEqual(2, svc.Containers.Count);
        Assert.AreEqual(2, svc.Images.Count);
        Assert.AreEqual(5, svc.Services.Count);
      }

不過我卻沒有想要用 Docker Compose 來建立測試所相依的服務容器,因為我還是希望 Container Port  以及一些設定是可以由程式裡去做指定,所以我只有使用執行 Docker Compose 來建立 Docker Image

這邊先建立好一個 docker-compose.yml  檔案,要注意這個  docker-compose.yml 檔案是跟 Dockerfile  放在同一個位置

修改建立 Docker Image 的方法,在方法裡是使用 using( … )  將執行 docker-compose.yml 的程式給包起來,所以當方法執行完畢後,會依照 docker-compose.yml  的內容建立 Docker-Image 而且也會建立 Docker-Container,但是這個 Docker-Container 並沒有設定 Expose Port,無法在測試裡使用,所以當完成建立 Docker-Image 與 Docker-Container 之後沒有做什麼事情就會結束並移除 Docker-Container,但是剛才所建立的 Docker-Image 依然會留下來讓後續使用。

/// <summary>
/// 建立測試用的 MongoDB Docker-image.
/// </summary>
private static void CreateMongoDockerImage()
{
	// 使用 FluentDocker (https://github.com/mariotoffia/FluentDocker)
	// 透過 FluentDocker 的 UseCompose 功能執行 docker-compose.yml
	// 執行 docker-compose 就會建立 MongoDB docker-image
	var dockerComposeFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Docker", "Mongo", "docker-compose.yml");
	using var svc = new Builder().UseContainer().UseCompose().FromFile(dockerComposeFile).RemoveOrphans().Build().Start();
}

而我建立 MongoDB Container 的方法還是依然維持使用 Testcontainers,因為有提供非同步方法,而且 xUnit  在繼承 IAsyncLifetime 要實做非同步的 Initializeasync 與 DisposeAsync 方法,

完整的 MongoFixture.cs 程式內容

public class MongoFixture : ProjectFixture, IAsyncLifetime
{
    private IContainer _dbContainer;

    public MongoFixture()
    {
        this.CreateMongoDockerImage();
        
        // FluentAssertions - Setup DateTime AssertionOptions
        SetupDateTimeAssertions();

        // MongoDb Class Mapping
        Mapping.RegisterClassMapping();
    }

    public async Task InitializeAsync()
    {
        using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
        await this.CreateRabbitMqContainerAsync(cts.Token);
    }

    public async Task DisposeAsync()
    {
        await this._dbContainer.StopAsync();
    }

    /// <summary>
    /// 建立測試用的 MongoDB Docker-image.
    /// </summary>
    private void CreateMongoDockerImage()
    {
        // 使用 FluentDocker (https://github.com/mariotoffia/FluentDocker)
        // 透過 FluentDocker 的 UseCompose 功能執行 docker-compose.yml
        // 執行 docker-compose 就會建立 MongoDB docker-image
        var dockerComposeFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Docker", "Mongo", "docker-compose.yml");
        using var svc = new Builder().UseContainer().UseCompose().FromFile(dockerComposeFile).RemoveOrphans().Build().Start();
    }

    /// <summary>
    /// 建立測試用的 MongoDB Docker-container.
    /// </summary>
    /// <param name="cancellationToken"></param>
    private async Task CreateRabbitMqContainerAsync(CancellationToken cancellationToken)
    {
        var databaseSettings = TestSettingProvider.GetDatabaseSettings();
        DatabaseIp = "127.0.0.1";
        DatabasePort = databaseSettings.HostPort;

        var environmentName = TestSettingProvider.GetEnvironmentName();
        var containerName = databaseSettings.ContainerName;

        // Create MongoDB Container
        this._dbContainer = new ContainerBuilder()
                            .WithImage($"{databaseSettings.Image}:{databaseSettings.Tag}")
                            .WithEnvironment(databaseSettings.EnvironmentSettings)
                            .WithName($"{environmentName}-{containerName}")
                            .WithPortBinding(databaseSettings.HostPort, databaseSettings.ContainerPort)
                            .WithAutoRemove(true)
                            .WithWaitStrategy(Wait.ForUnixContainer()
                                                  .UntilPortIsAvailable(databaseSettings.ContainerPort)
                                                  .UntilMessageIsLogged(databaseSettings.ContainerReadyMessage))
                            .Build();

        await this._dbContainer.StartAsync(cancellationToken);
    }

    /// <summary>
    /// FluentAssertions - Setup DateTime AssertionOptions
    /// </summary>
    private static void SetupDateTimeAssertions()
    {
        // FluentAssertions 設定 : 日期時間使用接近比對的方式,而非完全一致的比對
        AssertionOptions.AssertEquivalencyUsing(options =>
        {
            options.Using<DateTime>(ctx => ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromMilliseconds(1000)))
                   .WhenTypeIs<DateTime>();

            options.Using<DateTimeOffset>(ctx => ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromMilliseconds(1000)))
                   .WhenTypeIs<DateTimeOffset>();

            return options;
        });
    }
}

可能有些人會覺得說怎麼不直接就在 docker-compose.yml 檔案裡直接將執行時的設定與環境變數等直接寫好,程式裡只需要執行 docker-compose 就可以將相依服務的容器給建立好…

不是說不可以,最主要還是那個我很堅持每次 所建立的 Docker-Container 所使用的 Expose Port  一定要是隨機產生而不能直接固定寫死同一組,因為這些測試專案是有可能在 CI 過程裡執行,同一個建置環境可能會有多個 Agent 在同時執行,要是別的專案也在 CI  過程裡有執行測試也剛好會用到 Docker,是有機會會使用到一樣的 Port,總不能大家互相影響而導致所有的 CI Task 都壞掉吧。

而為什麼要在同一個測試專案裡用了 Testcontainers 與 FluentDocker 呢?為何不乾脆就統一使用 FluentDocker 呢?

文章裡也有說到,因為是 xUnit 的測試專案,可以繼承 IAsyncLifetime 介面實做 InitializeAsync 與 DisposeAsync 兩個非同步方法,而 Testcontainers 本身所提供的方法就是非同步所以優先使用,如果測試專案是使用 MSTest 的話,那麼我就會統一使用 FluentDocker 了。

以上

分享