针对生产数据库系统进行测试

在本页中,我们将讨论编写自动化测试的技术,这些测试涉及应用程序在生产环境中运行的数据库系统。 存在替代测试方法,可以用测试替身替换生产数据库系统;有关详细信息,请参阅 测试概述页。 请注意,此处不涉及针对与生产中使用的不同数据库(例如 Sqlite)的测试,因为不同的数据库用作测试替身。这种方法在不使用生产数据库系统的测试中介绍。

涉及实际数据库的测试的主要障碍是确保适当的测试隔离,以便并行运行(甚至串行)的测试不会相互干扰。 可 在此处查看下面的完整示例代码。

小提示

本页显示了 xUnit 技术,但其他测试框架中存在类似的概念,包括 NUnit。

设置数据库系统

现在,大多数数据库系统都可以在 CI 环境和开发人员计算机上轻松安装。 虽然通常很容易通过常规安装机制安装数据库,但现成的 Docker 映像可用于大多数主要数据库,并且可以在 CI 中特别轻松地安装。 Testcontainers 等库可以通过在测试期间自动管理容器化数据库实例来进一步简化此作。 对于开发人员环境, GitHub 工作区、 开发容器 可以设置所有必要的服务和依赖项,包括数据库。 虽然这需要对设置进行初始投资,但一旦完成,就拥有一个有效的测试环境,并且可以专注于更重要的事情。

在某些情况下,数据库具有特殊版本或版本,这对于测试很有帮助。 使用 SQL Server 时, LocalDB 可用于在本地运行测试,几乎无需设置,按需启动数据库实例,并可能节省不太强大的开发人员计算机上的资源。 然而,LocalDB 并非没有问题:

  • 它不支持 SQL Server Developer Edition 所做的一切。
  • 它仅在 Windows 上可用。
  • 它可能会导致首次测试运行时出现延迟,因为服务正在初始化。

我们通常建议安装 SQL Server 开发人员版本,而不是 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;ConnectRetryCount=0";

    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 async Task GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

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

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

最后,你可能已经注意到了上述固定例程的创建逻辑中的一些锁定。 如果夹具仅在单个测试类中使用,xUnit 将保证只实例化一次。但通常在多个测试类中会使用同一个数据库夹具。 xUnit 确实提供 收集装置,但该机制会阻止测试类并行运行,这对于测试性能非常重要。 为了使用 xUnit 类固定例程安全地管理此问题,我们围绕数据库创建和种子设定采用了一个简单的锁,并使用静态标志来确保我们永远不必执行两次。

用于修改数据的测试

上面的示例显示了一个只读测试,从测试隔离的角度来看,这是一种简单情况:由于没有修改任何内容,因此无法进行测试干扰。 相比之下,修改数据的测试比较有问题,因为它们可能会相互干扰。 隔离编写测试的一种常见方法是将测试包装在事务中,并在测试结束时回滚该事务。 由于实际上没有任何内容提交到数据库,其他测试看不到任何修改,从而避免了干扰。

下面是向数据库添加博客的控制器方法:

[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    await _context.SaveChangesAsync();

    return Ok();
}

我们可以使用以下方法测试此方法:

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

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

    context.ChangeTracker.Clear();

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

}

关于上述测试代码的一些说明:

  • 我们启动一个事务,以确保以下更改未提交到数据库,并且不会干扰其他测试。 由于事务永远不会提交,因此在释放上下文实例时,会在测试结束时隐式回滚该事务。
  • 在进行所需的更新后,我们会通过 ChangeTracker.Clear 清除上下文实例的更改跟踪器,以确保我们真正从下面的数据库中加载博客。 我们可以改用两个上下文实例,但随后必须确保这两个实例使用相同的事务。
  • 你甚至可以在固定例程的 CreateContext 中启动事务,以便测试接收已存在于事务中的上下文实例,并准备好进行更新。 这有助于防止意外忘记事务的情况,那样会导致测试干扰难以调试。 你可能还想在不同的测试类中分别进行只读测试和写入测试。

显式管理事务的测试

有一个最终类别的测试带来了额外的困难:用于修改数据并显式管理事务的测试。 由于数据库通常不支持嵌套事务,因此无法像上述那样使用事务进行隔离,因为它们需要由实际产品代码使用。 虽然这些测试往往比较罕见,但必须以特殊方式处理它们:必须在每次测试后将数据库清理为原始状态,并且必须禁用并行化,以便这些测试不会相互干扰。

让我们以以下控制器方法为例:

[HttpPost]
public async Task<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.
    await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);

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

    blog.Url = url;
    await _context.SaveChangesAsync();

    await transaction.CommitAsync();
    return Ok();
}

假设出于某种原因,该方法需要使用可序列化的事务(这通常不是这种情况)。 因此,我们不能使用事务来保证测试隔离。 由于测试实际上会将更改提交到数据库,因此我们将使用其自己的独立数据库定义另一个固定例程,以确保我们不会干扰上面显示的其他测试:

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

    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(),以确保我们拥有一个符合 up-to日期的测试数据库。 某些数据库中的这些操作可能会有些慢,这在循环进行代码更改和重复运行测试时可能会成为一个问题。 如果是这种情况,你可能希望在装置的构造函数中暂时注释掉 EnsureDeleted :这将在测试运行中重复使用同一数据库。

此方法的缺点是,如果更改 EF Core 模型,数据库架构不会是最新的,并且测试可能会失败。 因此,我们建议仅在开发周期期间暂时执行此操作。

高效的数据库清理

如上所述,当实际将更改提交到数据库时,我们必须在每个测试之间清理数据库以避免干扰。 在上面的事务性测试示例中,我们通过使用 EF Core APIs 删除表的内容来实现这一点。

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];

您可能还要考虑使用 respawn 的包,它可以有效地清理数据库。 此外,它不需要指定要清除的表,因此不需要更新清理代码,因为表已添加到模型中。

摘要

  • 针对实际数据库进行测试时,值得区分以下测试类别:
    • 只读测试相对简单,始终可以针对同一数据库并行执行,而无需担心隔离。
    • 写入测试比较棘手,但可使用事务来确保它们正确隔离。
    • 事务性测试是最有问题的,需要逻辑将数据库重置回其原始状态,以及禁用并行化。
  • 将这些测试类别分离为单独的类可能会避免测试之间的混淆和意外干扰。
  • 在种子设定的测试数据方面进行一些前期思考,并尝试以一种在种子数据更改时不会频繁中断的方式编写测试。
  • 使用多个数据库以并行方式进行对数据库进行修改的测试,并可能还支持不同的种子数据配置。
  • 如果测试速度是个问题,你可能希望了解创建测试数据库以及清理其运行之间的数据的更高效技术。
  • 始终牢记测试并行化和隔离。