单元测试的纯度和脆弱性风险

本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

转载自夜明的孤行灯

本文链接地址: https://www.huangyunkun.com/2018/01/06/unit-test-with-mock/



客户公司新来了一个CTO,新官上任三把火,他首先盯上了单元测试,他要求只为单个类编写单元测试。所有依赖项都被模拟,简单而言就是单元测试不跨类。

然后客户展示了一段代码,表示按照这个规范很难操作,因为现在有需求要业务重构B类。

class A(val b: B) {

   fun doSomething() {
       b.doSomethingElse()
       b.doSomethingElse2()
       b.doSomethingElse3()
   }
}

我们来简单分析下这个代码和对应的规范:

按照规范A类和B类都有自己的单元测试,然后根据新的业务需求,类B进行重构并隐藏在接口后面,因此从技术上讲,类A可能会根据场景获得不同的行为。

现在的问题是,当我们想要遵循项目的指导方针时,我们还应该重构A的单元测试。之前有一个测试,测试对象是B(当然是被mock的)。现在,当对B的引用消失时,测试将被重构,以验证与这个新接口的调用。

这种情况是在测试重构过程中发生的信息丢失。当在实际系统中存在此类调用时,由于单元测试的纯度,我们不再验证A->B之间的任何通信。

我认为这种情况是对于单元测试纯度的过度要求,进而增加了单元测试的脆弱性。

单元测试的纯度

周围打听下这种纯度要求其实很常见,特别见于新开始建设单元测试的团队,项目领袖对于单元测试对于质量保障有较高的期望,进而导致对于纯度有近乎象牙塔的规范要求,从而导致建设失败。

这种纯度的建设方式可以用于项目初期开荒,一锤子买卖,快速搞定上级下发的KPI,但是对于项目质量保障毫无价值。

对于纯度的争议并不少见,我理解其实更多是对于Unit这个外来词的定义问题,机械的将编程语言的类和方法作为一个Unit就是一个高纯度的要求。

还有一种原因我认为是对于单元测试本质目标的忽略,进而陷入定义的混乱,然而这不是重要的事情。

不可否认关于Unit的确切含义存在一些分歧。但是,单元测试测试的目的并不是为了符合某些任意的定义。目的是检测错误。或者更一般地说:验证代码的行为是否符合预期。

重构时,单元测试应该确保变更没有引入错误或更改行为。因此,测试不应该与测试代码中的实现细节耦合。事实上,应该可以完全重写类的实现,并让测试验证行为是否仍然相同。

因此,从这个角度来看,测试并不关心被测试类是否调用了其他类。这是一个实现细节。事实上,典型的重构是将方法中的一些内部代码提取到一个单独的类中,而这正是测试应该能够验证重构没有改变行为的场景。

还有一个更简单的例子,一个验证Email地址的Utils类,它是一个static方法,签名如下

public static bool isValid(string email)

高纯度的规范要求mock这个方法,你只需要和任何一个最近三个月写过单元测试的研发工程师聊一聊,他就能告诉这个规范是多么愚蠢。

因此,底线是:避免mock(除了外部服务或非确定性输入的情况),并让测试联动尽可能多的类来验证行为。

就我个人而言,我喜欢将“单元”视为“行为单元”——规范中可以独立测试的最小部分。但这只是我的定义。

Mock对脆弱性的影响

其实公正的讲,这个例子本身是特殊的,因为这个例子正好证明了一个中间问题。让我们来回想一下常见的改动情况。

当你做出向后不兼容的改变时,依赖于你所改变的东西就会崩溃。所以,如果你的测试现在报错了,那就太好了——一切都在按预期进行。在这一点上,你有几个选择:

一个是重新思考你对设计所做的更改,使它们向后兼容。这通常意味着创建新的类和接口,并根据新元素实现旧的用例。实际上,在适当的情况下,旧代码会被重构以利用新的做事方式,测试会验证你在做这件事时没有引入任何新的错误。新的单元测试涵盖了新的行为。

另一种选择是,你决定这种突破性的改变是必要的,而现在正是为此付出代价的合适时机。所以只需删除问题测试。

更理想的情况是你的改动是一个完全兼容的微小改动,那么一切如常,已有的测试会为你保驾护航。

而中间问题是什么?是你正在进行一个“小”不兼容的更改,并且测试的大部分价值仍然存在,但要继续积累这些价值需要不成比例的工作量。

对于这种问题的解法还是在mock上,什么时候使用mock技术和使用什么具体技术。

mock是一个极为宽泛的术语,它并不是某种特定技术,更不是某个框架的某个方法。

最简单的答案是尽量不mock,或者使用一个真实的实现去mock,这就意味着当你需要mock B类的时候,你可以评估下,如果使用一个真实的B类作为mock有什么问题吗?如果没有,那就使用它,直到你走到终点,一个无可避免的外部依赖。

然后在最后一步,你依然有多种选择,你可以使用一个虚假的实现来替代,这虚假实现可以是你自己实现的,它模拟了调用对象的基本逻辑(至少是对你的契约中承诺的基本逻辑)。这种手写存根代码。一些第三方基础设施代码必须用手写的存根代码来模仿。它无法自动生成,并且需要额外的时间来编写。然而,结果是高度可重用的。

在某些情况下这种“虚假”实现是广泛复用的,比如H2数据库,它广泛用于业界,是一个MySQL等关系型数据库的优良替代。

在最后无计可施的情况下,我们才使用Mockito等框架,我们可以选择spy或者mock。遗憾的是对于大部分规范来说,mock就等于Mockito.mock。

只要跳出这个误区,你的单元测试将具有较高的可靠性和可维护性。

最后

不要追求某种单一的规范,结合项目实际,编写易于测试、易于重构的有效测试。

唯一的底线,应该是单元测试本身的特性:可重复性与独立性。对于速度的要求取决你的需求,10ms的单元测试和1s的单元测试能多大的影响你的工作?

参考阅读

https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks



本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

转载自夜明的孤行灯

本文链接地址: https://www.huangyunkun.com/2018/01/06/unit-test-with-mock/

发表评论