针对生产数据库系统进行测试
在本页中,我们将讨论编写自动测试的技术,这些测试涉及应用程序在生产环境中针对其运行的数据库系统。 存在替代的测试方法,即生产数据库系统被测试替身所替代。有关详细信息,请参阅测试概述页。 请注意,此处未介绍针对非生产中使用的数据库(例如 Sqlite)的测试,因为会使用不同的数据库作为测试替身。在没有生产数据库系统时进行测试中介绍了此方法。
涉及真实数据库的测试的主要障碍是确保适当的测试隔离,以便并行(甚至串行)运行的测试不会相互干扰。 可在此处查看下面的完整示例代码。
设置数据库系统
现在,大多数数据库系统都可以在 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;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 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;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(),以确保我们具有最新的测试数据库。 这些操作在某些数据库中可能有点慢,当你循环访问代码更改和重新运行测试时,这可能是个问题。 如果是这种情况,你可能需要在固定例程的构造函数中暂时注释禁止 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];
你可能还需要考虑使用 respawn 包,该包可以有效地清除数据库。 此外,它不需要你指定要清除的表,因此不需要更新清理代码,因为表已添加到模型中。
总结
- 针对实际数据库进行测试时,值得区分以下测试类别:
- 只读测试相对简单,始终可以针对同一数据库并行执行,而无需担心隔离。
- 写入测试比较棘手,但可使用事务来确保它们正确隔离。
- 事务性测试是最棘手的,需要逻辑将数据库重置回其原始状态,并禁用并行化。
- 将这些测试类别分离为单独的类可能会避免测试之间的混淆和意外干扰。
- 在种子设定的测试数据方面进行一些前期思考,并尝试以一种在种子数据更改时不会频繁中断的方式编写测试。
- 使用多个数据库并行化会修改数据库的测试,并在可能的情况下允许不同的种子数据配置。
- 如果测试速度是个问题,你可能需要了解创建测试数据库以及清理其运行之间的数据的更高效技术。
- 始终牢记测试并行化和隔离。