383

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

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

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

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

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

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

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

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

4

7 回答 7

163

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

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

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

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

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

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

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

于 2008-09-28T03:45:30.500 回答
60

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

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

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

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

于 2008-11-24T15:34:50.900 回答
14

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

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

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

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

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

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

于 2008-09-28T03:55:29.553 回答
13

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

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

  • 句法
  • 复杂
  • 命令 (!)

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

于 2013-08-01T21:13:01.613 回答
5

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

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

于 2008-11-24T17:06:47.733 回答
3

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

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

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

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

于 2014-07-12T12:18:13.340 回答
3

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

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

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

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

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

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

为了了解它在实践中的含义,请考虑对某些 DAO 的测试,该测试与Comments to Posts 编写的s 一起使用Authors。为了测试此类 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 中运行,但通常在本地关闭以提高性能原因)。

于 2018-09-08T23:26:44.537 回答