编写更好的测试
编写更好的测试
总结
延伸阅读
编写更好的测试
在本系列的最后一章中,我们将讨论一些编写更好测试的技巧。
这三篇文章源于我的一次演讲。演讲最后部分涵盖了多种反模式和测试方面的问题,这些问题的复杂程度和重要性各不相同。一段时间后,我意识到最好只关注其中几个问题,因为它们恰恰是我遇到的最棘手问题的根源。
测试实施过程很痛苦
标题:
测试行为,而非实现
这是测试领域最广为人知的格言之一,但不知为何,在实践中却常常被遗忘。测试实施虽然短期内能带来完成工作的成就感和信心,但从中长期来看却会带来痛苦。
这样做的原因是它使重构变得困难,而重构是开发人员最常见的活动。
为了说明测试实现有多么棘手,我们来看一个例子。我们需要实现一个算法,用于计算结账流程的总价。最终我们得到了以下测试:
public function testCheckoutTotalCalculation(): void
{
$itemsInBasket = [
$this->oneItemWorth500,
$this->anotherItemWorth500
];
$repository = $this->createMock(CheckoutItemsRespository::class);
$repository->expects($this->once())
->method('fetch')
->with(self::BASKET_ID)
->willReturn($itemsInBasket);
$checkoutTotalCalculator = new Calculator($repository);
$basket = new Basket(self::BASKET_ID);
$calculatedTotal = $checkoutTotalCalculator->calculateTotal($basket);
$expected = 1000;
self::assertEquals($expected, $calculatedTotal);
}
看起来还不错,测试通过了,所以我们继续下一个任务,一切顺利。然而,这个测试很脆弱,因为它测试的是实现细节,在不久的将来会给我们带来麻烦。
在实现一些不相关的功能时,例如购物车中的商品列表(它CheckoutItemsRepository也使用了该功能),我们决定改进存储库,使其类似于这样:
public function fetch(int $basketId): ItemCollection { ... }
我们用集合代替了普通数组,是为了利用集合的映射功能。但是,我们之前的测试现在失败了。
总数的计算方法有改变吗?完全没有。同样的商品,总数应该也一样。
测试应该失败吗?不应该,因为计算方法没有改变。
测试失败了吗?当然失败了。
为什么?因为我们测试的是实现方式,而不是行为方式。
推断对于相当大的代码库的常见情况,重构后可能会出现很多红色测试,而根据定义,这些重构应该是透明的。
测试实现本身就是一种耦合——我们应该尽可能避免这种情况。我们之前的测试与所有参与价格计算的类都高度耦合。因此,对这些类的任何更改都会导致不相关的测试失败。
每次修改都要修复大量不相关的测试,这会导致人们对测试和测试过程产生怀疑。更糟糕的是,如果修复只是修改模拟对象的预期以适应新的方法签名或返回类型,那么下次修改同一段代码时,你还会遇到同样的问题。
幸运的是,有一些工具可以防止我们陷入测试实现的陷阱。
先测试,后实施
标题
先进行测试有助于生成更好的测试和实现方案。
先实现代码再编写测试,会让你不知不觉地将测试与刚刚编写的代码耦合在一起。你的大脑会记住你刚才输入的内容,并诱使你模拟这两个依赖项,从而返回你编写代码时所设想的确切结果,以便快速通过测试,然后继续处理下一个任务。
我们刚刚看到了这类测试有多么不理想。在修复了一堆因代码变更(与测试本身无关)而失效的测试之后,我们开始怀疑整个测试过程到底有意义吗?
根据我的经验,在编写生产代码之前先编写测试是避免那些与实现相关的、令人厌烦且脆弱的测试的最佳实践。通过先测试后实现,你会被迫在实现之前思考产品行为。你也会专注于解决当前的问题,而不是在不同的实现细节之间来回切换,最终迷失方向。
在前面的例子中,实现时的思路可能是这样的:“首先,我需要这些商品,我可以根据购物车 ID 从这个仓库中获取它们……我们还没有这种查找器,让我们来实现它(……);好的,现在我获取商品,然后……等等,但是如果仓库中没有商品,它应该抛出一个异常……这样做是不允许的(……);好的,仓库抛出了异常,遍历商品并调用这个 getter,我可以把总数累加到这个变量中……等等,我可以用 mapper,是的,这样更简洁。现在,让我们编写测试……在我删除这个变量之后,它就不再使用了”。
在进行测试时,首先更容易关注行为:“我有两个物品,每个价值 500,总价值应该是 1000”。
最终的实现方式可能非常相似,但测试部分会更加解耦,也更易于重构。它看起来会像这样:
public function testCheckoutTotalCalculation(): void
{
$this->repository->add($this->oneItemWorth500)
$this->repository->add($this->otherItemWorth500)
$checkoutTotalCalculator = new Calculator($this->repository);
$basket = new Basket(self::BASKET_ID);
$calculatedTotal = $checkoutTotalCalculator->calculateTotal($basket);
$expected = 1000;
self::assertEquals($expected, $calculatedTotal);
}
谈到测试,首先就不可避免地要谈到测试驱动开发(TDD)。关于这一点,我有两点看法。
首先,请记住,TDD 并非唯一的测试先行方法。即使不遵循红-绿-重构循环,你也可以先进行测试。
其次,最近反对TDD的人越来越多。我认为TDD是一个非常棒的开发工具,并且我坚信反对TDD的主要论点源于本文中提到的反模式,以及将教学示例当作绝对规则的做法。
网上有很多关于测试驱动开发(TDD)的文献,它们用一些简单的例子来解释TDD,但这些例子与你实际编写软件时遇到的情况相去甚远。如果你照搬这些例子,而不去分析它们对你当前代码的优劣,你很容易就会陷入糟糕的测试陷阱。
现在我们来看两个与测试实现密切相关的反模式。
避免模拟末日
标题:
无需模拟所有依赖项
互联网上关于测试的文章存在一个缺陷,那就是过度强调使用模拟对象(mock)。很多文章鼓励模拟所有依赖项,以隔离被测单元。我认为这里存在两个问题。首先,这种“什么都模拟”的做法本身就存在问题。其次,它隐含地将被测单元定义为单个类(因此导致了过度模拟)。由于后者是接下来要讨论的主题,我们现在先来看看模拟对象本身。
测试替身是单元测试和集成测试的关键组成部分。你应该使用测试替身来缩小代码执行范围,使其仅执行被测单元所需的代码。让我们用例子来说明这一点:
- 在对用户注册用例进行单元测试时,您需要模拟数据库访问,因为这不是您要测试的行为。
- 在对用户注册用例的持久性进行集成测试时,除了数据库之外,您还需要模拟对外部系统的任何访问(例如,通过 Facebook 集成来获取用户电子邮件)。
- 在用户注册用例中对 Facebook 通信进行集成测试时,您可以模拟数据库访问*。
*在这种情况下,不模拟持久化访问可能是有道理的。
然而,很多文献鼓励的做法是模拟被测类的所有关联类。这正是导致测试套件脆弱不堪的另一个根源,最终会在重构期间造成诸多麻烦。
考虑以下类:
class InvoiceProcessor
{
private RateRepository $rateRepository;
private InvoiceRepository $invoiceRepository;
private MoneyConversor $conversor;
private MoneyFactory $moneyFactory;
public function process(InvoiceCollection $invoices): void {}
}
除了为了举例而做出的一些可疑的设计决策之外,在对发票处理进行单元测试时,我们应该模仿什么呢?
假设存储库正在访问数据库以获取当前汇率和发票信息,我们应该对其进行模拟。交互行为是否符合预期应该在其他地方进行测试。这里我们关注的是发票处理的行为,并不关心任何持久化机制如何检索数据。这样,我们的单元测试就能保持隔离性,并且执行速度快、成本低。
现在,假设它MoneyConversor只是接受一些数值变化率并将其应用于货币数量进行转换,我们应该嘲笑它吗?
如果MoneyFactory函数只需要几个标量值(数量和货币)来构建Money对象呢?还有,InvoiceCollection作为参数传递的函数又该如何处理?
如果他们没有超出我们正在测试的范围(显然他们没有),我们就应该避免嘲笑他们。
为什么?如果我们全部模拟,就会测试实现细节,从而不必要地将待测单元与实现其预期行为的具体类耦合在一起。在全部模拟的情况下,任何重构都会导致测试失败,因为实现细节发生了变化。这是不可取的。
编写测试时,我们必须将被测单元视为一个黑盒。我们知道特定输入对应的预期结果。这种输入到输出的转换就是我们预期的行为。至于代码如何实现这种行为,我们在编写测试时无需关心。它位于黑盒内部,我们无法直接观察。只有当黑盒的某个部分与我们无法控制或不便控制的事物(例如数据库、消息代理、时间等)进行通信时,我们才需要了解其内部机制。这些部分正是我们需要模拟的对象,仅此而已。
这就引出了一个问题:如何界定黑箱的边界?以及,应该测试哪个单元才合适?
谨慎选择测试单元
标题:
不要为所有类编写测试,而要为所有单元编写测试。
但是,什么是单位呢?答案是……等等……这要视情况而定。
然而,对于什么不是合适的测试单元,至少有一个明确的答案:那就是每个类。始终坚持类与实现一一对应,是另一种将测试与实现耦合的方式。有时,我们会编写与特定类完全匹配的测试,但这并非出于什么铁律,而是因为这样做合乎情理。
网络文献在一定程度上鼓励这种 1:1 的规则,因为在一个简单的例子中,如果没有具体上下文,这确实是一种有效的方法。但在更复杂的应用中,我们会遇到很多不适用的情况。
设想这样一个场景:一个类有一个大型公共方法,该方法经过了全面且充分的测试:测试涵盖了该方法的行为,而不是其实现。这在使用“测试优先”方法时并不少见(你懂的)。
该方法过于庞大,我们将其部分代码重构到其他类中。这是一个很好的实践。现在,我们是否应该对这些新的、更小的类进行单元测试?它们不是已经在针对最初那个大类的首次测试中被测试过了吗?
我们有两个选择:要么重复之前已经缩小范围到较小类的测试,要么在初始类测试中模拟这些较小类。这两个选择都会使测试变得脆弱,使其与实现耦合,并使其难以进一步重构。
另一种可能的情况是,我们的InvoiceProcessor类使用了Amount值对象。这个Amount类在应用程序的其他部分也会用到,可能涵盖了一些在其他地方不需要的逻辑InvoiceProcessor(例如,处理发票时我们需要金额加法,但不需要减法)。在这种情况下,为这个单独的类编写测试就很有意义了。
在每种情况下确定什么是单元可能很棘手。清晰的架构对此大有帮助。
最近,我意识到在用例级别建立待测单元来测试领域逻辑是多么方便。这个具体的应用程序遵循分层架构,每个用例在应用层都有一个处理程序。这些处理程序是领域逻辑的客户端,它们调用相应的领域方法来完成用例。我已在这些处理程序中设置了大部分待测单元,因此领域逻辑是通过它们进行测试的,而无需针对具体的领域实体进行测试。这样,只要处理程序的测试通过,我就可以放心地对领域逻辑进行大规模重构,确保应用程序的行为符合预期。
然而,前面的例子可能并不适用于其他情况。请根据具体情况的需要进行分析和选择。
总结
最好的总结就是标题:
- 测试行为,而不是实现。
- 先进行测试有助于生成更好的测试和实现方案。
- 无需模拟所有依赖项。
- 不要为所有类编写测试,而要为所有单元编写测试。
撰写这个系列文章真的很有趣。希望你们也和我一样喜欢。欢迎在评论区留下任何反馈!
延伸阅读
- 再次强调, Steve Freeman 和 Nat Pryce 合著的《通过测试指导的面向对象软件开发》是一本极好的入门和改进测试的参考书。
- 如果你对TDD仍有疑虑,这里有Fran Iglesias对TDD如何有益于心理健康的一个很好的解释。



