少年,单元测试的两个流派了解下

现在的开发规范中多多少少会要求写单元测试。对单元测试的态度大家也分化的比较严重,有些人觉得单元测试没有用,根本就是浪费时间。有些人就特别的舔单元测试,甚至坚持TDD, 比如 pshu 本人:stuck_out_tongue_closed_eyes:。

作为一个更加疯狂的 TDD 舔狗,pshu想对那些说单元测试没啥用的同学喊几句话。觉得单元测试没有用的同学,是你没有用正确的姿势来写测试。而姿势不正确是因为你不了解单元测试。今天 pshu 将从单元测试“流派”的角度来让大家来了更加深入的了解单元测试,从而帮助大家写出更好的单元测试。

状态流

什么是状态流呢? 通过检查被测对象所影响的 状态 的方式来验证 正确性 的测试方式,就是状态流。可能大家第一次听说状态流,但是大部分的单元测试都在做这个流派的实践。主要原因就是大家一开始接触的就是这个流派,目前大部分的单元测试的教程所演示的第一个例子都是类似下面的代码。

assert(2, sum(1,1))

这就是一个典型的状态流的做法,通过检查 sum 函数返回值这个被测对象的状态是否为 ’2’ 来决定测试是否成功。这样的测试方式很直接,也很好理解。包括介绍 TDD 的经典例子保龄球算分的 demo,也是采用了状态流的做法。在状态流流派中最有名的一位选手就是 Robert C. Martin ,人称 Uncle Bob 。这位大叔(真的是大叔,已经 67 岁高龄了),是 敏捷宣言 的作者之一,当然 Uncle Bob 最有名还是他写的 《 Clean code 》 (整洁代码)和《 Agile Software Development: Principles, Patterns, and Practices 》(敏捷软件开发:原则、模式与实践 这两本书了。Uncle Bob 对状态流真是情有独钟。当然他对状态流的用法也非常的独到。比如我们要比较两个圆的是否一致的情况。

2B 青年会直接用 assertTrue(cirlce1.equals(circle2)) 从测试的角度看没有任何问题,但是当测试失败的时候。这样的状态流测试用例对查问题一点帮助都没有,只是冰冷的告诉你测试挂了。

一般青年会分别写三个断言,分别断言两个圆的圆心x,y和半径 r 是否都相等。虽然这样断言也没有任何问题,但是当其中某一个值不对的话,你看到的测试失败信息只有 期望 3,不等于4 的错误信息,到底是x 不对还是 y 不对,你还要再查看一次测试用例的代码才知道;而且每次只会暴露一个断言错误。(第一个断言失败,后面的就不执行了)

那Uncle Bob 会怎么写呢?会把Circle “序列化”成一个 string 表达,例如 ”c=3,3 r=5”,表示圆心坐标为3,3,半径为5 。那断言就会写法对俩个对象的序列化的结果的比较,如果测试用例有错误,就有类似下面的信息一样,错误在哪里自然就一目了然了。

expect c=3,3 r=5
actual c=4,3 r=9
         ^     ^

如果在用状态流在写单元测试总感觉单元测试给你带来的帮助不大,可以参考下 Uncle Bob 的做法;或者试试接下来介绍的行为流。

行为流

行为流顾名思义就是通过验证被测对象的“ 行为 ”作为正确性的标准。又因为在验证行为时大多要借助测试工具Mock,所以又被称作 Mock 流。那怎么验证软件的行为呢?就是看被 测对象用怎么样的顺序,用什么样的参数 调用了哪些依赖 。这样的介绍太过抽象了,pshu 举个例子。假设我们的计算机只有整型加法运算器,我们要设计一个基于加法的整形乘法器。一个简单的 JavaScript ES6 的实现

// 这是我们的加法器
class Calculator {
  add(...ns) {
    return ns.reduce((s, i) => s + i, 0)
  }
}
// 基于加法的朴素乘法器
class Multiplier {
  constructor(calculator) {
    this.cal = calculator
  }
  mult(a, b) {
    const isAPos = a > 0
    const isBPos = b > 0
    const posA = isAPos ? a : -a
    const posB = isBPos ? b : -b
    const isNegative = isAPos !== isBPos
    let res = 0
    for (let i = 1; i <= posA; i++) {
      res = this.cal.add(res, posB)
    }
    return isNegative ? -res : res
  }
}
// 对应的状态流的测试也很直接 3x4=12
suite('乘法器 1', () => {
  test('3x4', () => {
    const multiplier = new Multiplier(new Calculator())
    expect(multiplier.mul(3, 4))
      .to.equal(12)
  })
})

虽然这个乘法器实现简陋,但是基本满足功能。但是有一个问题,仅仅一个 3x4 的测试用例,够了吗?理论上我们要把所有的整数组合都跑一遍才算是对这个乘法器完整验证。所以有些情况下状态流,功能的验证有一定的局限性。那对这个朴素乘法器我们真正想测试,或者真正想保证的是什么呢?

其实我们想要保证的是这个乘法器用加法完成乘法的 做法 是正确的,或者说它的行为是正确的。这里我们期望的行为是,这个乘法器依次按照 0+4 , 4+48+4 的顺序调用了加法器。(至于 0+4 是不是等于 4,那是加法器对应的单元测试需要去保证的事情 )这就是我们想保证或者说验证的被测对象的行为,因此就需要我们用行为流的方式来写单元测试。为了演示简单pshu直接使用第三方的测试工具 Sinonjs。状态流的测试就会写成这个样子

test('3x4', () => {
  const cal = {add:()=>{throw Error('Never Called')}}
  mocker = sinon.mock(cal)
  multiplier = new Mul(cal)
  
  mocker
    .expects('add')
    .withArgs(0, 4)
    .returns(4)
  mocker
    .expects('add')
    .withArgs(4, 4)
    .returns(8)
  mocker
    .expects('add')
    .withArgs(8, 4)
    .returns(9999)
    
  expect(multiplier.mul(3, 4)).to.equal(9999)
    
  mocker.verify()
})

我们先在 Multiplier 这个对象注入加法器的 mock,然后设定好本次测试期望的软件行为,具体的做法就是用 mocker.expects 这个 API来定义;接着调用下乘法;最后用 mock的 verify 方法验证行为是否符合有预期。眼睛尖的同学可能看到了代码里面的 9999 , 是不是特别的不可思议;其实这就是 Mock 流的精髓,Mock 就是一个你可以任意控制的依赖,而你就是要通过这个可控的依赖来观察和验证被测代码。

行为流这样的做法就是从被测对象依赖的角度来观察被测对象的行为,相比于状态就显得不那么直接和易于理解。所以实践被采用的也比较少。但这个流派也有一批追随者,他们称自己为 GOOS,Growing Object-Oriented Software 。这是因为一本力挺行为流的书,名字就叫《Growing Object-Oriented Software》。这本书就是教读者如何在 mock的帮助下,通过 TDD 的方式渐进式的完成软件开发。说到这里那不得不提一提这本书的作者之一:Steve Freeman。为什么要提下他呢?因为pshu和他合过影。吹完牛,说会正题。现在介绍完两个流派,接下来就是说适用的场景了。

对比

接上面的乘法器的 3x4 例子中把乘法转换成加法的算法显然效率不高,聪明的同学早就想到通过位运算可以提高效率。(原来3次加法,最优的方法可以优化成1次)。那优化完之后,我们重新执行测试状态流和行为流的测试用例。状态流的测试用例仍然是通过的,状态流的失败了。通过和失败不是重点,重点是他们都完成了他们的目的。状态流保护的是被测对象的外部的观察到的状态。在我们的例子里面:用了不同的算法,乘法的功能是一样的。所以状态流的用例是通过的。那行为流能测试用例失败的也合情合理,因为它所确定的行为确实改变了(加法的次数变了,参数也变了)。这个改动破坏了你认为重要的东西,那自然要提醒你了,这才是一个成熟的测试啊。在实际的开发中,很多情况就是需要你保护好自己代码的行为,尤其是依赖第三方情况,调用第三方的顺序是 A->B->C, 如果错了就是 bug。小小的总结下就,如果你的写的代码更加重视的自己对外输出的功能的时候,状态流就更加的适合你;如果你的代码更加像是一个协调者,依赖很多第三方库,那行为流就是好选择。当然状态流和行为流在实践中并不是二选一的选择,完全可以结合起来一起用.

后提下大家可能会误解认为行为流一定要用 Mock 库。 行为的记录和对比完全可以自己实现。 讲状态流提到的 Uncle Bo b,就不太喜欢使用第三方 Mock,他一般会自己针对被测对象简单的实现一个Mock。 而且他还指出: 如果为你的代码设计实现 Mock 的时候非常的困难,那一定是你的代码实现有问题。 同理于如果你觉得为你的代码写单元测试很困难,不是单元测试难写,而是你代码设计的可测性太差了。 如果大家觉得这样的说法很有问题的话,不妨看看 Uncle Bob 用TDD 实现 Dijkstra  算法(https://blog.cleancoder.com/uncle-bob/2016/10/26/DijkstrasAlg.html) 和地形生产算法(https://blog.cleancoder.com/uncle-bob/2017/01/09/DiamondSquare.html) 的例子。 这两个例子都非常的有趣和开眼界,  非常推荐。

如果你看到这里,你一定是个好学同学,那我觉得你非常适合加入 pshu 现在所在 的团队:蚂蚁金服支付宝前端会员组(办公地点杭州、北京)。没听过这个团队,那一年一度的支付宝年度账单一定知道吧。对,它就是出自我们支付宝前端会员组。 在这里 除了可以打造国民级的应用,你还能体验到 Node.js 进行企业级应用的全栈开发。什么千万级日 PV 都是日常。大前端我们也不只是叫叫,H5,小程序,BFF...在这里都得到了落地,此外团队还在智能化体验技术方向积极地探索。没想到吧,搞前端还能扯上 AI,惊不惊喜,如果你前端技术方面开了很多大脑洞, 不妨带过来和我们一起聊,说不定我们一拍即合,动手把它落地了。当然最最重要的是,加入了以后可以天天和pshu 一起吹牛聊天,诱惑吧!想加入吧,有什么要求?pshu 帮大家总结下就是下面两个条件。注意,两个条件是或的关系。

  • 精通 Node.js 开发

  • 精通前端开发(React或者Vue)

想加入的同学,赶紧到公众号"撩 pshu" 按钮点下,大喊一句“我要进支付宝”, 然后二话不说直接简历砸过来,就可以了。悄悄的告诉你目前人头充足,但还是抓紧点!

好了,就写这么多了,如果喜欢本文的记得点赞转发。如果你有朋友想加入支付宝,记得也把文章转发给他。

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章