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


346

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

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

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

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

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

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

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

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


我认为您在测试环境中仍应具有数据库索引,以应对诸如唯一索引之类的情况。
dtc

我个人在这里不介意这个问题,但是如果按照规则进行操作,这个问题不是针对stackoverflow而是针对softwareengineering.stackexchange网站。
ITExpert

Answers:


155

实际上,我已经成功地使用了您的第一种方法,但是我认为可以解决一些问题的方式略有不同:

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

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

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

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

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

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


37
加载生产数据库副本也具有安全性和隐私隐患。一旦变大,将其复制并放入您的开发环境中可能会很困难。
WW。

老实说,这是一个巨大的痛苦。我是测试新手,我还写了一个我想测试的orm。我已经使用了您的第一种方法,但请阅读它不能构成测试单元。我使用特定的数据库引擎功能,因此模拟DAO会很困难。我认为生病只能使用我当前的方法,因为它可以工作,而其他人也可以使用它。自动测试摇滚。谢谢。
frostymarvelous 2011年

2
我管理两个不同的大型项目,在其中一个中,这种方法是完美的,但是在另一个项目中尝试实施此方法却遇到了很多麻烦。因此,我认为这取决于每次执行测试时重新创建架构的难易程度,我目前正在努力为这个永远存在的问题找到新的解决方案。
2013年

2
在这种情况下,使用诸如Roundhouse之类的数据库版本控制工具绝对值得,因为它可以运行迁移。它可以在任何数据库实例上运行,并且应确保架构是最新的。此外,在编写迁移脚本时,还应该编写测试数据-保持迁移和数据同步。
jedd.ahyoung,

更好地使用猴子补丁和
模拟

56

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

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

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

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


1
速度是使用(特别是)内存数据库的唯一原因吗?
rinogo 2014年

2
我想另一个好处可能是它的“扔掉”性质-无需自己清理。只是杀死内存数据库。(但是还有其他方法可以完成此操作,例如您提到的ROLLBACK方法)
rinogo 2014年

1
优点是每个测试可以单独选择其策略。我们有在子线程中完成工作的测试,这意味着Spring将始终提交数据。
2014年

@Aaron:我们也遵循这一策略。我想知道您断言内存模型与真实数据库具有相同结构的策略是什么?
Guillaume

1
@Guillaume:我正在从相同的SQL文件创建所有数据库。H2对此非常有用,因为它支持主要数据库的大多数SQL特质。如果这不起作用,那么我将使用一个过滤器,该过滤器采用原始SQL,并将其转换为内存数据库的SQL。
亚伦·迪古拉

14

我问这个问题已经很长时间了,但是我认为没有万灵药。

我目前要做的是模拟DAO对象,并在内存中保留大量对象的表示形式,这些对象表示可以存在于数据库中的有趣数据实例。

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

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

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

不管您认为与代码更相关的是什么,都有一些项目可能会使它变得更容易,例如DbUnit


13

即使有工具,让你嘲笑你的数据库在这种或那种方式(如jOOQMockConnection,它可以被看作这个答案 -免责声明,我对jOOQ的供应商合作),我会建议嘲笑复杂的大型数据库查询。

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

  • 句法
  • 复杂
  • 订购(!)

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


5

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

我还首先对测试环境进行了所有更改(包括对数据库架构的更改),因此最终导致了另一种方式:在所有测试通过之后,将架构更新应用于生产主机。我还在开发系统上保留了一对单独的测试数据库和应用程序数据库,以便在接触实际生产环境之前可以验证数据库升级是否正常。


3

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

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

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

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

  1. 非常接近测试
  2. 明确的(使用SQL文件存储数据使查看哪些数据使用什么测试变得非常困难)
  3. 将测试与无关的更改隔离开来。

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

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

回滚与提交

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

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


如果使用实体和orm而不是sql脚本为数据库设置种子,那么它还具有以下优点:如果对模型进行更改,编译器将强制您修复种子代码。当然,只有在使用静态类型语言时才有意义。
daramasala

因此,为了澄清起见:您是在整个应用程序中使用实用程序功能/类,还是仅用于测试?
Ella

@Ella这些实用程序功能通常不需要测试代码。想想例如PostBuilder.post()。它为帖子的所有必填属性生成一些值。生产代码中不需要这样做。
Roman Konoval

2

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

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

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

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.