单元测试多层异常——何时停止?

4
假设我有3个类,每个类都有一个责任和方法。同时假设这些类有接口来促进依赖注入。A类调用B类的接口,而B类调用C类的接口。如果C类抛出NotAPrimeNumberException(例如,int参数不是质数),那么我希望有一个单元测试来确保如果传入参数不是质数,则C会抛出NotAPrimeNumberException。目前为止还好。
我认为单元测试提供了我需要了解被测试方法行为的所有文档。因此,上述单元测试将是像MakeSureNotAPrimeNumberExceptionIsThrownIfNumberisNotPrimeTest()这样的东西。
B类知道C类可能会抛出NotAPrimeNumberException。如果我想让NotAPrimeNumberException从B类中冒泡出来,我是否应该编写一个单元测试来检查在某些情况下是否会抛出NotAPrimeNumberException?那A类呢?如果A也让NotAPrimeNumberException冒泡出来,它是否也应该有一个单元测试?
我的担忧是,如果我在B类中不编写单元测试,那么B类的消费者将不知道B类可能会抛出这种类型的异常。但是,如果我编写了单元测试,那么强制调用B类以抛出NotAPrimeNumberException,却不处理B类中的异常,这有点儿愚蠢。如果我不编写单元测试,是否有适当的方式在API中记录此异常可能发生的情况?
额外的问题是如何在NUnit和Rhino-mocks中实现这一点?这当然取决于前两个问题。

1
当预期的行为发生时,测试应该通过。即使该行为是抛出异常。 - Stefan H
4个回答

2
如果A和B类期望与其协作的类表现出特定的行为,那么你应该进行测试以确保这些行为发生。如果你想避免从A到B到C的硬编码调用而导致的混乱,那么你应该应用好莱坞原则/依赖倒置原则来打破这种依赖关系。A应该依赖于B的接口IB而不是B的实例。然后,对A的测试可以简单地断言A是否按照预期工作,包括在面对IB抛出的异常时正常工作(或忽略它们)。同样,B可以依赖于C的接口IC,并且B的测试也可以忽略C可能做什么或不做什么的细节,而只关注B的功能。
将A连接到B再连接到C的集成测试可以验证类之间的正确协作,并且还是确保如果C引发异常,则A会上报此异常的地方。如果后来发现C只是返回null而不是引发异常,则您的集成测试将失败,并且您会看到新行为已经发生。但是,我不太倾向于在我的各个单元测试中添加测试,以显示如果出现故障,则异常将会上报,因为这是默认行为。相反,我可能会测试我提出的任何新异常是否在预期时被引发,以及我执行的任何异常处理是否按预期工作。这是因为在单元测试中,您应该只测试正在测试的代码-而不是协作者的行为。

抱歉,我刚刚更新了注释以使其更加清晰。我只是假设有接口和依赖注入用于模拟目的,但在问题中没有明确说明。我的问题的真正重点是关于编写一个单元测试方法,因为一个依赖项抛出异常,以及这是否有意义来确保我的单元测试记录了方法的所有行为。 - jakejgordon
你在评论中回答了自己的问题。你的单元测试应该记录“我的方法的所有行为”,而不是你的方法可能调用的方法的行为。只要你这样做了,就没问题了。如果重要的是你的方法如何处理异常冒泡到它,那么很容易(使用模拟或伪造)设置一个测试,在该测试中抛出异常到你的方法,并验证它是否正确处理。如果这是重要的行为,作为单元测试对我来说似乎是有效的。但是模拟交互,不要依赖于特定的其他方法实现。 - ssmith
如果B针对给定参数抛出NotAPrimeNumberException异常,则我绝对会在我的测试套件中为此编写一个测试。 我不会尝试通过B的使用者(例如A,假设A调用B)来测试B的行为。 在A中,我可能会测试如果B抛出异常时A会做什么-这是测试A的行为。 我不会在A中测试B在某些情况下是否确实抛出异常,因为这现在是测试B的行为,而不是A的行为(我们已经有关于B的测试)。 - ssmith
补充上一条评论 - 如果B是第三方或框架库,那么您可能会编写一些“修复”测试来记录您对其工作方式的假设,然后如果/当您升级或替换B时,这些测试将通知您是否更改了其行为的假设。但关键点是,您的单元测试应该测试您的代码,而不是您的代码所依赖的代码。 - ssmith
很好。我的理解是,B的行为是不处理已知会发生的C异常。在这种情况下,我将通过模拟测试确保当C抛出异常时,B也会抛出异常(当然不使用真实的实现)。这样,对于B存在异常的条件,我就有了清晰的文档说明。如果我们添加了一些异常处理并且没有相应地更新单元测试,那么唯一有意义的单元测试失败方式就是这种情况。我将继续与我的团队内部展开讨论,但感谢您的帮助! - jakejgordon
显示剩余2条评论

2

以下是一些想法:

  1. Class C旨在抛出NotAPrimeNumberException。因此,您应该编写一个测试来验证预期的异常是否被抛出。
  2. 在这种情况下,是否要测试Class B或A取决于它们是否需要处理异常。如果Class B在Class C抛出异常时需要执行某些操作,则应该测试该行为。如果Class B只是“通过”该异常,则不应该测试它。否则,您还需要测试NullReferenceException、ArithmeticOverflowException、HttpException和所有其他异常类型的行为。这是无止尽的单元测试,并没有什么帮助。
  3. 关于Class C行为改变的问题,您需要考虑几件事情。任何依赖于更改后的行为的测试也应进行更改。(由于您正在进行TDD,您在更改行为之前就已更改了测试,对吧?)此外,模拟Class C抛出异常的行为的任何Mock对象也需要更改。如果Class C永远不会抛出该异常,那么测试Class C抛出异常的情况是没有用的。这最后一个考虑因素有点棘手,我不知道如何以正式的方式管理它。我对进一步的想法感兴趣。

抱歉,我刚刚更新了一下注释,让它更加清晰明了。我只是假设有接口和依赖注入来进行模拟,但在问题中没有说明。我的问题的真正重点是关于编写一个单元测试方法,因为一个异常引发了一个依赖项,是否有意义来确保我的单元测试记录了方法的所有行为。我删除了你在第3个问题中回答的问题,因为那个问题不太好。 - jakejgordon

1

你应该使用模拟测试 B 和 C 的方法调用。这是单元测试的重点 - 你只需测试代码的一个单元,而不是它的依赖。

不考虑这一点,如果你不想在这种情况下使用模拟,我认为在其中一个依赖项抛出异常时,不应有特殊条件允许通过。它应该失败。第一个原因:你实际上测试的确切代码并没有真正起作用(你在测试整个代码与其依赖关系)。第二个原因:这样你的测试变得混乱。

记得保持简单。


对不起,我刚刚更新了一下评论,让它更清晰一些。我只是假设有接口和依赖注入来进行模拟,但是在问题中没有这样说。我的问题的真正重点是关于编写一个单元测试方法,因为异常会抛出一个依赖项,是否有意义来确保我的单元测试记录了该方法的所有行为。 - jakejgordon

0
如果类A和B不需要关心是否存在异常,那么你就不应该对该功能进行单元测试。如果它们在异常情况下有一些特定的行为(例如将其包装在不同的异常中或捕获并用哔哔声大声抱怨),并且您需要验证该行为,则应该进行单元测试。
在前一种情况下,我会决定不对A和B进行单元测试,以避免使我的测试变得脆弱。如果验证A和B在面对某个特定异常时的行为并不重要,那么您不希望将这些测试与不同模块的行为绑定在一起。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接