选择测试策略

概述中所述,需要做出的一个基本决定是,测试是否会像应用程序一样涉及生产数据库系统,或是否要针对一个测试替身来运行测试,后者用于替代生产数据库系统。

针对实际外部资源进行测试(而不是将其替换为测试替身)可能会遇到以下困难:

  1. 在许多情况下,针对实际外部资源进行测试根本不可行或不切实际。 例如,应用程序可能会与某些服务进行交互,而这些服务不容易进行测试(由于速率限制或缺少测试环境)。
  2. 即使可以涉及真实的外部资源,测试起来也可能会非常慢:针对云服务运行大量测试可能会导致测试时间过长。 测试应是开发人员日常工作流的一部分,因此测试快速运行非常重要。
  3. 针对外部资源执行测试可能会遇到隔离问题,测试会相互干扰。 例如,针对数据库并行运行的多个测试可能会修改数据,并导致彼此以各种方式失败。 使用测试替身可避免这种情况,因为每个测试都针对其自身的内存中资源运行,因此自然会与其他测试隔离。

但针对测试替身通过的测试不能保证程序在针对实际外部资源运行时正常工作。 例如,数据库测试替身可能会执行区分大小写的字符串比较,而生产数据库系统执行不区分大小写的比较。 仅当针对实际生产数据库执行测试时才会发现此类问题,这使得这些测试成为任何测试策略的重要组成部分。

针对数据库进行测试可能并没有看起来那么难

由于针对真实数据库进行测试存在上述困难,开发人员经常被敦促首先使用测试替身,并准备一个可靠的测试套件,以便在自己的计算机上频繁运行;相比之下,涉及数据库的测试执行频率要低得多,并且在许多情况下提供的覆盖范围也要少得多。 建议更多地考虑后者,我们认为数据库实际上受上述问题的影响可能比人们想象的要小得多:

  1. 如今,大多数数据库都可以轻松地安装在开发人员的计算机上。 Docker 等基于容器的技术可以使这一切变得非常简单,而 Github WorkspacesDev Container 等技术可以为你设置整个开发环境(包括数据库)。 使用 SQL Server 时,还可在 Windows 上针对 LocalDB 进行测试,或者在 Linux 上轻松设置 Docker 映像。
  2. 针对本地数据库进行测试(使用合理的测试数据集)通常非常快,因为通信完全是本地的,并且测试数据通常缓冲在数据库端的内存中。 EF Core 本身包含 30,000 多项针对 SQL Server 的测试;这些测试可在几分钟内可靠地完成,在每次提交时在 CI 中执行,并且经常由开发人员在本地执行。 一些开发人员转向使用内存中数据库(虚设对象),认为只有这样才能提高速度,但实际情况几乎从未如此。
  3. 在针对真实数据库运行测试时,隔离确实是一个障碍,因为测试可能会修改数据并相互干扰。 然而,可以使用多种技术在数据库测试场景中提供隔离;这些内容我们在针对生产数据库系统进行测试中进行了重点介绍。

上述内容并不意味着要贬低测试替身或反对使用替身。 一方面,某些测试场景(例如模拟数据库故障)必须使用测试替身才能进行。 但根据我们的经验,由于上述原因,用户经常回避对其数据库进行测试,认为它很慢、很困难或不可靠,但事实不一定是这样。 针对生产数据库系统进行测试旨在解决此问题,提供编写针对数据库的快速且隔离的测试的指南和示例。

不同类型的测试替身

测试替身是一个广泛的术语,包含的方法大不相同。 本部分介绍的一些常见技术与用于测试 EF Core 应用程序的测试替身相关:

  1. 使用 SQLite(内存中模式)作为数据库虚设对象,替换生产数据库系统。
  2. 使用 EF Core 内存中提供程序作为数据库虚设对象,替换生产数据库系统。
  3. 模拟 DbContextDbSet 或为其创建存根。
  4. 在 EF Core 与应用程序代码之间引入存储库层,并模拟该层或为其创建存根。

下面,我们将探讨每种方法的含义,并将其与其他方法进行比较。 建议阅读不同的方法,以便充分了解每种方法。 如果已决定编写不涉及生产数据库系统的测试,则存储库层是唯一允许对数据层进行全面且可靠的存根操作/模拟的方法。 但是,这种方法在实现和维护方面会消耗大量成本。

将 SQLite 作为数据库虚设对象

一种可能的测试方法是将生产数据库(例如 SQL Server)与 SQLite 交换,从而有效地将其用作测试用虚设对象。 除易于设置之外,SQLite 还具有内存中数据库功能,此功能特别适用于测试:每个测试在自己的内存中数据库中自然隔离,无需管理实际文件。

但在执行此操作之前,请务必了解在 EF Core 中,如果数据库提供程序不同,其行为也会不同 - EF Core 不会尝试抽象基础数据库系统的每个方面。 从根本上说,这意味着针对 SQLite 进行测试不能保证与 SQL Server 或任何其他数据库的结果相同。 下面的示例介绍了可能的行为差异:

  • 同一 LINQ 查询可能会在不同的提供程序上返回不同的结果。 例如,SQL Server 默认执行不区分大小写的字符串比较,而 SQLite 区分大小写。 这可使测试在 SQLite 上通过,而在 SQL Server 上则失败(反之亦然)。
  • SQLite 根本不支持在 SQL Server 上运行的某些查询,因为这两个数据库中的确切 SQL 支持有所不同。
  • 如果查询碰巧使用特定于提供程序的方法(例如 SQL Server 的 EF.Functions.DateDiffDay),该查询将在 SQLite 上失败,并且无法进行测试。
  • 原始 SQL 可能可行,也可能失败或返回不同的结果,具体取决于所执行的操作。 SQL 方言在数据库中的很多方面都是不同的。

与针对生产数据库系统运行测试相比,开始使用 SQLite 相对容易,并且有许多用户已在这样做了。 遗憾的是,在测试 EF Core 应用程序时,上述限制最终往往会成为问题,即使它们一开始似乎并非如此。 因此,建议针对真实数据库编写测试,或者如果使用测试替身是绝对必要的,则考虑存储库模式的成本,如下所述。

有关如何使用 SQLite 进行测试的信息,请参阅此部分

将内存中作为数据库虚设对象

作为 SQLite 的替代方法,EF Core 还附带了内存中提供程序。 尽管此提供程序最初旨在支持 EF Core 本身的内部测试,但一些开发人员在测试 EF Core 应用程序时将其用作数据库虚设对象。 强烈建议不要这样做:作为一个数据虚设对象,内存中具有与 SQLite 相同的问题(见上文),除此之外还具有以下限制

  • 内存中提供程序支持的查询类型通常比 SQLite 提供程序要少,因为它不是关系数据库。 与生产数据库相比,更多查询会失败或行为不同。
  • 事务不受支持。
  • 完全不支持原始 SQL。 将这一点与 SQLite 进行比较,在 SQLite 中可以使用原始 SQL,只要该 SQL 在 SQLite 和生产数据库上以相同的方式运行即可。
  • 内存中提供程序尚未针对性能进行优化,并且通常比内存中模式下的 SQLite(甚至是生产数据库系统)运行得慢。

总之,内存中具有 SQLite 的所有缺点,另外还有自身的一些缺点,没有任何可比性优势。 如果要查找简单的内存中数据库虚设对象,请使用 SQLite 而不是内存中提供程序;但请考虑改用存储库模式,如下所述。

有关如何使用内存中进行测试的信息,请参阅此部分

模拟 DbContext 和 DbSet 或为其创建存根

此方法通常使用模拟框架创建 DbContextDbSet 的测试替身,并针对这些替身进行测试。 模拟 DbContext 是测试各种非查询功能(如调用 AddSaveChanges())的好方法,允许你验证代码是否在编写场景中调用了它们

但是,无法正确模拟 DbSet 查询功能,因为查询通过 LINQ 运算符表示,这是对 IQueryable 的静态扩展方法调用。 因此,当有人谈论“模拟 DbSet”时,他们真正的意思是创建一个由内存集合中支持的 DbSet,然后针对内存中的该集合计算查询运算符,就像一个简单的 IEnumerable 一样。 这实际上不是模拟,而是一种伪造,其中内存中集合取代了真正的数据库。

由于 DbSet 仅伪造其本身并且在内存中计算查询,因此这种方法最终与使用 EF Core 内存中提供程序非常相似:这两种技术都通过内存中集合在 .NET 中执行查询运算符。 因此,这种方法也存在同样的缺点:查询的行为会有所不同(例如区分大小写),或者将失败(例如,由于特定于提供程序的方法),原始 SQL 将不起作用,并且事务将完全被忽略。 因此,通常应避免使用此方法来测试任何查询代码。

存储库模式

上述方法尝试将 EF Core 的生产数据库提供程序与虚设测试提供程序交换,或尝试创建由内存中集合支持的 DbSet。 这些方法很相似,因为它们仍然会计算程序的 LINQ 查询(使用 SQLite 或内存中),这最终是上述问题的根源:设计针对特定生产数据库执行的查询无法在其他地方可靠地执行而不出现问题。

对于适当的可靠测试替身,请考虑引入存储库层,以便在应用程序代码和 EF Core 之间进行调解。 存储库的生产实现包含实际的 LINQ 查询,并通过 EF Core 执行它们。 在测试期间,存储库抽象直接进行存根操作或模拟,而无需任何实际的 LINQ 查询,从而有效地从测试堆栈中删除 EF Core,并允许测试仅专注于应用程序代码。

下图是数据库虚假对象方法(SQLite/内存中)与存储库模式的比较:

Comparison of fake provider with repository pattern

由于 LINQ 查询不再是测试的一部分,因此可以直接向应用程序提供查询结果。 换言之,前面的方法大致允许为查询输入创建存根(例如,将 SQL Server 表替换为内存中表),但仍执行内存中的实际查询运算符。 相比之下,存储库模式允许直接为查询输出创建存根,从而实现更强大且更集中的单元测试。 请注意,要使此操作可行,存储库不能公开任何 IQueryable 返回的方法,因为无法再次为这些方法创建存根;应改为返回 IEnumerable。

但是,由于存储库模式需要将每个(可测试的)LINQ 查询封装在返回 IEnumerable 的方法中,因此它会对应用程序强加一个额外的体系结构层,并且可能会产生大量的实现和维护成本。 在选择如何测试应用程序时,不应低估此成本,特别是考虑到存储库公开的查询仍然可能需要针对真实数据库进行测试。

值得注意的是,除测试之外,存储库还具有其他优势。 存储库可确保所有数据访问代码都集中在一个地方,而不是分散在应用程序中,如果你的应用程序需要支持多个数据库,则可通过存储库抽象跨提供程序调整查询。

有关显示使用存储库进行测试的示例,请参阅此部分

总体比较

下表提供了不同测试方法的快速比较视图,并显示了可在哪种方法下测试哪种功能:

功能 内存中 SQLite 内存中 模拟 DbContext 存储库模式 针对数据库进行测试
测试替身类型 虚设对象 虚设对象 虚设对象 模拟/存根 真实对象,无替身
是否是原始 SQL? 依赖的对象
事务? 否(已忽略)
是否是特定于提供程序的转换?
是否是确切的查询行为? 依赖的对象 依赖的对象 依赖的对象
是否可在应用程序中的任意位置使用 LINQ? 否*

* 所有可测试的数据库 LINQ 查询都必须封装在 IEnumerable 返回的存储库方法中,才能进行存根操作/模拟。

总结

  • 建议开发人员对其针对实际生产数据库系统运行的应用程序做到良好的测试覆盖。 这样可确信应用程序在生产中实际运行,并且通过适当设计,测试可以可靠且快速地执行。 由于在任何情况下都需要这些测试,因此最好从这里开始,如果需要,请稍后使用测试替身添加测试。
  • 如果决定使用测试替身,建议实现存储库模式,以便通过该模式创建存根或模拟 EF Core 之上的数据访问层,而不是使用伪造的 EF Core 提供程序(Sqlite/内存中)或模拟 DbSet
  • 如果出于某种原因,存储库模式不是可行的选项,请考虑使用 SQLite 内存中数据库。
  • 避免使用内存中提供程序进行测试 - 不建议这样做,并且仅旧版应用程序支持。
  • 避免模拟 DbSet 进行查询。