测试在软件开发中的位置可谓举足轻重。几乎所有讲软件工程的书都会提到并且强调其重要性。测试在构建软件的作用非常明显,尤其是当你构建大型应用的时候。
本篇文章主要由四部分组成:
- 什么是测试
- 为什么要测试
- 测试的方法
- 自动化测试
测试就是保证你的代码按照预期运行的代码。
测试就是代码,和普通代码除了功能之外没有区别。 测试的功能就是确保被测试的代码按照预期运行。
如何确保呢? 通过断言。 怎么才能按照预期呢? 这就需要你充分考虑业务场景了。 高的测试覆盖率是优秀代码的特征。 如果你实现不知道怎么测试,那就先提高你的测试覆盖率吧。
高的测试覆盖率并不能反应代码质量高
高的测试覆盖率是好的代码的必要条件,因此想写好代码,先把你的测试覆盖率提高再说吧。
测试用例会收集包括软件哪一部分,哪一个分支甚至哪一条语句执行的信息。
测试覆盖率是一个技术指标,用来衡量被测试代码占所有代码的比例。
如下是我写的一个 sample 的测试覆盖率报告:
这里测试覆盖率有了更多的精细化的技术指标: 比如语句覆盖率(Stmts),分支覆盖率(Branch),函数覆盖率(Funcs),行数覆盖率(Lines),未被覆盖的行(Uncovered Line)等。这些指标通过名字就大概能看出含义。
断言是编程术语,表示为一些布尔表达式,程序员相信在程序中的某个特定点该表达式值为真,可以在任何时候启用和禁用断言验证, 因此可以在测试时启用断言而在部署时禁用断言。同样,程序投入运行后,最终用户在遇到问题时可以重新启用断言。 使用断言可以创建更稳定、品质更好且 不易于出错的代码。当需要在一个值为 FALSE 时中断当前操作的话,可以使用断言。 单元测试必须使用断言(Junit/JunitX)。 - 百度百科
断言实际上不仅仅在是测试中才会用到,很多其他地方也会用到。 只是有测试就会有断言,因此我们一个误区就是断言只有在测试中才有。
比如如下是 vue 的部分源码:
// assertProp 的定义省略了
if (
process.env.NODE_ENV !== "production" &&
// skip validation for weex recycle-list child component props
!(__WEEX__ && isObject(value) && "@binding" in value)
) {
assertProp(prop, key, value, vm, absent);
}react 也有类似的代码:
// assertValidProps 的定义省略了
assertValidProps(tag, props);assert 的目的就是为了断言 props 是否是有效的。
我们可以自定义断言,上面的 assertProp 就是。 我们可以非常轻松的写一个简单断言。 比如:
function assertValidArray(arr) {
return Array.isArray(arr) ? "valid" : "invalid";
}测试可以让你安心,它给你自信,它告诉你“嗯!你的代码没有问题”,它给了你 自信,这样的信息在构建大型项目是很重要的。
我的编程习惯是文档驱动,测试驱动,也就是说在做一个需求之前,先写下文档然后写测试用例,再写下实现的步骤(TODO),最终填充代码实现。这个模式可能和大家的编程习惯不太一样,甚至是完全相反的。 不过从我的经验来看,这个习惯给我带来了很多好处,我希望你也可以尝试一下。
程序员最不愿意做的事情除了临时需求变更之外恐怕就是改别人的代码了。 改别人的代码需要充分了解当时的场景,包括业务,基本假设是什么,除此之外还要看懂别人的每一行代码,我们才有信息去更改别人的代码。这是一个非常痛苦的过程。有了测试了用例情况就有所不同了。
在测试用例写的足够完善的情况下,他们只要保证改了代码测试用例可以通过了, 他们就有信息去修改你的代码了。
如果没有测试用例,发布新的功能和修复一个线上 bug 是非常痛苦的。 毕竟修复一个 bug,引入两个 bug 的梗不止一次地出现在我们的真实生活之中。 你如果非常了解这个项目倒还好,如果不呢? 这会让你举步维艰,仿佛陷入了一个无尽的沼泽。 因此完善的测试确实可以让你放心的按下确认发布按钮。
尤其是在敏捷团队中,发布和修复线上 bug 是一个非常频繁的操作,你如果不希望天天提心吊胆修改代码,那么从现在开始完善你的测试用例吧!
如果你不想从头到尾把代码看一遍。那么直接去看测试用例往往是一个快速的方式。 从测试用例我们不仅可以看到代码有哪些功能,我们设置可以看到代码能够处理的和不能处理的东西。 这些东西都是一目了然的。 我甚至在接手一个项目之前会优先看他的 package.json 和 test 文件夹。
前者可以看出项目的依赖组成,有哪些脚本。 后者可以看出项目具体的功能点
测试的类型有很多,不同维度去划分也会产生不同的类型。 这里讲以最常见的划分方法划分的测试种类。 枯燥的术语往往是比较难以理解的,希望你不会被这些术语给吓到。
我们先来看下最最基础的测试类型-单元测试。
单元测试或许是最简单最常见的测试类型了。
单元测试就是测试一小块代码的测试,这一小块代码可以是一个函数, 一个模块或者一个类等
单元测试同时也是最容易编写的,最容易被理解的测试类型。 单元测试总是设定一个环境,然后给定一个输入,检测程序的输出。
输入也可以是一个函数
如果你写过单元测试的话,你会发现有些东西非常容易写单元测试,而有些却非常难以书写。 更进一步我们发现那些难以书写单元测试的代码大都是依赖别的模块或者被别的模块依赖,换句话说是有副作用的,是不纯粹的。
非常容易写单元测试的代码是那些于 IO/UI 等无关的代码。
IO/UI 相关的可以是 ajax,localStorage,dom 等等
如果你确实依赖于外界(比如 IO/UI),你需要在进行单元测试的时候去 mock 他们。 因此,尽量限制代码的副作用在可控的范围是一件非常重要的事情
试试函数式编程吧。
如下是一个纯函数,没有副作用,很方便测试。
// math.js
function sum(x, y) {
return x + y;
}
// math.test.js
assert(sum(1, 2) === 3);
// ... 其他边界值测试我们再来看一个有副作用的例子:
// math.hs
function sumByLocalStorage(x) {
return x + window.localStorage.getItem("y");
}
// maths.test.js
function setup() {
window.localStorage.setItem("y", 2);
}
assert(sumByLocalStorage(1) === 3);可以看出你多了 mock 的步骤,你需要去 node(我们假设你使用 node 环境运行测试)中 mock 一个 localStorage 这个浏览器的 API。
这当然不是重点,重点是你改变了外界的环境,这会带来安全隐患。
当然你可以选择每次单个测试用例运行之后执行 teardown 将你的副作用清除,如上的例子是
window.localStorage.removeItem('y').
当然这依赖于开发者本身,因此还是有隐患。 如果不清除,对于我们调试问题会带来很大的障碍,尤其是开发大型项目的时候,这种
感受会更加深刻。
在我们讲解集成测试之前呢,我们来看下为什么我们需要集成测试。
单元测试确保了软件的每一个部件运转正常,而集成测试确保了它们在一起的时候运行正常。
独立运行正常并不意味着集成起来就正常,否则也就不会有集成测试了。
那么为什么独立运行正常,集成起来就有可能不正常了呢?
原因就在于我们的应用是有副作用的。 数学和计算机学友一个概念叫幂等(idempotent、idempotence)。这个概念在 后端会比较常见,前端很少会用到这个概念。后端幂等指的是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
那么对于前端也是类似的,如果我们的系统完全幂等。那么集成测试就显得不重要了。当然这个是不可能的。另外一个原因在于你各个部分正常运行,但是将各个部分组合到一起的部分,有异常(这部分是无法被单元测试覆盖到的)。因此集成测试是很有必要的。
集成测试关注的是各个部分的交互和相互作用。
集成测试就是将单元代码放到一起,看它们是否正确运行。
端到端测试相对来说比较昂贵,测试的代价会比较大,并且这部分占所有测试的比重也是比较低的。 但是不意味着不重要。
这部分主要关注的是真正的用户行为是否正确。前面两种测试更侧重于技术性,这部分则是完全站在产品角度,站在应用功能角度去测试。
