7

我有以下挑战,我还没有找到一个好的答案。我正在使用 Mocking 框架(在本例中为 JMock)来允许将单元测试与数据库代码隔离开来。我正在模拟对涉及数据库逻辑的类的访问,并使用 DBUnit 单独测试数据库类。

我遇到的问题是,我注意到逻辑在概念上在多个地方重复的模式。例如,我需要检测数据库中的某个值不存在,因此在这种情况下我可能会从方法中返回 null。所以我有一个数据库访问类,它进行数据库交互,并适当地返回 null。然后我有一个业务逻辑类,它从模拟中接收 null,然后在值为 null 时进行测试以适当地采取行动。

现在,如果将来该行为需要更改并且返回 null 不再合适,比如说因为状态变得更加复杂,所以我需要返回一个报告该值不存在的对象以及来自的一些附加事实数据库。

现在,如果我将数据库类的行为更改为在这种情况下不再返回 null,那么业务逻辑类似乎仍然可以正常工作,并且该错误只会在 QA 中发现,除非有人记得耦合,或者正确地遵循了方法的用途。

我觉得我错过了一些东西,必须有一种更好的方法来避免这种概念上的重复,或者至少对其进行测试,以便如果它发生变化,那么没有传播变化的事实会使单元测试失败。

有什么建议么?

更新:

让我试着澄清我的问题。我正在考虑代码何时随时间演变,如何确保通过模拟测试的类和模拟所代表的类的实际实现之间的集成不会中断。

例如,我刚刚有一个案例,我有一个最初创建的方法并且不期望 null 值,所以这不是对真实对象的测试。然后,该类的用户(通过模拟测试)得到增强,可以在某些情况下将 null 作为参数传递。在集成中断时,因为未对真实类进行空值测试。现在,在开始构建这些类时,这没什么大不了的,因为您在构建时正在测试两端,但是如果设计需要在两个月后发展,而您往往会忘记细节,那么您将如何测试它们之间的交互这两组对象(通过模拟测试与实际实现测试的对象)?

潜在的问题似乎是重复之一(即违反 DRY 原则),期望确实保留在两个地方,虽然关系是概念上的,但没有实际的重复代码。

[在 Aaron Digulla 对他的回答进行第二次编辑后编辑]:

对,这正是我正在做的事情(除了在通过 DBUnit 测试并在测试期间与数据库交互的类中与 DB 有一些进一步的交互,但这是相同的想法)。所以现在,假设我们需要修改数据库行为以使结果不同。使用模拟的测试将继续通过,除非 1)有人记得或 2)它在集成中中断。所以数据库的存储过程返回值(比如)在模拟的测试数据中本质上是重复的。现在让我困扰的是重复的逻辑是重复的,这是对 DRY 的微妙违反。可能就是这样(毕竟集成测试是有原因的),但我觉得我错过了一些东西。

[编辑开始赏金]

阅读与 Aaron 的互动就可以找到问题的重点,但我真正想要的是一些关于如何避免或管理明显重复的见解,以便真实班级行为的变化将出现在与 mock 交互的单元测试是有问题的。显然这不会自动发生,但可能有一种方法可以正确设计场景。

[关于奖励赏金的编辑]

感谢所有花时间回答问题的人。获胜者教会了我一些关于如何考虑在两层之间传递数据的新知识,并首先得到了答案。

4

11 回答 11

5

你从根本上要求不可能的事。当您更改外部资源的行为时,您要求您的单元测试预测并通知您。如果不编写测试来产生新的行为,他们怎么知道?

您所描述的是添加一个必须测试的全新状态 - 而不是空结果,现在有一些对象从数据库中出来。你的测试套件怎么可能知道被测对象的预期行为对于一些新的随机对象应该是什么?你需要编写一个新的测试。

正如您评论的那样,模拟不是“行为不端”。模拟正在做你设置它做的事情。规范更改的事实对模拟没有影响。在这种情况下,唯一的问题是实施更改的人忘记更新单元测试。实际上,我不太确定您为什么认为存在任何重复的担忧。

向系统添加一些新的返回结果的编码器负责添加单元测试来处理这种情况。如果该代码也 100% 确定现在不可能返回 null 结果,那么他也可以删除旧的单元测试。但你为什么要?单元测试正确地描述了被测对象在收到空结果时的行为。如果您将系统的后端更改为某个返回 null 的新数据库,会发生什么情况?如果规范改回返回 null 怎么办?您不妨保留测试,因为就您的对象而言,它确实可以从外部资源中获取任何东西,并且它应该优雅地处理所有可能的情况。

模拟的全部目的是将您的测试与真实资源分离。它不会自动使您免于将错误引入系统。如果您的单元测试准确地描述了收到 null 时的行为,那就太好了!但是这个测试不应该知道任何其他状态,当然也不应该以某种方式被告知外部资源将不再发送空值。

如果你在做正确的、松耦合的设计,你的系统可以有你能想象到的任何后端。您不应该只考虑一种外部资源来编写测试。如果您添加一些使用真实数据库的集成测试,从而消除模拟层,听起来您可能会更开心。这对于进行构建或健全性/烟雾测试总是一个好主意,但通常会阻碍日常开发。

于 2009-05-08T17:43:03.640 回答
4

你不会在这里错过任何东西。这是使用模拟对象进行单元测试的一个弱点。听起来您正在正确地将单元测试分解为合理大小的单元。这是一件好事;在“单元”测试中发现人们测试过多的情况更为常见。

不幸的是,当您在这种粒度级别进行测试时,您的单元测试不会涵盖协作对象之间的交互。您需要进行一些集成测试或功能测试来涵盖这一点。我真的不知道比这更好的答案了。

有时在单元测试中使用真正的协作者而不是模拟是很实际的。例如,如果您正在对数据访问对象进行单元测试,那么在单元测试中使用真实的域对象而不是模拟通常很容易设置并执行得一样好。反过来往往不正确——数据访问对象通常需要数据库连接、文件或网络连接,而且设置起来非常复杂且耗时;在对域对象进行单元测试时使用真实的数据对象会将需要几微秒的单元测试变成需要数百或数千毫秒的单元测试。

所以总结一下:

  1. 编写一些集成/功能测试来捕捉协作对象的问题
  2. 并不总是需要嘲笑合作者——用你最好的判断
于 2009-05-08T22:22:39.537 回答
2

单元测试无法告诉您某个方法何时突然产生了一组较小的可能结果。这就是代码覆盖的用途:它会告诉您代码不再执行。这反过来又会导致在应用层中发现死代码。

[编辑] 基于评论:模拟不能做任何事情,只能允许实例化被测类并允许收集其他信息。特别是,它绝不能影响您要测试的结果。

[EDIT2] 模拟数据库意味着您不关心数据库驱动程序是否工作。您想知道的是您的代码是否可以正确解释数据库返回的数据。此外,这是测试您的错误处理是否正常工作的唯一方法,因为您无法告诉真正的 DB 驱动程序“当您看到此 SQL 时,抛出此错误”。这只能通过模拟来实现。

我同意,这需要一些时间来适应。这是我所做的:

  • 我有检查 SQL 是否有效的测试。每个 SQL 都会针对静态测试数据库执行一次,我验证返回的数据是否符合我的预期。

  • 所有其他测试都使用返回预定义结果的模拟数据库连接器运行。我喜欢通过对数据库运行代码,在某处记录主键来获得这些结果。然后,我编写了一个工具,它采用这些主键并将带有模拟的 Java 代码转储到 System.out。这样,我可以非常快速地创建新的测试用例,并且测试用例将反映“真相”。

    更好的是,我可以通过再次运行旧 ID 和我的工具来重新创建旧测试(当数据库更改时)

于 2009-03-13T16:09:37.090 回答
2

您的数据库抽象使用 null 表示“未找到结果”。忽略在对象之间传递 null 是一个坏主意这一事实,当您的测试想要测试什么都没有找到时会发生什么时,不应使用该 null 文字。相反,请使用常量或测试数据构建器,以便您的测试仅涉及对象之间传递的信息,而不涉及该信息的表示方式。然后,如果您需要更改数据库层表示“未找到结果”(或您的测试所依赖的任何信息)的方式,您在测试中只有一个地方可以更改它。

于 2009-05-12T15:11:18.640 回答
1

我想把问题缩小到它的核心。

问题

当然,您的大部分更改都会被测试捕获。
但是有一部分场景您的测试不会失败 - 尽管它应该:

在编写代码时,您会多次使用您的方法。您会得到方法定义和使用之间的 1:n 关系。每个使用该方法的类都将在相应的测试中使用它的模拟。所以mock也用了n次。

您的方法结果曾经被认为永远不会是null. 更改此设置后,您可能会记得修复相应的测试。到现在为止还挺好。

您运行您的测试 -全部通过

但是随着时间的流逝,您忘记了一些东西……模拟永远不会返回null. 因此,使用模拟的 n 类的 n 测试不测试null.

你的QA 会失败——尽管你的测试没有失败。

显然,您将不得不修改其他测试。但是没有失败的工作。所以你需要一个比记住所有引用测试更好的解决方案。

一个办法

为避免此类问题,您必须从一开始就编写更好的测试。如果您错过了测试类应该处理错误或null值的情况,那么您只是有不完整的测试。这就像没有测试您班级的所有功能。

以后很难添加这个。- 所以尽早开始并进行广泛的测试。

正如其他用户所提到的 - 代码覆盖率揭示了一些未经测试的案例。但是缺少错误处理代码缺少根据测试不会出现在代码覆盖率中。(100% 的代码覆盖率并不意味着你没有遗漏任何东西。)

所以写好测试:假设外界是恶意的。这不仅包括传递错误的参数(如null值)。你的模拟也是外部世界的一部分。通过nulls 和异常 - 并观察您的班级按预期处理它们。

如果您决定null成为一个有效值 - 这些测试稍后将失败(因为缺少异常)。所以你会得到一个失败的列表。

因为每个调用类处理错误或null不同 - 这不是可以避免的重复代码。不同的治疗需要不同的测试。


提示:保持你的模拟简单和干净。将预期的返回值移动到测试方法。(您的模拟可以简单地将它们传递回去。)避免在模拟中测试决策。

于 2009-05-09T13:46:47.697 回答
1

我是这样理解你的问题的:

您正在使用实体的模拟对象来使用 JMock 测试应用程序的业务层。您还在使用 DBUnit 测试您的 DAO 层(您的应用程序和数据库之间的接口),并传递填充了一组已知值的实体对象的真实副本。因为您使用 2 种不同的方法来准备测试对象,所以您的代码违反了 DRY,并且您的测试可能会随着代码的更改而与现实不同步。

福尔说……

它不完全相同,但它确实让我想起了 Martin Fowler 的Mocks Aren't Stubs文章。我认为 JMock 路线是模拟方式,而“真实对象”路线是执行测试的古典主义方式。

在解决这个问题时尽可能保持 DRY 的一种方法是成为一个古典主义者而不是一个模仿者。也许您可以在测试中妥协并使用您的 bean 对象的真实副本。

用户制造商避免重复

我们在一个项目上所做的是为我们的每个业务对象创建Maker。maker 包含静态方法,这些方法将构造给定实体对象的副本,并填充已知值。然后,无论您需要哪种对象,您都可以调用该对象的制造商并获取其具有已知值的副本以用于您的测试。如果该对象有子对象,您的 maker 将为子对象调用 makers 以便从上到下构造它,您将根据需要获得尽可能多的完整对象图。您可以将这些 maker 对象用于所有测试——在测试 DAO 层时将它们传递给数据库,并在测试业务服务时将它们传递给服务调用。因为制造商是可重复使用的,所以它是一种相当干燥的方法。

然而,您仍然需要使用 JMock 的一件事是在测试您的服务层时模拟您的 DAO 层。如果你的服务调用 DAO,你应该确保它被注入了一个模拟。但是你仍然可以使用你的 Maker —— 在设置你的期望时,只要确保你的模拟 DAO 使用相关实体对象的 Maker 传回期望的结果。这样我们仍然没有违反 DRY。

编写良好的测试会在代码更改时通知您

为了避免您的代码随时间变化而出现的问题,我的最后建议是始终进行针对空输入的测试。假设当您第一次创建方法时,null 是不可接受的。如果使用 null,您应该有一个测试来验证是否引发了异常。如果稍后可以接受空值,您的应用程序代码可能会更改,以便以新的方式处理空值,并且不再引发异常。发生这种情况时,您的测试将开始失败,并且您将“提醒”事情不同步。

于 2009-05-12T21:30:36.523 回答
1

您只需决定返回 null 是外部 API 的预期部分还是实现细节。

单元测试不应该关心实现细节。

如果它是您预期的外部 API 的一部分,那么由于您的更改可能会破坏客户端,这自然也应该破坏单元测试。

从外部 POV 来看,这个东西返回 NULL 是否有意义,或者这是一个方便的结果,因为可以在客户端中直接假设这个 NULL 的含义?NULL 应该意味着 void/nix/nada/unavailable 没有任何其他含义。

如果您打算稍后细化此条件,则应将 NULL 检查包装到返回信息异常、枚举或显式命名的布尔值的内容中。

编写单元测试的一个挑战是,即使是第一个编写的单元测试也应该反映最终产品中的完整 API。您需要可视化完整的 API,然后针对它进行编程。

此外,您需要在单元测试代码中保持与在生产代码中相同的纪律,避免重复和功能嫉妒之类的气味。

于 2009-05-13T11:29:52.423 回答
0

对于特定场景,您正在更改方法的返回类型,这将在编译时被捕获。如果没有,它将出现在代码覆盖率上(如 Aaron 所述)。即使这样,您也应该进行自动化功能测试,这些测试将在签入后不久运行。也就是说,我进行了自动烟雾测试,所以在我的情况下,那些会发现:)。

如果不考虑上述情况,您仍然有两个重要因素在初始场景中发挥作用。您希望对单元测试代码给予与其他代码相同的关注,这意味着保持它们 DRY 是合理的。如果你在做 TDD,那甚至会把这个问题放在你的设计上。如果您对此不感兴趣,则涉及的另一个相反因素是 YAGNI,您不希望在代码中出现所有(不)可能的情况。所以,对我来说,它会是:如果我的测试告诉我我遗漏了一些东西,我会仔细检查测试是否正常并继续进行更改。我确保不要在我的测试中做假设场景,因为这是一个陷阱。

于 2009-03-13T16:23:55.870 回答
0

如果我正确理解了这个问题,那么您有一个使用模型的业务对象。有一个测试 BO 和 Model 的交互(Test A),还有一个测试 Model 和数据库的交互(Test B)。测试 B 更改为返回一个对象,但该更改不会影响测试 A,因为测试 A 的模型是模拟的。

当测试 B 更改时,我看到使测试 A 失败的唯一方法是不在测试 A 中模拟模型并将两者组合成一个测试,这不好,因为你会测试太多(而且你使用不同的框架)。

如果您在编写测试时知道这种依赖关系,我认为一个可接受的解决方案是在每个测试中留下评论,描述依赖关系以及如果一个更改,您需要更改另一个。无论如何,您必须在重构时更改测试 B,一旦您进行更改,当前测试就会失败。

于 2009-05-13T14:42:24.247 回答
-1

您的问题非常令人困惑,并且文本的数量并没有帮助。

但是我可以通过快速阅读来提取的含义对我来说意义不大,因为您希望非合同更改更改会影响模拟的工作方式。

模拟是让您专注于测试系统特定部分的推动力。模拟部分将始终以指定的方式工作,并且测试可以专注于测试它应该测试的特定逻辑。因此,您不会受到无关逻辑、延迟问题、意外数据等的影响。

您可能会有不同数量的测试在另一个上下文中检查模拟功能。

关键是,模拟接口和实际实现之间根本不应该存在任何联系。它只是没有任何意义,因为你在嘲笑合同并给它一个你自己的实现。

于 2009-05-08T14:03:22.357 回答
-2

我认为您的问题违反了 Liskov 替换原则:

子类型必须可以替代它们的基本类型

理想情况下,您将拥有一个依赖于抽象的类。一个抽象说“为了能够工作,我需要这个方法的实现,它接受这个参数,返回这个结果,如果我做错了,就会抛出这个异常”。这些都将在您所依赖的接口上定义,无论是通过编译时间限制还是通过注释。

从技术上讲,您似乎依赖于抽象,但在您所说的场景中,您并不真正依赖抽象,您实际上依赖于实现。你说“如果这个方法改变了它的行为,它的用户就会崩溃,我的测试永远不会知道”。在单元测试级别上,您是对的。但在合约层面,以这种方式改变行为是错误的。因为通过更改方法,您显然违反了方法与其调用者之间的约定。

为什么要改变方法?很明显,该方法的调用者现在需要不同的行为。因此,您要做的第一件事不是更改方法本身,而是更改您的客户所依赖的抽象或合同。他们必须先改变并开始使用新合同:“好的,我的需求改变了,我不再希望这个方法在这个特定的场景中返回那个,这个接口的实现者必须返回这个”。所以,你去改变你的界面,你去根据需要改变界面的用户,这包括更新他们的测试,你做的最后一件事就是改变你传递给客户的实际实现。这样,您就不会遇到您所说的错误。

所以,

class NeedsWork(IWorker b) { DoSth() { b.Work() }; }
...
AppBuilder() { INeedWork GetA() { return new NeedsWork(new Worker()); } }
  1. 修改 IWorker,使其反映 NeedsWork 的新需求。
  2. 修改 DoSth 使其与满足其新需求的新抽象一起工作。
  3. 测试 NeedsWork 并确保它适用于新行为。
  4. 更改您为 IWorker 提供的所有实现(在此场景中为 Worker)(您现在首先尝试这样做)。
  5. 测试工作者,使其满足新的期望。

看起来很可怕,但在现实生活中,这对于微小的变化来说是微不足道的,而对于巨大的变化来说是痛苦的,事实上,它必须如此。

于 2009-05-08T21:51:10.487 回答