单元测试数据库驱动的应用程序的最佳策略是什么?

时间:2008-09-28 03:26:42

标签: database unit-testing orm mocking

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

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

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

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

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

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

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

您使用了哪些策略来测试数据库驱动的应用程序?什么对你有好处?

7 个答案:

答案 0 :(得分:138)

我实际上已经使用了你的第一种方法取得了相当大的成功,但我认为这种解决方案可以解决你的一些问题:

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

  2. 使用持续集成服务器构建数据库架构,加载示例数据并运行测试。这就是我们如何使测试数据库保持同步(在每次测试运行时重建它)。虽然这要求CI服务器具有对其自己的专用数据库实例的访问权和所有权,但我说每天建立3次我们的数据库模式可以极大地帮助找到在交付之前可能找不到的错误(如果不是以后的话) )。我不能说我在每次提交之前重建架构。有人吗?使用这种方法你不必(也许我们应该,但如果有人忘记,这不是什么大问题。)

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

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

    1. 副本将从生产版本
    2. 过期
    3. 将对副本的架构进行更改,但不会传播到生产系统。在这一点上,我们有不同的模式。不好玩。
    4. 模拟数据库服务器:
      我们目前的工作也是这样做的。在每次提交之后,我们对注入了模拟db访问器的应用程序代码执行单元测试。然后我们每天三次执行上面描述的完整db构建。我绝对推荐这两种方法。

答案 1 :(得分:52)

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

  • 它会让您考虑在测试数据库中保留哪些数据以及原因。将生产数据库运送到测试系统只是“我不知道我在做什么或为什么,如果有什么东西坏了,那不是我!” ;)
  • 确保数据库可以在新地方轻松重建(例如,当我们需要从生产中复制错误时)
  • 它极大地提高了DDL文件的质量。

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

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

答案 2 :(得分:14)

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

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

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

我觉得非常有趣的另一种方法,但并不总是值得花费一点时间,就是在单元测试中运行的嵌入式数据库上创建用于生产的相同模式。

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

无论您认为哪些内容与您的代码更相关,都有一些项目可以让您更轻松,例如DbUnit

答案 3 :(得分:10)

即使有工具允许您以某种方式模拟数据库(例如jOOQMockConnection,这可以在this answer中看到 - 免责声明,我工作对于jOOQ的供应商而言,我建议使用复杂查询来模拟更大的数据库。

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

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

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

答案 4 :(得分:5)

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

我还首先针对我的测试环境进行所有更改(包括数据库模式),因此它最终成为另一种方式:在所有测试通过后,将模式更新应用于生产主机。我还在我的开发系统上保留了一对单独的测试与应用程序数据库,这样我就可以在触摸真实生产框之前验证数据库升级是否正常工作。

答案 5 :(得分:2)

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

优点是这样的抽象,因为JDBC数据(结果集,更新计数,警告......)与后端无关:你的prod db,测试数据库,或者只提供一些模型数据对于每个测试用例。

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

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

答案 6 :(得分:2)

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

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

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

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

  1. 非常接近测试
  2. 显式的(使用SQL文件存储数据使查看哪个数据使用哪个测试非常困难)
  3. 将测试与无关的更改隔离开。

基本上,这意味着这些实用程序仅允许声明性地指定测试本身对测试必不可少的内容,而忽略不相关的内容。

要弄清楚它在实践中的含义,请考虑对一些CommentPostAuthors的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. 维护代码要容易得多(例如,在许多测试中引用的某些实体(例如Author)中添加必需列,不需要更改大量文件/记录,而只需更改生成器和/或工厂即可)
  2. 特定测试所需的数据在测试本身中描述,而不在其他文件中描述。这种接近性对于测试可理解性非常重要。

回滚与提交

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

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