聊聊前端与单元测试
点上方蓝字关注公众号「前端UpUp
」
作者:大笑
来源:https://zhuanlan.zhihu.com/p/55887740
先来几个专业词汇,这样显得高大上一点(不存在的=。=)
BDD: Behavior-Driven Development (行为驱动开发)
TDD: Test-Driven Development (测试驱动开发)
ATDD: Acceptance Test Driven Development(验收测试驱动开发)
好,说完了,然后我们废话不多说,直接进入正题。我会从多个测试框架入手,结合各种断言库,用代码方式说明。
单元测试(Unit Testing),是指对软件中的最小可测试单元进行检查和验证。
当今所有著名的框架都要进行单元测试,经过测试的框架,它的信任度显然高于未测试的框架。
这里,我们介绍一下karma这个前端的单元测试框架。
首先我们来安装一波:
新建一个空文件夹,然后在空文件夹中打开终端输入
npm init -y
(sudo) npm install karma-cli -g
npm install karma karma-jasmine karma-chrome-launcher jasmine-core --save-dev
npm install karma-phantomjs-launcher --save-dev
你安装karma-cli这个倒是说得过去,可是这个jasmine是啥,这个chrome-launcher和phantomjs-launcher又是啥?
没错,单说测试框架是不完整的,必须要有断言库与之相配合,这里的jasmine就是断言库。
啥是断言(assert)?
根据概念:
断言是编程术语,表示为一些布尔表达式,程序员相信在程序中的某个特定点该表达式值为真,可以在任何时候启用和禁用断言验证,因此可以在测试时启用断言而在部署时禁用断言。
一言以蔽之,老子/老娘说啥就是啥!听起来好像挺霸道的。那么具体呢?
顺着karma的正常流程向下走,我们来写一个简单的单元测试。在终端输入:
karma init
你会发现,需要做一个调查问卷了,问题如下:
> 请问你要用哪种测试框架呢?
> 按tab键选择,按回车键进入下一个问题。
> jasmine
(因为我们安装的是jasmine,选什么断言库都别忘了安装一下)
> 您想要使用Require.js么?
> 选择yes的话,会安装Require.js插件。
> 按tab键选择,按回车键进入下一个问题。
> no
(这里我们选择no)
> 你想要在什么浏览器中测试呢?
> 按tab键选择,输入空字符串进入下一个问题。
> Chrome
> PhantomJS
>
注:上面的选择这两个浏览器的原因是我们之前安装了这两个浏览器的启动器(launcher)
> 需要测试的源文件和测试命令文件放在哪呢?
你可以使用通配符(glob patterns)来匹配文件,比如:"js/*.js" 或 "test/**/*Spec.js"
输入空字符串进入下一个问题。
>
(这里先留空,可根据测试情况灵活配置)
>在符合匹配的文件中有哪些文件可以排除在外呢?
你可以使用通配符来匹配文件,比如:"**/*.swp"
输入空字符串进入下一个问题。
>
> 你想要Karma根据文件的变化立即做出响应么?
> yes
之后,你就会发现你的文件夹里多了一个文件:
打开这个文件,你会发现里面是一个配置项函数:
module.exports = function(config) {
basePath: '', // 根路径将会同files和excluede项中的相对路径相关联
frameworks: ['jasmine'], // 所使用的测试框架
files: [], // 这里是需要测试的文件列表,有多种配置方式
exclude: [], // 测试过程中排除在外的文件列表
reporters: ['progress'], // 测试结果的汇报方式,
port: 9876, // web服务器接口
colors: true, // 是否使用彩色报告
logLevel: config.LOG_INFO, // 日志级别,可配置的值有: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
autoWatch: true, // 是否自动观测文档改变并执行测试命令
browsers: ["Chrome", "PhantomJS"], // 用哪些浏览器测试呢
singleRun: false, // 持续集成模式,如果设置成true,Karma将自行捕获浏览器,运行测试并根据结果退出,
concurrency: Infinity // 并发数,同时跑多少个浏览器进行测试,默认无上限
}
默认会生成的配置项就是上面这些,更完整的配置请点我
这里稍微提一下browsers配置项,它可以配置高达8种浏览器:
每一种都需要安装对应的launcher。其中有两个需要注意chromeHeadless和PhantomJS。这两个是无头浏览器。所谓无头浏览器就是没有脑袋的浏览器。
无头浏览器即headless browser,是一种没有界面的浏览器。既然是浏览器那么浏览器该有的东西它都应该有,只是看不到界面而已。因此这种浏览器没有渲染UI的过程,用于测试时的速度很快。
这就回答了上文launcher是啥的问题。毕竟,没有浏览器靠脑补可没法测试啊(真实)
言归正传。我们回到karma测试本身。接下来,我们修改一下配置:
files: ["src/srcTest/**/*.js", "test/unit/**/*.js"]
注意,上述写法只是配置写法中的一种, 配置的文件位置也是随您自己指定,更详细的配置请点我
采用上文写法的话,我们在files数组里面配置的第一项是需要测试的文件,第二项就是用什么方法去测试它的文件。
因此,我们也在文件里创建对应的文件夹:
这里有一个要注意的点。我们的需要测试的文件和测试驱动文件的名字是一一对应的,区别就在于测试驱动文件的名字后要加上.spec
那么我们就在srcTest的文件里面写点什么吧....
newBee.js
// 减法函数
function minus(x) {
return function(y) {
return x - y;
};
}
testKarma.js
// 加法函数
function add(x) {
return function(y) {
return x + y;
};
}
// 乘法函数
function multi(x) {
return function(y) {
return x * y;
};
}
//if函数测试
function ifTest(boolean) {
if (boolean) {
return "热热";
} else {
return "凉凉";
}
}
// 反转字符串
function reverseStr (string) {
return string.split("").reverse().join("");
}
那么接下来,就在.spec文件里写入对应的测试断言。我滴个龟龟,终于说到断言了。
因为我们这里使用的是Jasmine,因此就先放一下它的官网。
我们结合实例来说文档
newBee.spec.js
describe("newBee单元测试", function() {
it("减法函数测试", function() {
var minus7 = minus(7);
expect(minus7(6)).toBe(0);
});
});
testKarma.spec.js
describe("testKarma单元测试", function() {
it("如果函数测试", function() {
expect(ifTest(true)).toBe(true);
expect(ifTest(false)).toBe("凉凉");
});
it("回文函数测试", function() {
expect(reverseStr('abc')).toEqual('cba');
})
});
基本的格式就是这样的,下面来解释一下
// 分组describe(), 这个是可以嵌套的,并且每个单独的测试都有beforeAll, afterAll, beforeEach和afterEach
describe("这里写测试群组的名称", function(){
// 具体的测试,it(), 当其中所有的断言都为true时,则通过;否则失效。
it('这里写具体测试的名称', function(){
var a = true;
// 期望, expect()。匹配,to*()
// 每个匹配方法在期望值和实际值之间执行逻辑比较
// 它负责告诉jasmine断言的真假,从而决定测试的成功或失败
// 木有错,老子/老娘说啥就是啥
expect(a).toBe(true); // 这是肯定断言
expect(!a).not.toBe(true); // 这是否定断言
// jasmine内置的匹配方法有很多,亦可自定义匹配方法
// toBe()
// toEqual()
// toMatch()
// toBeUndefined()
// toBeNull()
// toBeTruthy()
// toContain()
// toBeLessThan()
// toBeCloseTo()
// toThrowError()
// 等等等等
})
})
那么,测试方法写完了,我们来实际运行一下测试吧。打开终端,输入:
karma start
就会在终端看到
可以看到,我们的测试在Chrome和PhantomJS浏览器中分别测试了的5个方法,都有2个没有通过测试,没错,我们当初在写测试的时候故意写错了(这是真的)。
那么我们把测试修改成真值。
newBee.spec.js
describe("newBee单元测试", function() {
it("减法函数测试", function() {
var minus7 = minus(7);
expect(minus7(6)).toBe(1);
});
});
testKarma.spec.js
it("如果函数测试", function() {
expect(ifTest(true)).toBe("热热");
expect(ifTest(false)).toBe("凉凉");
});
结果是:
全部SUCCESS, 撒花。
到这里,一个基本的测试流程就走完了。然而,这并非终点。
其实,还能更进一步的。我们打开终端:
npm install karma-coverage --save-dev
然后打开karma.conf.js, 添加一些配置项
// 这里配置哪些文件需要统计测试覆盖率,例如,如果你的所有代码文件都在src文件夹中,你就需要如下配置
preprocessors: {
"src/srcTest/*.js": "coverage"
},
// 新增coverageReporter选项
// 配置覆盖率报告的查看方式,type查看类型,可以取值html、text等等,dir输出目录
coverageReporter: {
dir: "docs/unit",
reporters: [
{
type: "html",
subdir: "report-html"
}
]
},
reporters: ['progress', "coverage"] // 没错,reporters里面新增了一个coverage
然后保存,再运行一次karma start
接着会发现你的项目里多了一个文件夹
用浏览器打开index.html。就会看到
这就是你所写的js的测试覆盖率。
这样看起来是不是高大上了一些呢?
这里就有一个问题了。普通的js可以测试,可是我是写Vue的啊,Vue组件怎么测试呢?很简单,Vue官网有非常详细的测试教程。甚至还有专用的测试工具和测试说明
彳亍口巴,你说的这些个单元测试看起来花里胡哨的,实际作用是什么呢?
单元测试的好处
单元测试不但会使你的工作完成得更轻松。而且会令你的设计会变得更好,甚至大大减少你花在调试上面的时间。
提高代码质量
减少bug, 快速定位bug
使修改和重构可以更放心
显得专业
单元测试的缺点
开发人员要花费时间在写测试代码上,然而又不会给你加工资...
小项目写测试只能单纯的增加开发时间和成本,然而又不会给你加工资...
我写了测试除了懂测试的人能看懂,别人又不知道,然而还不会给你加工资...
别别别,别打我...你先听我道(hu)理(jiao)讲(man)完(chan)。
对于所编写的代码,你在调试上面画了多少时间?
对于以前你自认为正确的代码,而实际上这些代码却存在重大的bug,你花了多少时间在重新确认这些代码上面?
对于一个别人报告的bug,你花了多少时间才找出导致这个bug的源码位置?
对于那些没有使用单元测试的程序员而言,上面这些问题所耗费的时间的是逐渐增加的,而且项目越深入,花费的时间越多;另一方面,适当的单元测试却可以很大程度地减少这些时间,从而为你腾出足够的时间来编写所有的单元测试——甚至可能还有剩余的空闲时间。
更加真实的是,主流的框架必须要写测试
不想当程序员的设计师不是好运维。----鲁迅
作为一个程序员,如果你想要让自己写的框架放到github和npm上能够为世界上的其他人所用。那么一个最基本的前提就是————代码没有BUG。可是,你的怎么向语言不通思维不同的人解释你的JavaScript库确实足够健壮呢。这个时候就需要单元测试出场了。
主流前端框架虽然在所使用的测试库(karma、jest、QUnit)和断言库(assert、jasmine、 chai)上略有差别,但Vue、React、Angular、Underscore甚至是jQuery都写了单元测试。
来个石锤
下面我们看一看Vue的测试是怎么写的:
git clone https://github.com/vuejs/vue.git
npm install
npm run test unit // 这里可以看到单元测试
npm run test // 这里就看全部的测试
Vue的测试覆盖率为
举例:v-show的测试
// import Vue from 'vue'
describe('Directive v-show', () => {
it('should check show value is truthy', () => {
const vm = new Vue({
template: '<div><span v-show="foo">hello</span></div>',
data: { foo: true }
}).$mount()
expect(vm.$el.firstChild.style.display).toBe('')
})
it('should check show value is falsy', () => {
const vm = new Vue({
template: '<div><span v-show="foo">hello</span></div>',
data: { foo: false }
}).$mount()
expect(vm.$el.firstChild.style.display).toBe('none')
})
it('should update show value changed', done => {
const vm = new Vue({
template: '<div><span v-show="foo">hello</span></div>',
data: { foo: true }
}).$mount()
expect(vm.$el.firstChild.style.display).toBe('')
vm.foo = false
waitForUpdate(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
vm.foo = {}
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('')
vm.foo = 0
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
vm.foo = []
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('')
vm.foo = null
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
vm.foo = '0'
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('')
vm.foo = undefined
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
vm.foo = 1
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('')
}).then(done)
})
it('should respect display value in style attribute', done => {
const vm = new Vue({
template: '<div><span v-show="foo" style="display:block">hello</span></div>',
data: { foo: true }
}).$mount()
expect(vm.$el.firstChild.style.display).toBe('block')
vm.foo = false
waitForUpdate(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
vm.foo = true
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('block')
}).then(done)
})
it('should support unbind when reused', done => {
const vm = new Vue({
template:
'<div v-if="tester"><span v-show="false"></span></div>' +
'<div v-else><span @click="tester=!tester">show</span></div>',
data: { tester: true }
}).$mount()
expect(vm.$el.firstChild.style.display).toBe('none')
vm.tester = false
waitForUpdate(() => {
expect(vm.$el.firstChild.style.display).toBe('')
vm.tester = true
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
}).then(done)
})
})
只要你的测试覆盖率足够高,你就可以在著名的GitHub装逼网站Codecov搞一个覆盖率标签了。就像下面这个:
怎么样,这样你所写的框架,是不是就逼格满满?
所以你还在等什么,测不了吃亏,测不了上当,赶紧在自己的代码中加入测试吧,~~只要998~~,代码逼格带回家!
感谢大家
关注「前端UpUp」,分享精选面试热点文章。
加我好友,一起讨论算法,2021一起UpUp。