一个初级码农的测试之旅(一)——初识单元测试

前言

首先说一下我自己——一个码农,准确的讲我是一名在中国最大互联网公司搬砖的初级码农。我不是计算机科班出身,一年前进入公司的时候,从未接触过web开发,没有完整的学习过数据库知识,写不出一条完整的sql语句,甚至不知道js和css到底是怎么控制页面行为和样式的——这样的人为什么可以通过面试?反正不是因为我长得帅。

背景知识

文章最初,先介绍一下我们团队的产品——阿里云持续交付平台(crp.aliyun.com),是一个旨在服务阿里云上众多开发者的持续交付平台(你可能还没听说过,但不妨一试哦),产品以nodejs实现,采用express框架。

接触单元测试

经历了入职最初的摸索之后,我逐渐掌握了上面提到的各种不会,开始可以用相对“丑陋”的代码来实现产品的需求,表面上看起来实现得也还不错。
为什么是表面上?因为在两次较为重要的发布之前,我们总是修复bug到凌晨之后,调试的办法当然是大家最熟悉的console.log…用过nodejs的人都知道,天生的异步会让console.log变得异常艰辛。
为什么会在上线前的人肉测试时才出bug?因为我们当时的代码是裸奔的,所谓的“赶进度”让我们时常提起单元测试的重要性却每每都在完成需求之后就去领取新的story了。
让我这个初级码农最终走上单元测试这条不归路的原因,除了上述的痛苦经历,还有一个更直接的原因——团队来了一位真正懂并且会写单元测试的哥。可以说,本文就是在他逼迫之下完成了一些个单测之后又在他“逼迫”下最终成文的。

什么是单元测试

几乎所有程序员都会说出单测的重要,但单测到底是什么,为什么重要,又应该怎么去写呢?
对于单元测试的定义,我们可以参考相对严谨的*中的词条(https://en.wikipedia.org/wiki/Unit_testing) ,简单翻译就是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作,而一般的分歧则在于对最小单位划分的定义,对于这个分歧我无意深究,因为根据项目实际情况来划分会显得更加合理。

为什么选择单元测试

按照上述的定义,单元测试只能涵盖最小单位本身,并不能确保系统整体的正确性,既然如此,为什么不直接采用更全面更真实的集成测试(https://en.wikipedia.org/wiki/Integration_testing) 或者功能测试(https://en.wikipedia.org/wiki/Functional_testing) 呢?原因相信很多人都知道,这是在成本与收益之间选择了折中。成本一方面体现在复杂度,对3个模块(假设每个模块有n条路径)的单元测试,我们需要考虑n+n+n种可能,但如果是对三个模块做功能测试,那么我们则需要考虑n*n*n种可能,难易程度一目了然;成本的另一方面是时间,我们期望用5分钟甚至更少的时间去发现80%甚至更多的错误,而剩下20%左右的错误用集中的更长的时间去处理,毕竟一次代码提交之后,程序员希望尽快知道结果,以决定是修复bug还是开始新功能的开发。

两个简单的例子

毫无疑问,写单测会增加“额外”的工作量,特别是当你还不懂得如何灵活使用并驾驭测试框架的时候。但当一切都步入正轨的时候,事情就会变得美妙起来。下面我将简单的举几个nodejs的单测例子来详细说明:

示例一

某天我接了一个计算pipeline触发时间的需求,基本要求是根据触发的具体时间,换算成n秒前、n分钟前、n天前这样的显示格式(超过一年的则直接显示具体日期)。开发难度并不高(友情提示:实在不会,百度或者Google一下都有类似你想要的答案哦)。但是,我应该怎么向产品经理证明我的程序是没问题的呢?“一分钟前”、“一小时前”哪怕“一天前”都还好,但“五个月前”甚至于一年前的日期要显示具体日期,我总不能等一年才通过验收吧!这种时候,单元测试可以替我说话:
首先,我们需要准备数据:

before(function() {
    instanceTriggerInfos = [{
       instanceId = 1,
       operationTime: new Date((new Date()).getTime() - 2 * 60 * 1000)  //两分钟前
    },
    {
       instanceId = 2,
       operationTime: new Date((new Date()).getTime() - (3 * 24 + 4) * 60 * 60 * 1000)  //三天前
    },
    {
       instanceId = 3,
       operationTime: new Date('2015-1-1')  //2015年1月1日,超过一年了
    }];
});

我写了一个方法叫做formatDate,对计算结果进行判断,于是我可以写这样一个测试。

describe('function #present', function() {
    it('should only return the maximum unit', function() {
    var presentation= formatDate (instanceTriggerInfos);
        expect(presentation[instance1Id].time).to.equals(2);
        expect(presentation[instance1Id].unit).to.equals('分钟前');
    expect(presentation[instance2Id].time).to.equals(3);
       expect(presentation[instance2Id].unit).to.equals("天前");
       expect(presentation[instance3Id].time) .to.equals('2015-1-1');
   });
});

As you wish, all test pass!于是乎,我可以开开心心地交差并迎接新的挑战去了!
等等,如果需求变成了超过一年显示“1年前呢”?那我在修改代码之后,只要把最后一个断言改成如下,并通过测试是不是就可以了呢?

expect(presentation[instance3Id].time).to.equals(1);
expect(presentation[instance3Id].unit).to.equals("年前");

其实,在面对需求变更地时候,我们甚至可以改变顺序,即先修改断言,然后去修改代码,同样以代码通过测试为验收标准——有没有觉得一丝丝地耳熟,没错,这就是传说中的测试驱动开发(TDD)。

示例二

我们的产品(crp.aliyun.com,广告Again)具有定时执行任务的能力,具体实现是将定时配置丢到redis里面,然后定时去redis里面提取可以在当前周期执行的任务,并执行之。所以当用户重新配置过之后,我们需要将旧的配置删除,并创建新的配置。有一天发现在修改过之后,旧的配置并没有被删除,于是乎去追代码、找bug(没错,这段功能没有单测)——不久便找到了根源,这段代码大致如下:

var deleteSchedule = function(scheduleId) {
    var jobs = getJobs(); //获取所有job
    var targetJob = _.find(jobs, function(job) {
        return job.id === scheduleId;
    });//找到与scheduleId匹配的job
    deletetJob(targetJob);//删除targetJob
};

问题出在

job.id === scheduleId

这一判断条件,通过console.log得知传入的scheduleId是一个object:

{
    id: scheduleId
}

“万恶”的动态语言,程序不运行至此,根本不知道入参int还是object。所以我直接将判断条件改为

job.id === scheduleId.Id

并git push。此时,发生了一件忧伤的事情:
由于我们在本地给git配置了一个 pre-push的hook来自动运行单测,如果单测不通过,则拒绝push。而此次push就恰恰被拒绝了。转念一想,也是一件好事,说明我们的单测发挥作用了呀。
看了测试代码后才发现,原来deleteSchedule这个函数在另一个地方也被调用了,而那里传入的scheduleId是int,所以更加合理的修改应该是在是上述的调用处,将入参从object改为int(更符合入参名称)。这次改完之后,我没有直接push,而是老老实实的给此处调用该接口的函数加上了单测…
所以在这个例子中,单元测试不仅仅帮我发现了bug,而且还督促我把代码写的更好。

结语

作为一名立志于不断打怪升级的程序员,上面提到的测试也仅仅是测试之旅的开始,后面我会继续分享如何选择和使用单元测试框架,如何准备数据来写集成测试等等。期待与大家一起交流,让测试不再是一句口号。

上一篇:js的组合实现,支持"二维"


下一篇:基于docker的持续交付系列(一):如何将app与docker整合并部署