【问题标题】:What's the best strategy for unit-testing database-driven applications?单元测试数据库驱动应用程序的最佳策略是什么?
【发布时间】:2010-09-13 19:02:56
【问题描述】:

我使用许多由后端复杂程度不同的数据库驱动的 Web 应用程序。通常,有一个ORM 层与业务和表示逻辑分开。这使得对业务逻辑进行单元测试相当简单;事物可以在离散的模块中实现,测试所需的任何数据都可以通过对象模拟来伪造。

但是测试 ORM 和数据库本身总是充满问题和妥协。

多年来,我尝试了一些策略,但没有一个完全让我满意。

  • 使用已知数据加载测试数据库。针对 ORM 运行测试并确认返回正确的数据。这里的缺点是您的测试数据库必须跟上应用程序数据库中的任何模式更改,并且可能会不同步。它还依赖于人工数据,并且可能不会暴露由于愚蠢的用户输入而发生的错误。最后,如果测试数据库很小,它不会像缺少索引那样显示效率低下。 (好吧,最后一个并不是真正应该用于单元测试的,但它并没有什么坏处。)

  • 加载生产数据库的副本并对其进行测试。这里的问题是您可能不知道在任何给定时间生产数据库中有什么。如果数据随时间变化,您的测试可能需要重写。

有人指出,这两种策略都依赖于特定的数据,单元测试应该只测试功能。为此,我看到建议:

  • 使用模拟数据库服务器,并仅检查 ORM 是否发送正确的查询以响应给定的方法调用。

您使用了哪些策略来测试数据库驱动的应用程序(如果有的话)?什么对你最有效?

【问题讨论】:

  • 我认为你仍然应该在测试环境中为唯一索引之类的情况提供数据库索引。
  • 我个人不介意这个问题,但如果我们按照规则,这个问题不是针对 stackoverflow 而是针对 softwareengineering.stackexchange i> 网站。
  • 这个问题将 3 个不同的方面结合为一个问题。 1. 不同环境中的数据库同步(dev、qa、staging...) 2. 数据库性能测试 3. 单元测试 每个方面都有一些最佳实践。

标签: database unit-testing orm mocking


【解决方案1】:

我实际上已经使用了您的第一种方法并取得了相当大的成功,但是我认为以稍微不同的方式可以解决您的一些问题:

  1. 将整个架构和用于创建它的脚本保存在源代码管理中,以便任何人在签出后都可以创建当前数据库架构。此外,将样本数据保存在由构建过程的一部分加载的数据文件中。当您发现导致错误的数据时,请将其添加到您的示例数据中以检查错误不会再次出现。

  2. 使用持续集成服务器来构建数据库架构、加载示例数据并运行测试。这就是我们保持测试数据库同步的方式(在每次测试运行时重建它)。虽然这要求 CI 服务器有权访问和拥有自己的专用数据库实例,但我说每天构建 3 次我们的 db 模式极大地帮助发现了可能直到交付之前才发现的错误(如果不是稍后)。我不能说我在每次提交之前都重建了架构。有人吗?使用这种方法,您将不必这样做(也许我们应该这样做,但如果有人忘记了也没什么大不了的)。

  3. 对于我的小组,用户输入是在应用程序级别(而不是数据库)完成的,因此这是通过标准单元测试进行测试的。

加载生产数据库副本:
这是我上一份工作中使用的方法。这是几个问题的巨大痛苦原因:

  1. 副本会从生产版本过时
  2. 将对副本的架构进行更改,并且不会传播到生产系统。在这一点上,我们会有不同的模式。不好玩。

模拟数据库服务器:
在我目前的工作中,我们也这样做。每次提交后,我们对注入了模拟数据库访问器的应用程序代码执行单元测试。然后,我们每天执行 3 次上述完整的数据库构建。我绝对推荐这两种方法。

【讨论】:

  • 加载生产数据库副本还涉及安全和隐私问题。一旦它变大,复制它并将其放入您的开发环境中可能是一件大事。
  • 老实说,这是一个巨大的痛苦。我是测试新手,我还写了一个我想测试的 orm。我已经使用了您的第一种方法,但读到它不会成为测试单元。我使用特定的数据库引擎功能,所以模拟 DAO 会很困难。我认为我只使用我当前的方法,因为它有效并且其他人使用它。顺便说一句,自动化测试很摇滚。谢谢。
  • 我管理着两个不同的大型项目,在其中一个项目中,这种方法非常完美,但在另一个项目中实现这一点时遇到了很多麻烦。所以我认为这取决于每次执行测试时重新创建模式的难易程度,我目前正在努力为这个永远存在的问题寻找新的解决方案。
  • 在这种情况下,使用像 Roundhouse 这样的数据库版本控制工具绝对值得 - 可以运行迁移的东西。这可以在任何数据库实例上运行,并且应该确保架构是最新的。此外,在编写迁移脚本时,还应编写测试数据——保持迁移和数据同步。
  • 更好地使用猴子补丁和模拟,避免编写操作
【解决方案2】:

我一直在问这个问题很长时间,但我认为没有灵丹妙药。

我目前所做的是模拟 DAO 对象,并在内存中保留一个良好的对象集合表示,这些对象表示可能存在于数据库中的有趣数据案例。

我看到这种方法的主要问题是,您只涵盖与 DAO 层交互的代码,但从未测试 DAO 本身,根据我的经验,我发现该层发生了很多错误,因为好吧。我还保留了一些针对数据库运行的单元测试(为了在本地使用 TDD 或快速测试),但这些测试从未在我的持续集成服务器上运行,因为我们没有为此目的保留数据库,我认为在 CI 服务器上运行的测试应该是独立的。

我发现另一种非常有趣但并不总是值得的方法,因为它有点耗时,它是在仅在单元测试中运行的嵌入式数据库上创建用于生产的相同模式。

尽管毫无疑问,这种方法可以提高您的覆盖率,但也有一些缺点,因为您必须尽可能接近 ANSI SQL 才能使其与您当前的 DBMS 和嵌入式替代品一起使用。

无论您认为什么与您的代码更相关,都有一些项目可能会使其更容易,例如 DbUnit

【讨论】:

    【解决方案3】:

    出于以下原因,我总是针对内存数据库(HSQLDB 或 Derby)运行测试:

    • 它让您思考将哪些数据保存在您的测试数据库中以及为什么。只需将您的生产数据库拖入测试系统就可以转化为“我不知道我在做什么或为什么,如果出现问题,那不是我!!” ;)
    • 它确保可以在新位置轻松重新创建数据库(例如,当我们需要从生产中复制错误时)
    • 它极大地帮助了 DDL 文件的质量。

    一旦测试开始,内存中的数据库就会加载新数据,并且在大多数测试之后,我调用 ROLLBACK 来保持它的稳定。 始终保持测试数据库中的数据稳定!如果数据一直在变化,则无法测试。

    数据从 SQL、模板数据库或转储/备份加载。如果它们是可读格式,我更喜欢转储,因为我可以将它们放入 VCS。如果这不起作用,我使用 CSV 文件或 XML。如果我必须加载大量数据……我不会。您永远不必加载大量数据 :) 不适用于单元测试。性能测试是另一个问题,适用不同的规则。

    【讨论】:

    • 速度是使用(特别是)内存数据库的唯一原因吗?
    • 我想另一个优点可能是它的“一次性”性质 - 无需自己清理;只需杀死内存数据库。 (但还有其他方法可以做到这一点,例如您提到的 ROLLBACK 方法)
    • 优点是每个测试都可以单独选择它的策略。我们有在子线程中完成工作的测试,这意味着 Spring 将始终提交数据。
    • @Aaron:我们也在遵循这个策略。我想知道您断言内存模型与真实数据库具有相同结构的策略是什么?
    • @Guillaume:我正在从同一个 SQL 文件创建所有数据库。 H2 非常适合这一点,因为它支持主要数据库的大多数 SQL 特性。如果这不起作用,那么我使用一个过滤器,它采用原始 SQL 并将其转换为内存数据库的 SQL。
    【解决方案4】:

    我使用第一个(针对测试数据库运行代码)。我看到你用这种方法提出的唯一实质性问题是模式不同步的可能性,我通过在我的数据库中保留版本号并通过脚本对每个版本增量应用更改来进行所有模式更改来处理。

    我还首先针对我的测试环境进行了所有更改(包括对数据库架构的更改),所以它最终是相反的:在所有测试通过后,将架构更新应用到生产主机。我还在我的开发系统上保留了一对单独的测试与应用程序数据库,以便我可以在那里验证数据库升级是否正常工作,然后再接触真正的生产盒。

    【讨论】:

      【解决方案5】:

      即使有工具可以让您以一种或另一种方式模拟您的数据库(例如jOOQMockConnection,可以在this answer 中看到 - 免责声明,我为 jOOQ 的供应商工作),我建议不要用复杂的查询来模拟更大的数据库。

      即使您只是想对您的 ORM 进行集成测试,请注意 ORM 会向您的数据库发出一系列非常复杂的查询,这些查询可能会有所不同

      • 语法
      • 复杂性
      • 订单 (!)

      模拟所有这些以生成合理的虚拟数据非常困难,除非您实际上是在模拟中构建一个小数据库,它解释传输的 SQL 语句。话虽如此,请使用众所周知的集成测试数据库,您可以使用众所周知的数据轻松地对其进行重置,然后运行集成测试。

      【讨论】:

        【解决方案6】:

        对于基于 JDBC 的项目(直接或间接,例如 JPA、EJB,...),您可以不模拟整个数据库(在这种情况下,最好在真实 RDBMS 上使用测试数据库),但只能模拟在 JDBC 级别。

        优点是这种方式的抽象,因为 JDBC 数据(结果集、更新计数、警告......)无论后端是什么:您的产品数据库、测试数据库或提供的一些模型数据都是相同的每个测试用例。

        通过为每种情况模拟 JDBC 连接,无需管理测试数据库(清理、一次只进行一个测试、重新加载夹具等)。每个模型连接都是隔离的,无需清理。每个测试用例中只提供最少的固定装置来模拟 JDBC 交换,这有助于避免管理整个测试数据库的复杂性。

        Acolyte 是我的框架,其中包括用于此类模型的 JDBC 驱动程序和实用程序:http://acolyte.eu.org

        【讨论】:

          【解决方案7】:

          我使用的是第一种方法,但有点不同,可以解决你提到的问题。

          为 DAO 运行测试所需的一切都在源代码控制中。它包括用于创建数据库的模式和脚本(docker 对此非常有用)。如果可以使用嵌入式数据库 - 我使用它来提高速度。

          与其他描述方法的重要区别在于,测试所需的数据不是从 SQL 脚本或 XML 文件加载的。一切(除了一些有效不变的字典数据)都是由应用程序使用实用程序函数/类创建的。

          主要目的是让数据供测试使用

          1. 非常接近测试
          2. 显式(使用 SQL 文件存储数据使得查看哪些数据被哪些测试使用非常有问题)
          3. 将测试与不相关的更改隔离开来。

          这基本上意味着这些实用程序允许在测试本身中以声明方式仅指定测试所必需的内容并省略不相关的内容。

          要了解它在实践中的含义,请考虑由Authors 编写的与Comments 到Posts 一起使用的某些DAO 的测试。为了测试此类 DAO 的 CRUD 操作,应在数据库中创建一些数据。测试看起来像:

          @Test
          public void savedCommentCanBeRead() {
              // Builder is needed to declaratively specify the entity with all attributes relevant
              // for this specific test
              // Missing attributes are generated with reasonable values
              // factory's responsibility is to create entity (and all entities required by it
              //  in our example Author) in the DB
              Post post = factory.create(PostBuilder.post());
          
              Comment comment = CommentBuilder.comment().forPost(post).build();
          
              sut.save(comment);
          
              Comment savedComment = sut.get(comment.getId());
          
              // this checks fields that are directly stored
              assertThat(saveComment, fieldwiseEqualTo(comment));
              // if there are some fields that are generated during save check them separately
              assertThat(saveComment.getGeneratedField(), equalTo(expectedValue));        
          }
          

          这比带有测试数据的 SQL 脚本或 XML 文件有几个优点:

          1. 维护代码更容易(例如在许多测试中引用的某些实体中添加强制列,例如作者,不需要更改大量文件/记录,而只需更改构建器和/或工厂)
          2. 特定测试所需的数据在测试本身中描述,而不是在其他文件中。这种接近性对于测试的可理解性非常重要。

          回滚与提交

          我发现测试在执行时提交更方便。首先,如果提交从未发生,则无法检查某些效果(例如DEFERRED CONSTRAINTS)。其次,当测试失败时,可以在数据库中检查数据,因为它不会被回滚恢复。

          当然,这有一个缺点,即测试可能会产生损坏的数据,这将导致其他测试失败。为了解决这个问题,我尝试隔离测试。在上面的示例中,每个测试都可能创建新的Author,并且所有其他实体都与其相关,因此很少发生冲突。为了处理可能被破坏但不能表示为数据库级别约束的剩余不变量,我使用一些编程检查可能在每次测试后运行的错误条件(它们在 CI 中运行,但通常在本地关闭以提高性能原因)。

          【讨论】:

          • 如果您使用实体和 orm 而不是 sql 脚本为数据库播种,它还有一个优点,即如果您对模型进行更改,编译器将强制您修复种子代码。仅当您当然使用静态类型语言时才相关。
          • 澄清一下:您是在整个应用程序中使用实用程序函数/类,还是仅用于测试?
          • @Ella 这些实用功能通常在测试代码之外不需要。例如考虑PostBuilder.post()。它为帖子的所有必需属性生成一些值。这在生产代码中是不需要的。
          猜你喜欢
          • 2020-03-06
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多