針對生產資料庫系統進行測試

在此頁面中,我們會討論撰寫自動化測試的技術,這些測試牽涉到應用程式在生產環境中執行的資料庫系統。 替代測試方法存在,其中生產資料庫系統會由測試雙精度浮點數交換;如需詳細資訊, 請參閱測試概觀頁面 。 請注意,測試與生產環境中使用的資料庫不同(例如 Sqlite)並未在此涵蓋,因為不同的資料庫會作為測試雙精度浮點數;此方法涵蓋在 測試中,而不需要生產資料庫系統

涉及實際資料庫之測試的主要障礙是確保適當的測試隔離,讓平行執行的測試(甚至序列中)不會互相干擾。 您可以在這裡 檢視 下列的完整範例程式碼。

提示

此頁面顯示 xUnit 技術,但其他測試架構中也有類似的概念,包括 NUnit

設定資料庫系統

現在大部分的資料庫系統都可以在 CI 環境和開發人員電腦上輕鬆安裝。 雖然透過一般安裝機制安裝資料庫通常很容易,但現成可用的 Docker 映射適用于大多數主要資料庫,而且可在 CI 中特別容易安裝。 針對開發人員環境 GitHub 工作區 開發容器 可以設定所有必要的服務和相依性,包括資料庫。 雖然這需要對設定進行初始投資,但一旦完成,您就擁有工作測試環境,而且可以專注于更重要的事情。

在某些情況下,資料庫會有特殊版本或版本,有助於測試。 使用 SQL Server 時, LocalDB 可以用來在本機執行測試,幾乎完全沒有設定,視需要啟動資料庫實例,並可能節省較不強大的開發人員機器上的資源。 不過,LocalDB 沒有問題:

  • 它無法提供 SQL Server Developer Edition 的所有支援。
  • 它只能在 Windows 上使用。
  • 啟動服務時,可能會造成第一個測試回合延遲。

我們通常建議安裝 SQL Server Developer 版本,而不是 LocalDB,因為它提供完整的 SQL Server 功能集,而且通常很容易做到。

使用雲端資料庫時,通常適合針對資料庫的本機版本進行測試,以提升速度和降低成本。 例如,在生產環境中使用 SQL Azure 時,您可以針對本機安裝的 SQL Server 進行測試 - 這兩者非常類似(雖然在進入生產環境之前,對 SQL Azure 本身執行測試仍然明智)。 使用 Azure Cosmos DB 時, Azure Cosmos DB 模擬器 是本機開發及執行測試的公用程式。

建立、植入和管理測試資料庫

安裝資料庫之後,您就可以開始在測試中使用資料庫。 在大部分的簡單案例中,您的測試套件有單一資料庫,可在多個測試類別的多個測試之間共用,因此我們需要一些邏輯,以確保在測試回合的存留期內,建立並植入資料庫一次。

使用 Xunit 時,這可以透過 類別裝置來完成,此裝置 代表資料庫,而且會在多個測試回合之間共用:

public class TestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;Trusted_Connection=True";

    private static readonly object _lock = new();
    private static bool _databaseInitialized;

    public TestDatabaseFixture()
    {
        lock (_lock)
        {
            if (!_databaseInitialized)
            {
                using (var context = CreateContext())
                {
                    context.Database.EnsureDeleted();
                    context.Database.EnsureCreated();

                    context.AddRange(
                        new Blog { Name = "Blog1", Url = "http://blog1.com" },
                        new Blog { Name = "Blog2", Url = "http://blog2.com" });
                    context.SaveChanges();
                }

                _databaseInitialized = true;
            }
        }
    }

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);
}

當上述裝置具現化時,它會使用 EnsureDeleted() 卸載資料庫(以防先前執行存在),然後使用 EnsureCreated() 最新的模型組態建立它( 請參閱這些 API 的檔)。 建立資料庫之後,裝置會植入一些測試可以使用的資料。 值得花一些時間思考種子資料,因為稍後變更新的測試可能會導致現有的測試失敗。

若要在測試類別中使用裝置,只要透過您的裝置類型實 IClassFixture 作,xUnit 就會將它插入您的建構函式:

public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
    public BloggingControllerTest(TestDatabaseFixture fixture)
        => Fixture = fixture;

    public TestDatabaseFixture Fixture { get; }

您的測試類別現在具有 Fixture 屬性,可供測試用來建立功能完整的內容實例:

[Fact]
public void GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

    var blog = controller.GetBlog("Blog2").Value;

    Assert.Equal("http://blog2.com", blog.Url);
}

最後,您可能已經注意到上述裝置的建立邏輯中有一些鎖定。 如果裝置只用于單一測試類別,則保證由 xUnit 完全具現化一次;但在多個測試類別中,通常會使用相同的資料庫裝置。 xUnit 確實提供 收集裝置 ,但該機制會防止測試類別平行執行,這對測試效能很重要。 若要使用 xUnit 類別裝置安全地管理此功能,我們會對資料庫建立和植入採取簡單的鎖定,並使用靜態旗標來確保我們永遠不需要執行兩次。

測試修改資料

上述範例顯示唯讀測試,這是測試隔離觀點的簡單案例:因為沒有任何修改,因此無法測試干擾。 相反地,測試哪些修改資料比較有問題,因為它們可能會互相干擾。 隔離撰寫測試的一個常見技巧是將測試包裝在交易中,並在測試結束時復原該交易。 由於實際上不會認可任何資料庫,因此不會避免其他測試看到任何修改和干擾。

以下是將部落格新增至資料庫的控制器方法:

[HttpPost]
public ActionResult AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    _context.SaveChanges();

    return Ok();
}

我們可以使用下列專案來測試此方法:

[Fact]
public void AddBlog()
{
    using var context = Fixture.CreateContext();
    context.Database.BeginTransaction();

    var controller = new BloggingController(context);
    controller.AddBlog("Blog3", "http://blog3.com");

    context.ChangeTracker.Clear();

    var blog = context.Blogs.Single(b => b.Name == "Blog3");
    Assert.Equal("http://blog3.com", blog.Url);

}

上述測試程式碼的一些注意事項:

  • 我們會開始交易,以確保下列變更未認可至資料庫,且不會干擾其他測試。 由於永遠不會認可交易,所以在處置內容實例時,會在測試結束時隱含回復。
  • 進行我們想要的更新之後,我們會使用 清除內容實例的變更追蹤器 ChangeTracker.Clear ,以確保我們確實從下列資料庫載入部落格。 我們可以改用兩個內容實例,但我們必須確定這兩個實例使用相同的交易。
  • 您甚至可以想要在裝置 CreateContext 的 中啟動交易,讓測試接收已經在交易中的內容實例,並準備好進行更新。 這有助於防止意外忘記交易的情況,導致測試干擾,而可能難以偵錯。 您也可以在不同的測試類別中分隔唯讀和寫入測試。

測試如何明確管理交易

有一個最終的測試類別,其中呈現額外的困難:測試會修改資料,同時明確管理交易。 因為資料庫通常不支援巢狀交易,所以無法如上所示使用交易進行隔離,因為它們需要由實際的產品名稱使用。 雖然這些測試通常比較罕見,但必須以特殊方式處理它們:您必須在每次測試之後清除資料庫的原始狀態,而且必須停用平行處理,讓這些測試不會互相干擾。

讓我們檢查下列控制器方法作為範例:

[HttpPost]
public ActionResult UpdateBlogUrl(string name, string url)
{
    // Note: it isn't usually necessary to start a transaction for updating. This is done here for illustration purposes only.
    using var transaction = _context.Database.BeginTransaction(IsolationLevel.Serializable);

    var blog = _context.Blogs.FirstOrDefault(b => b.Name == name);
    if (blog is null)
    {
        return NotFound();
    }

    blog.Url = url;
    _context.SaveChanges();

    transaction.Commit();
    return Ok();
}

假設基於某些原因,此方法需要使用可序列化的交易(這通常不是這種情況)。 因此,我們無法使用交易來保證測試隔離。 由於測試實際上會將變更認可至資料庫,因此我們會使用自己的個別資料庫來定義另一個裝置,以確保我們不會干擾上面已顯示的其他測試:

public class TransactionalTestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTransactionalTestSample;Trusted_Connection=True";

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);

    public TransactionalTestDatabaseFixture()
    {
        using var context = CreateContext();
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();

        Cleanup();
    }

    public void Cleanup()
    {
        using var context = CreateContext();

        context.Blogs.RemoveRange(context.Blogs);

        context.AddRange(
            new Blog { Name = "Blog1", Url = "http://blog1.com" },
            new Blog { Name = "Blog2", Url = "http://blog2.com" });
        context.SaveChanges();
    }
}

此裝置與上述裝置類似,但值得注意的是包含 Cleanup 方法;我們會在每次測試之後呼叫此裝置,以確保資料庫重設為其起始狀態。

如果這個裝置只供單一測試類別使用,我們可以將它當做上述類別裝置來參考 -xUnit 不會在同一類別內平行處理測試(深入瞭解 xUnit 檔中 的測試集合和平行處理 )。 不過,如果我們想要在多個類別之間共用此裝置,我們必須確定這些類別不會平行執行,以避免任何干擾。 若要這樣做,我們將使用此裝置作為 xUnit 集合裝置 ,而不是作為 類別裝置

首先,我們會定義 測試集合,該集合 會參考我們的裝置,並將供所有需要它的交易式測試類別使用:

[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}

我們現在參考測試類別中的測試集合,並接受建構函式中的裝置,如下所示:

[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
    public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
        => Fixture = fixture;

    public TransactionalTestDatabaseFixture Fixture { get; }

最後,我們會讓測試類別可處置,並安排在每次測試之後呼叫裝置 Cleanup 的方法:

public void Dispose()
    => Fixture.Cleanup();

請注意,由於 xUnit 只會具現化集合裝置一次,因此我們不需要像上述那樣使用鎖定資料庫建立和植入。

您可以在這裡 檢視 上述的完整範例程式碼。

提示

如果您有多個測試類別與修改資料庫的測試,您仍然可以使用不同的裝置平行執行它們,每個都參考自己的資料庫。 建立及使用許多測試資料庫並無問題,而且應該在一切有説明時完成。

有效率的資料庫建立

在上述範例中,我們使用 EnsureDeleted() 和執行 EnsureCreated() 測試之前,確定我們有最新的測試資料庫。 某些資料庫中的這些作業可能會有點慢,當您逐一查看程式碼變更並重複執行測試時,可能會發生問題。 如果是這種情況,您可能會想要在裝置的建構函式中暫時批註化 EnsureDeleted :這會在測試回合之間重複使用相同的資料庫。

這種方法的缺點是,如果您變更 EF Core 模型,您的資料庫架構將不會是最新的,而且測試可能會失敗。 因此,我們只建議您在開發週期期間暫時執行這項操作。

有效率的資料庫清除

我們上面看到,當變更實際認可至資料庫時,我們必須在每個測試之間清除資料庫,以避免干擾。 在上述交易測試範例中,我們使用 EF Core API 來刪除資料表的內容來執行這項作業:

using var context = CreateContext();

context.Blogs.RemoveRange(context.Blogs);

context.AddRange(
    new Blog { Name = "Blog1", Url = "http://blog1.com" },
    new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();

這通常不是清除資料表最有效率的方式。 如果測試速度是個問題,您可能想要改用未經處理的 SQL 來刪除資料表:

DELETE FROM [Blogs];

您也可以考慮使用 重新寄出 套件,以有效率地清除資料庫。 此外,您不需要指定要清除的資料表,因此不需要更新清除程式碼,因為資料表會新增至模型。

摘要

  • 針對實際資料庫進行測試時,值得區分下列測試類別:
    • 唯讀測試相當簡單,而且一律可以針對相同的資料庫平行執行,而不必擔心隔離。
    • 寫入測試比較有問題,但交易可用來確定它們已正確隔離。
    • 交易式測試最有問題,需要邏輯將資料庫重設為其原始狀態,以及停用平行處理。
  • 將這些測試類別分成不同的類別,可能會避免測試之間的混淆和意外干擾。
  • 請為植入的測試資料提供一些預先思考,並嘗試以在種子資料變更時不會太常中斷的方式撰寫測試。
  • 使用多個資料庫平行處理修改資料庫的測試,也可能允許不同的種子資料組態。
  • 如果測試速度是個問題,您可能想要查看建立測試資料庫的更有效率的技術,以及在執行之間清除其資料。
  • 請務必記住測試平行處理和隔離。