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

我使用过很多web应用程序,它们都是由后台复杂程度各异的数据库驱动的。通常,有一个ORM层独立于业务和表示逻辑。这使得单元测试业务逻辑相当简单;事情可以在离散的模块中实现,测试所需的任何数据都可以通过对象模拟来伪造。

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

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

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

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

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

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

您在测试数据库驱动的应用程序时使用了哪些策略?对你来说最有效的方法是什么?

121480 次浏览

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

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

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

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

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

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

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

我一直在问这个问题,但我认为没有解决这个问题的灵丹妙药。

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

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

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

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

无论你认为哪个与你的代码更相关,都有一些项目可以让它更简单,比如DbUnit

出于以下原因,我总是对内存中的DB (HSQLDB或Derby)运行测试:

  • 它使您考虑在测试DB中保留哪些数据以及为什么要保留这些数据。仅仅将您的生产DB拖到测试系统中就意味着“我不知道我在做什么,也不知道为什么,如果有什么东西坏了,那肯定不是我!!”;)
  • 它确保可以在新的地方轻松地重新创建数据库(例如,当我们需要从生产中复制一个错误时)。
  • 它极大地提高了DDL文件的质量。

一旦测试开始,内存中的DB就会加载新的数据,在大多数测试之后,我调用ROLLBACK以保持它的稳定。总是保持数据在测试DB稳定!如果数据一直在变化,就不能进行测试。

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

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

我还首先对测试环境进行了所有更改(包括对数据库模式的更改),因此结果正好相反:在所有测试通过之后,将模式更新应用到生产主机。我还在我的开发系统上保留了一对单独的测试数据库和应用程序数据库,这样我就可以在接触真正的生产系统之前验证数据库升级是否正常工作。

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

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

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

模拟所有这些以产生合理的虚拟数据是相当困难的,除非您实际上在模拟中构建了一个小数据库,它解释传输的SQL语句。话虽如此,请使用一个知名的集成测试数据库,您可以很容易地使用知名数据重置该数据库,并针对该数据库运行集成测试。

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

这样做的好处是抽象,因为JDBC数据(结果集、更新计数、警告……)无论在后端是什么:您的prod db、测试db,还是为每个测试用例提供的一些模型数据,都是相同的。

通过为每种情况模拟JDBC连接,不需要管理测试db(清理,一次只进行一个测试,重新加载fixture,等等)。每个模拟连接都是隔离的,不需要清理。每个测试用例中只提供了模拟JDBC交换所需的最低限度的fixture,这有助于避免管理整个测试数据库的复杂性。

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

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

为dao运行测试所需的一切都在源代码控制中。它包括创建DB的模式和脚本(docker在这方面非常好)。如果嵌入式数据库可以使用-我使用它的速度。

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

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

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

这基本上意味着这些实用程序允许声明式地在测试本身中指定对测试至关重要的东西,而忽略不相关的东西。

为了了解它在实践中的含义,考虑一些DAO的测试,该测试使用由Authors编写的__abc0到__abc1。为了测试此类DAO的CRUD操作,应该在DB中创建一些数据。测试看起来像这样:

@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. 特定测试所需的数据在测试本身中进行了描述,而不是在其他文件中。这种接近性对于测试可理解性非常重要。

回滚vs提交

我发现测试在执行时提交更方便。首先,有些效果(例如DEFERRED CONSTRAINTS)如果从未发生提交就不能检查。其次,当测试失败时,可以在DB中检查数据,因为回滚没有恢复数据。

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