Repository 測試使用 Testcontainers

在 MSTest 測試專案裡使用 Testcontainers 建立 MS SQL Server 資料庫對 Repository  進行測試

測試專案裡的測試資料庫建立方式

很久以前要對 Repository 進行測試,在以前的文章裡就有介紹過使用 LocalDB 來進行,

但那是好多年前的事情了,那是個 Docker 技術還不是成熟與普遍的時候,

2017 年之後 Docker  技術開始被越來越多人所接受和應用後,我就試著在測試專案裡建立測試用的 MS SQL Server Container  來做為測試時的資料庫,

那時候不像現在有這麼多種很多套件、方法可以讓我們很方便地使用,

所以我試著自己使用 CliWrap  這個 NuGet Package  做了一個在程式裡執行 Docker CLI  指令的工具,藉此來做到 Container  的建立、移除等指令的處理,

雖然不是很漂亮地實做,但也還只是個堪用、能用,剛好可以解決我在 Repository  測試時建立測試資料庫的需求,

之所以不再繼續用 LocalDB  的原因就在於跨作業系統,因為專案的建置與部署已經不再如往常只有 Windows 環境一種選擇而已,Linux  也是一個選項,所以跨作業系統環境下就會選擇使用 Docker 容器化技術。

 

前面說到一開始是自己用 CliWrap  這個套件建立了 DockerSupports 工具來做到 Container  的建立、移除等指令的處理,

但因為做得不是很彈性、抽象、泛用,所以當專案裡所相依使用的第三方服務越來越多元的時候,就必須要在 DockerSupports 裡面去因應各種服務增加不同服務的容器建立與移除方法,

到最後就覺得繼續維護、擴充 DockerSupports  這個工具實在是一件累人又吃力不討好的事情,就開始找找看有無什麼替代方案可以解決這樣的難題,

一開始選用了「TestEnvironment.Docker」這個 NuGet  套件替代我土砲自製的 DockerSupports  工具,而且也完美地解決了我的需求,在多個工作專案裡也使用了好一段時間,

但是在 Docker 版本更新後,漸漸地在使用時就出現了很多的問題,執行測試時也出現不穩定的狀況,無論是在本機或是 CI  建置過程的測試步驟裡,

之後又選用「Ductus.FluentDocker」替代 TestEnvironment.Docker,也解決了原本執行測試時的不穩定狀況。

 

到了 2022/09  的時候,我在 Nick Chapsas  的 Youtube 頻道看到了這麼一個影片「The cleanest way to use Docker for testing in .NET

在影片裡就介紹了「Testcontainers」這個 NuGet Package,於是我就開始改用 Testcontainers 取代原本使用的 FluentDocker

Testcontainers for .NET is a library to support tests with throwaway instances of Docker containers for all compatible .NET Standard versions. The library is built on top of the .NET Docker remote API and provides a lightweight implementation to support your test environment in all circumstances.

現在不管是 Repository  測試或者是網站整合測試,都是使用 Testcontainers 建立測試時會使用到的 MS SQL Server, Redis, RabbitMQ, PostgreSQL, MongoDB…. 

 

範例專案

前面一大串的廢話就可以跳過,直接進入正題,怎麼使用 Testcontainers 在測試專案裡建立測試時使用的 MS SQL Server  容器。

直接來看看這次要用的範例專案

刻意不將 Repository  去抽離成單獨的類別庫專案,當然在我的工作專案裡都是會將 Repository 相關的類別抽離放在單一的類別庫專案裡,不過這是範例專案,也就不想這麼麻煩。

範例專案之後會再發佈到 Github,先來看看幾個重點類別

DatabaseConnectionOptions 類別

public class DatabaseConnectionOptions
{
    /// <summary>
    /// ConnectionString
    /// </summary>
    public string ConnectionString { get; set; }
}

在 Program.cs 裡會先把資料庫連線字串從 configurations 裡取得,然後放到 DatabaseConnectionOptions 的 ConnectionString  屬性裡,再注入到 IOptions<T>  裡

我不會在 Repository 類別裡去直接相依 IConfiguration  然後再去拿資料庫連線字串,而是相依注入 IOptions<DatabaseConnectionOptions>  後再從 Options  裡拿資料庫連線字串

我在過去看到很多人的專案會在 Controllers  或 Repository  類別裡直接相依注入 IConfiguration,應該要減少對於展示層所使用類別的直接依賴,對於後續專案的維護、彈性會比較好

 

DatabaseHelper  類別

using Microsoft.Data.SqlClient;

public class DatabaseHelper : IDatabaseHelper
{
    private readonly DatabaseConnectionOptions _databaseConnectionOptions;

    /// <summary>
    /// Initializes a new instance of the <see cref="DatabaseHelper"/> class.
    /// </summary>
    /// <param name="options">The options.</param>
    public DatabaseHelper(IOptions<DatabaseConnectionOptions> options)
    {
        this._databaseConnectionOptions = options.Value;
    }

    private string ConnectionString => this._databaseConnectionOptions.ConnectionString;


    /// <summary>
    /// Gets the connection.
    /// </summary>
    /// <returns></returns>
    public IDbConnection GetConnection()
    {
        var connectionStringBuilder = new SqlConnectionStringBuilder(this.ConnectionString)
        {
            TrustServerCertificate = true
        };

        var connection = new SqlConnection(connectionStringBuilder.ConnectionString);
        return connection;
    }
}

我不會在 Repository  類別的方法裡直接去建立 IDbConnection instance,而是會再透過 DatabaseHelper  這個類別

現在我都建議大家的專案裡要將 System.Data.SqlClient 更換為 Microsoft.Data.SqlClient,後續微軟也會將新功能優先在 Microsoft.Data.SqlClient 裡實做

Microsoft.Data.SqlClient is a data provider for Microsoft SQL Server and Azure SQL Database. Now in General Availability, it is a union of the two System.Data.SqlClient components which live independently in .NET Framework and .NET Core. Going forward, support for new SQL Server features will be implemented in Microsoft.Data.SqlClient.

Microsoft SqlClient Data Provider for SQL Server | Github

Cheat sheet for porting from System.Data.SqlClient to Microsoft.Data.SqlClient | Github

 

ShipperRepository  類別

public class ShipperRepository : IShipperRepository
{
    private readonly IDatabaseHelper _databaseHelper;

    /// <summary>
    /// Initializes a new instance of the <see cref="ShipperRepository"/> class.
    /// </summary>
    /// <param name="databaseHelper">The database helper.</param>
    public ShipperRepository(IDatabaseHelper databaseHelper)
    {
        this._databaseHelper = databaseHelper;
    }

    /// <summary>
    /// 以 ShipperId 查詢資料是否存在
    /// </summary>
    /// <param name="shipperId">shipperId</param>
    /// <returns></returns>
    public bool IsExists(int shipperId)
    {
        if (shipperId <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(shipperId));
        }
        
        var parameters = new DynamicParameters();
        parameters.Add("ShipperId", shipperId);

        using var conn = this._databaseHelper.GetConnection();
        const string sqlCommand = "select count(ShipperId) from Shippers where ShipperId = @ShipperId ";
        var result = conn.QueryFirstOrDefault<int>(sqlCommand, parameters);
        return result > 0;
    }

    /// <summary>
    /// 以 ShipperId 取得資料
    /// </summary>
    /// <param name="shipperId">shipperId</param>
    /// <returns></returns>
    public ShipperModel Get(int shipperId)
    {
        if (shipperId <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(shipperId));
        }
        
        var parameters = new DynamicParameters();
        parameters.Add("ShipperId", shipperId);

        using var conn = this._databaseHelper.GetConnection();
        const string sqlCommand = "select ShipperId, CompanyName, Phone from Shippers where ShipperId = @ShipperId ";
        var result = conn.QueryFirstOrDefault<ShipperModel>(sqlCommand, parameters);
        return result;
    }

    /// <summary>
    /// 取得 Shipper 的資料總數
    /// </summary>
    /// <returns></returns>
    public int GetTotalCount()
    {
        using var conn = this._databaseHelper.GetConnection();
        const string sqlCommand = "select count(ShipperId) from Shippers ";
        var result = conn.QueryFirstOrDefault<int>(sqlCommand);
        return result;
    }

    /// <summary>
    /// 取得所有 Shipper 資料
    /// </summary>
    /// <returns></returns>
    public IEnumerable<ShipperModel> GetAll()
    {
        using var conn = this._databaseHelper.GetConnection();
        const string sqlCommand = "select ShipperId, CompanyName, Phone from Shippers order by ShipperId ASC";
        var result = conn.Query<ShipperModel>(sqlCommand);
        return result;
    }

    /// <summary>
    /// 新增
    /// </summary>
    /// <param name="model">The model.</param>
    /// <returns></returns>
    public IResult Create(ShipperModel model)
    {
        ArgumentNullException.ThrowIfNull(model);
        
        using var conn = this._databaseHelper.GetConnection();

        const string sqlCommand = """
                                  Insert into Shippers (ShipperId, CompanyName, Phone)
                                  Values (@ShipperId, @CompanyName, @Phone);
                                  """;

        var executeResult = conn.Execute(sqlCommand, model);

        IResult result = new Result(false);

        if (executeResult.Equals(1))
        {
            result.Success = true;
            result.AffectRows = executeResult;
            return result;
        }

        result.Message = "資料新增錯誤";
        return result;
    }

    /// <summary>
    /// 修改
    /// </summary>
    /// <param name="model">The model.</param>
    /// <returns></returns>
    public IResult Update(ShipperModel model)
    {
        ArgumentNullException.ThrowIfNull(model);
        
        using var conn = this._databaseHelper.GetConnection();

        const string sqlCommand = """
                                  UPDATE Shippers SET
                                  CompanyName = @CompanyName,
                                  Phone = @Phone
                                  WHERE ShipperID = @ShipperID
                                  """;

        var executeResult = conn.Execute(sqlCommand, model);

        IResult result = new Result(false);

        if (executeResult.Equals(1))
        {
            result.Success = true;
            result.AffectRows = executeResult;
            return result;
        }

        result.Message = "資料更新錯誤";
        return result;
    }

    /// <summary>
    /// 刪除
    /// </summary>
    /// <param name="shipperId">shipperId</param>
    /// <returns></returns>
    public IResult Delete(int shipperId)
    {
        var parameters = new DynamicParameters();
        parameters.Add("ShipperID", shipperId);

        using var conn = this._databaseHelper.GetConnection();
        const string sqlCommand = "DELETE FROM Shippers WHERE ShipperID = @ShipperID";

        var executeResult = conn.Execute(sqlCommand, parameters);

        IResult result = new Result(false);

        if (executeResult.Equals(1))
        {
            result.Success = true;
            result.AffectRows = executeResult;
            return result;
        }

        result.Message = "資料刪除錯誤";
        return result;
    }
}

接下來就是要對這個類別做測試,不過測試案例就不會是重點,重點會是在「怎麼使用 Testcontainer  建立測試用資料庫容器」

 

測試專案

接下來會使用 MSTest  建立測試專案,不過這兩年來我已經改用 xUnit 建立測試專案,會轉換的最大原因是網路上許多資源都是優先提供 xUnit 測試,要將這些在 xUnit  測試裡使用的程式去轉換成 MSTest,都還需要再多花一點時間,看久了就乾脆轉用 xUnit 建立測試專案了,一開始還不習慣,但差別也就只有在設定測試環境的那些程式設定,而測試案例的程式撰寫其實與原本在 MSTest  裡並沒有多大的差異,寫久了之後反而覺得 xUnit  還比較方便、好寫。

測試專案會安裝以下這些 NuGet 套件

測試專案 Sample.WebApplicationTests

TestContainerHelper.cs

先來看看這個類別裡的 CreateDatabaseContainer 方法,這個就是使用 Testcontainers  所提供的方法然後依據外部所輸入的設定內容建立 MS SQL Server 容器

我知道有 Testcontinsers.MsSql  這個擴充套件,但我還是習慣使用基本的方式來建立容器,不會因為要建立其他類型的服務而要去熟悉不同的設定方式

using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Sample.WebApplicationTests.Settings;

namespace Sample.WebApplicationTests.Docker;

/// <summary>
/// Class TestContainersHelper
/// </summary>
public static class TestContainersHelper
{
    /// <summary>
    /// 使用 Testcontainers-dotnet 建立 Database Container.
    /// </summary>
    /// <param name="databaseSetting"></param>
    /// <param name="typeOfTarget"></param>
    /// <returns></returns>
    public static IContainer CreateDatabaseContainer(Mssql databaseSetting, Type typeOfTarget)
    {
        var environmentName = TestSettingProvider.GetEnvironmentName(typeOfTarget);
        var containerName = databaseSetting.ContainerName;

        var container = new ContainerBuilder()
                        .WithImage($"{databaseSetting.Image}:{databaseSetting.Tag}")
                        .WithEnvironment(databaseSetting.EnvironmentSettings)
                        .WithName($"{environmentName}-{containerName}")
                        .WithPortBinding(databaseSetting.HostPort, databaseSetting.ContainerPort)
                        .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(databaseSetting.ContainerPort))
                        .WithAutoRemove(true)
                        .Build();

        container.StartAsync().ConfigureAwait(false).GetAwaiter().GetResult();

        return container;
    }
}

TestSettings.json

我不太會在程式裡直接將一些可能會變動的設定值寫死在程式裡,所以會把要建立容器的 Docker Image 資料寫在 TestSettings.json  檔案裡

{
  "EnvironmentName": "sample-webapplication-tests",
  "Mssql": {
    "Image": "mcr.microsoft.com/mssql/server",
    "Tag": "2019-latest",
    "HostPort": 0,
    "ContainerPort": 1433,
    "SaPassword": "azsxdcfvgb_",
    "ContainerName": "mssql",
    "ContainerReadyMessage": "The default language (LCID 0) has been set for engine and full-text services",
    "EnvironmentSettings": [
      "ACCEPT_EULA=Y",
      "SA_PASSWORD=azsxdcfvgb_"
    ]
  }
}

之後再透過 TestSettingProvider  這個類別裡的 GetDatabaseSettings 方法取得設定資料

TestHook.cs

再來看看怎麼在 TestHook.cs 的 AssemblyInitialize 和 AssemblyCleanup 方法裡去建立和移除資料庫容器

using DotNet.Testcontainers.Containers;
using FluentAssertions;
using Sample.WebApplicationTests.Docker;
using Sample.WebApplicationTests.Settings;
using Sample.WebApplicationTests.Utilities;

namespace Sample.WebApplicationTests;

[TestClass]
public class TestHook
{
    private static IContainer _databaseContainer;

    private static string DatabaseIp { get; set; }

    private static string DatabaseSaPassword { get; set; }

    private static string DatabaseName => "Sample";

    internal static string SampleDbConnectionString => string.Format(TestDbConnection.Container.Database, DatabaseIp, DatabaseName, DatabaseSaPassword);

    [AssemblyInitialize]
    [Timeout(2000)]
    public static void AssemblyInitialize(TestContext context)
    {
        // Get the Database Setting
        var databaseSetting = TestSettingProvider.GetDatabaseSettings();
        DatabaseIp = $"127.0.0.1,{databaseSetting.HostPort}";
        DatabaseSaPassword = databaseSetting.SaPassword;
        
        // Create Database Container
        _databaseContainer = TestContainersHelper.CreateDatabaseContainer(databaseSetting, typeof(TestHook));

        // 確認測試用資料庫已經準備好
        var masterConnectionString = string.Format(TestDbConnection.Container.Master, DatabaseIp, DatabaseSaPassword);
        DatabaseCommand.PrintMssqlVersion(masterConnectionString);

        // 在 container 裡的 SQL Server 建立測試用 Database
        DatabaseCommand.CreateDatabase(masterConnectionString, DatabaseName);
        
        // FluentAssertions 設定: 日期時間使用接近比對的方式,而非完全一致的比對
        SetupDateTimeAssertions();
    }

    [AssemblyCleanup]
    public static void AssemblyCleanup()
    {
        _databaseContainer.StopAsync().ConfigureAwait(false).GetAwaiter().GetResult();
    }

    /// <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;
        });
    }
}

 

DatabaseHelperCustomization.cs

這個類別會使用到「單元測試使用 AutoFixture.AutoNSubstitute」這一篇文章裡的作法

using System;
using AutoFixture;
using Microsoft.Extensions.Options;
using Sample.WebApplication.Infrastructure.Helpers;
using Sample.WebApplication.Infrastructure.Settings;

namespace Sample.WebApplicationTests;

public class DatabaseHelperCustomization : ICustomization
{
    private static string ConnectionString => TestHook.SampleDbConnectionString;

    public void Customize(IFixture fixture)
    {
        fixture.Register(() => this.DatabaseHelper);
    }

    private DatabaseHelper _databaseHelper;

    private DatabaseHelper DatabaseHelper
    {
        get
        {
            if (this._databaseHelper is not null)
            {
                return this._databaseHelper;
            }

            var databaseConnectionOptions = new DatabaseConnectionOptions { ConnectionString = ConnectionString };
            var options = Options.Create(databaseConnectionOptions);
            this._databaseHelper = new DatabaseHelper(options);
            return this._databaseHelper;
        }
    }
}

StubRepository.cs

using Sample.WebApplication.Infrastructure.Helpers;

namespace Sample.WebApplicationTests;

public class StubRepository<T> where T : class
{
    private readonly DatabaseHelper _databaseHelper;
    
    public StubRepository(DatabaseHelper databaseHelper)
    {
        this._databaseHelper = databaseHelper;
    }

    private T _systemUnderTest;

    internal T SystemUnderTest
    {
        get
        {
            this._systemUnderTest = Activator.CreateInstance(typeof(T), this._databaseHelper) as T;
            return this._systemUnderTest;
        }
        set => this._systemUnderTest = value;
    }
}

BaseRepositoryTests.cs

using System;
using AutoFixture;
using AutoFixture.AutoNSubstitute;

namespace Sample.WebApplicationTests;

public abstract class BaseRepositoryTests<T> where T : class
{
    private IFixture _fixture;

    protected IFixture Fixture => this._fixture ??= new Fixture().Customize(new AutoNSubstituteCustomization())
                                                                 .Customize(new DatabaseHelperCustomization());

    protected string ConnectionString => TestHook.SampleDbConnectionString;

    protected StubRepository<T> Stub;

    [TestInitialize]
    public void TestInitialize()
    {
        this.Stub = this.Fixture.Create<StubRepository<T>>();
    }
}

ShipperRepositoryTests.cs

最後就來看看測試類別的實做內容,因為我把 TestInitialize  的設定都放在 BaseRepositoryTests<T>  類別裡,所以在 ShipperRepositoryTests  裡就看不到需要設定測試目標類別的部分

using AutoFixture;
using FluentAssertions;
using Sample.WebApplication.Infrastructure.Models;
using Sample.WebApplication.Infrastructure.Repository;
using Sample.WebApplicationTests.TestData;
using Sample.WebApplicationTests.Utilities;

namespace Sample.WebApplicationTests.Infrastructure.Repository;

[TestClass]
public class ShipperRepositoryTests : BaseRepositoryTests<ShipperRepository>
{
    [TestCleanup]
    public void TestCleanup()
    {
        TableCommands.Truncate(this.ConnectionString, TableNames.Shippers);
    }

    #region -- Prepare to Test --

    public TestContext TestContext { get; set; }

    [ClassInitialize]
    public static void ClassInitialize(TestContext context)
    {
        TableCommands.Drop(TestHook.SampleDbConnectionString, TableNames.Shippers);
        var filePath = Path.Combine("TestData", "TableSchemas", "Sample_Shippers_Create.sql");
        var script = File.ReadAllText(filePath);
        TableCommands.Create(TestHook.SampleDbConnectionString, script);
    }

    [ClassCleanup]
    public static void ClassCleanup()
    {
        TableCommands.Drop(TestHook.SampleDbConnectionString, TableNames.Shippers);
    }

    #endregion

    //-----------------------------------------------------------------------------------------

    [TestMethod]
    [Owner("Kevin")]
    [TestCategory("ShipperRepository")]
    [TestProperty("ShipperRepository", "IsExists")]
    public void IsExists_輸入的ShipperId為0時_應拋出ArgumentException()
    {
        // arrange
        var shipperId = 0;

        // act
        var exception = Assert.ThrowsException<ArgumentOutOfRangeException>(
            () => this.Stub.SystemUnderTest.IsExists(shipperId));

        // assert
        exception.Message.Should().Contain(nameof(shipperId));
    }

    [TestMethod]
    [Owner("Kevin")]
    [TestCategory("ShipperRepository")]
    [TestProperty("ShipperRepository", "IsExists")]
    public void IsExists_輸入的ShipperId無法對映到資料_應回傳false()
    {
        // arrange
        var shipperId = 99;
        
        // act
        var actual = this.Stub.SystemUnderTest.IsExists(shipperId);

        // assert
        actual.Should().BeFalse();
    }

    [TestMethod]
    [Owner("Kevin")]
    [TestCategory("ShipperRepository")]
    [TestProperty("ShipperRepository", "IsExists")]
    public void IsExists_輸入的ShipperId有對映到資料_應回傳True()
    {
        // arrange
        var model = this.Fixture.Build<ShipperModel>().Create();
        var shipperId = model.ShipperId;
        InsertData(model);

        // act
        var actual = this.Stub.SystemUnderTest.IsExists(shipperId);

        // assert
        actual.Should().BeTrue();
    }    
    
    [TestMethod]
    [Owner("Kevin")]
    [TestCategory("ShipperRepository")]
    [TestProperty("ShipperRepository", "Create")]
    public void Create_輸入的model為null時_應拋出ArgumentNullException()
    {
        // arrange
        ShipperModel model = null;

        // act
        var exception = Assert.ThrowsException<ArgumentNullException>(
            () => this.Stub.SystemUnderTest.Create(model));

        // assert
        exception.Message.Should().Contain(nameof(model));
    }

    [TestMethod]
    [Owner("Kevin")]
    [TestCategory("ShipperRepository")]
    [TestProperty("ShipperRepository", "Create")]
    public void Create_輸入一個有資料的model_新增完成應回傳()
    {
        // arrange
        var model = this.Fixture.Build<ShipperModel>().Create();

        // act
        var actual = this.Stub.SystemUnderTest.Create(model);

        // assert
        actual.Success.Should().BeTrue();
        actual.AffectRows.Should().Be(1);
    }    
    
    
    //-----------------------------------------------------------------------------------------
    
    private static void InsertData(ShipperModel model)
    {
        const string sqlCommand =
            """
            begin tran
            Insert into Shippers (ShipperId, CompanyName, Phone)
            Values (@ShipperId, @CompanyName, @Phone);
            commit
            """;

        DatabaseCommand.ExecuteSqlCommand(TestHook.SampleDbConnectionString, sqlCommand, model);
    }
}

 

觀察測試執行

當測試執行後可以到 Terminal   裡透過 docker ps -a 指令觀察,可以看到建立了指定 docker image 的 container,當所有的測試都執行完成後也會把建立的容器都移除

測試結果


不知不覺就寫了一大堆,充滿了我獨有的風格

使用 Testcontainers  這個套件就可以在測試執行時建立起各種服務的容器,接下來就是把測試給完成,

而且不管是在開發環境或是 CI  建置環境,不論是 Windows  或是 Linux 裡,只要  準備好 Docker 環境就可以執行測試

之後在這個基礎上再加上 WebApplicationFactory 的使用,也是可以完成網站整合測試,不過這個所牽涉到的觀念以及服務相依、替代、轉換等細節就會比較多

以後有機會再分享給各位。

之後會再公開範例專案的原始碼,以上

分享