正儿八经地写JavaScript之单元测试

前段时间阅读《Android编程权威指南》,第21章专门介绍了Android中的单元测试,当时照猫画虎进行了一点练习,感觉在项目中引入单元测试确实是一件事半功倍的事情。于是开始着手查询JavaScript单元测试,才发现原来已经有这么多工具了。下面是对单元测试与Mocha使用心得的一些整理。

<!--more-->

在谷歌下搜索javascript unit test,推荐链接展示了一堆mocha的相关搜索,发现原来是TJ大神写的,作为一个脑残粉,于是就选择了mocha

参考:

1. 单元测试

开发是一个增量的过程,拿最近的项目来说:

  • 前期需要按照指定的规则(类似于一个模板引擎)在客户端解析用户上传的txt文本,提取作者,时间,概述等基本内容;
  • 需求更新,要求在原来的解析规则上适当放开限制,提高代码的容错率
  • 需求更新,运行用户上传docx文档,解析图片内容并自动上传到服务器,替换为对应的URL

总体来说这个需求是围绕着文本解析进行的。在最初开发的时候,根本不知道后面的需求迭代;而后续开发是在前面的功能基础上进行的,尤其是第二条,简单来讲就是增加可匹配的标签,但是必须提防的是增加的标签不会影响前面已经实现的功能~总之越往开发后期,代码改的越心惊胆颤。

要是早点学习了单元测试该多好!单元测试就是为了验证代码的功能是否会如预期般生效

我的理解是:我们会在开发过程中手动去检测某个函数是否会返回正确的结果,某个分支是否会在指定条件下进入等,单元测试就是用来测试我们写的功能代码,更重要的是,测试代码是可以重复执行的,也就是说,我们可以在后续开发的过程中折回来测试先前的功能,需要的成本仅仅只是执行一个命令而已。

那么,测试框架又是什么呢?

测试框架的职责即提供一套 API 帮助开发者更方便的测试代码

简单来讲,测试框架可以更方便的帮我们保管(组织)测试代码,展示测试结果及测试覆盖率等;具体来讲,测试框架可以帮我们执行测试逻辑,包括异步测试,测试过程钩子函数,断言库等。

选择mocha的原因主要是:攻略太多了啊喂~

2. mocha使用

关于mocha的使用,官网上已经介绍的比较详细了,网上也能找到大量的教程。这里只是简单整理一下。

2.1. 安装

只需要进行下面三步即可:

  • 全局安装mocha,mocha -h可查看帮助命令
  • 在项目目录下新增一个test文件夹,用于存放测试文件,一般命名为XX.test.js这样
  • 然后在与test同级的目录使用mocha命令,会自动执行test目录下的所有js文件(所以测试目录下就不要放其他无关的文件了)

2.2. API

2.2.1. describeit

  • describe方法用来描述和执行一组测试,第一个参数会输出到控制台,作为这组测试的标识符
  • it方法用来描述某个测试用例,同样会在控制台输出,如果测试用例通过,则会在其前面打勾
let assert = require("assert");

describe("demo", function () {
      it("100% success", function(){
          assert.equal(1, 1);
      })
})

相关的文字描述可以用来组织层次化的测试用例,比如针对某个的对象的某些方法,为每个方法进行数个测试案例,则代码层次可以为

describe("demo", function () {
    describe("foo", function () {
          it("", function(){
              // todo some assert
          })
          it("xxx", function(){
              // some assert
          })
    })

    describe("bar", function () {
          it("xxx", function(){
              // some assert
          })
    })

      it("100% success", function(){
          assert.equal(1, 1);
      })
})

测试代码主要是为开发人员服务,因此语义化的代码尤其重要,为了更加直观的组织测试代码,mocha还额外提供了一个context方法,实际上他只是describe方法的别名而已,可以灵活使用。

2.2.2. 钩子函数

在测试过程中,mocha提供了4个钩子函数

  • before,在当前describe块的所有测试用例之前调用,调用一次
  • after,在当前describe块的所有测试用例之后调用,调用一次
  • beforeEach,在当前describe块的每个测试用例调用之前都会调用,调用N次
  • afterEach,在当前describe块的每个测试用例调用之后都会调用,调用N次

下面的代码简单测试一下

describe("hooks", function () {
    var a = 0;
      // 其余方法省略~
    beforeEach(function () {
        console.log(++a);
    });

      it("case 1", function(){
          assert.equal(1, 1)
      })
      it("case 2", function(){
          assert.equal(10, 10)
      })
})

钩子函数在某些时刻非常有用,比如为了验证数据库模型的insert方法,需要向数据库插入一条记录,在测试执行完毕,需要将对应的记录移除(测试数据不应当保留),对应的逻辑放在钩子函数里面会非常合适。

需要注意的是,如果不存在it函数,那么也不会执行钩子函数哦~

2.2.3. done

JS中的异步代码是非常常见的,比如网络请求,数据库操作,定时器等,用mocha来测试异步代码也十分简单:使用,并在异步结束执行之后调用done即可完成测试用例的执行。

describe("demo", function () {
    var a = 1;
    beforeEach(function (done) {
        setTimeout(function(){
              a = 10;
              done();
          }, 1000)
    });

      it("asynchronous demo", function(){
          assert.equal(a, 10)
      })
})

文档上的done方法讲解的并不是很容易理解,这里参考

如果在describe,it及钩子函数的回调函数中传入了done参数,则必须等待该done完成调用之后才会执行后面的测试案例。

describe("done", function(done){
    context("has done", function(data){
        setTimeout(function(){
            done();
        }, 200);

        it("done test1", function(){
            console.log("it里的输出1");
        });

        it("done test2", function(){
            console.log("it里的输出2");
        });

        console.log("it外的输出");
    })

    context("no done", function(){
        console.log("no done()");
    });
     // 执行结果依次为 it外的输出->no done()->it里的输出1->it里的输出2
});

可见done会阻塞它所位于块内的后面的it方法,我们还可以写一个通用的异步测试的方法

function promiseDescribe(describeName, task, assertFunc){
    describe(describeName, function () {
        let globalVal = {};
        beforeEach(function (done) {
            task(globalVal, done);
        });

        if (assertFunc instanceof Array) {
            assertFunc.forEach(func => {
                func(globalVal);
            });
        } else {
            assertFunc(globalVal);
        }
    });
};

下面是使用方法

promiseDescribe("async", function(globalVal, done){
    setTimeout(function(){
        globalVal.a = 10;
        done();
    })
}, function(globalVal){
    it("should", function(){
        assert.equal(globalVal.a, 10)

    })
})

由于文档还没有看全,之前异步代码测试我都是使用这个简陋的方法进行的。

2.2.4. 描述符

单元测试可以在边开发边进行测试,最好的方式的完成一个功能就编写对应的测试用例,虽然会耽搁一些时间,但是与之后返回进行手动测试相比,这一切都是值得的,此外在回头写文档的时候也可以直接参照测试用例进行。但是问题来了,在开发的时候我们只希望执行某一些测试用例而非全部测试,mocha为我们提供了相关的描述符

  • it.only(),只执行带有only修饰的测试用例,如果同时存在多个带only的测试用例,则他们都会被执行(only貌似就有点名不副实了),如果不存在only修饰,则会执行所有的测试用例,所以在开发的时候only方法非常有用(可以立即测试我们刚写的接口)。
  • it.skip(),在某些时候测试用例需要进行调整或者跳过,则可以使用skip修饰,此时在控制台对应的测试用例会显示pending;对于作废的测试用例,官方的建议是skip而不是直接删除掉

需要注意的是,不仅可以在某个测试用例上使用描述符,也可以为某个用例集合describecontext使用,在测试用例很多的情况下就会很有用哦~

2.3. 断言

前面提到,单元测试主要是为了验证程序的功能是否正常,那么,功能的正常与否是如何判断的呢?大多数时候,单元测试都是针对接口和库函数进行的,而对于接口和函数的评定标准无非是传入对应的参数,是否返回预期的结果(正确的结果由我们手动给定)。因此,在单元测试中使用断言是在正常不过的了。

断言即我们相信程序在某种条件下必定会输出的某个确定的结果。断言库提供了方便的api来判断程序是否输出了指定的结果,上面代码中的assert就是断言库。在官方文档中推荐了几种断言库

  • assert,node内置断言库,功能比较少
  • should.js,听名字貌似很直观的样子
  • chai,上面的assert使用的就是chai,不过我也是刚接触,所以不是很熟悉,这里是文档

需要注意的是,如果书写合适的断言是单元测试中一个比较难的地方,下面提到测试用例的时候会再次说明。

3. 测试用例

为了练习单元测试,我尝试着写了一个mysqljs模型类,然后为这个模型类编写测试用例。测试用例实际上就是单独调用某个方法,并测试其输出结果的一组逻辑代码。

在测试的过程中,发现了不少问题。下面这几个问题是我对自己的提问,到目前为止,并没有合适的解决答案~

3.1. 如何组织测试用例

前面也提到了,测试代码主要是面向开发人员的,因此为了高效率的开发,如何组织测试用例就显得尤为重要。

mocha可以在describecontext中进行嵌套,这样就可以在控制台输出树状的测试结果,非常直观。此外it描述语句要尽可能清晰的描述该测试用例,也就是“需要测试的单元在给定的条件和参数下会发生什么事情”。

此外还可以将测试用例按模块和功能以单文件的形式进行分类整理(而不是将所有的测试代码都放在同一个文件中),这点可以参考jQuery源码的测试代码,在其test/unit目录下,对各个模块如ajaxanimation等测试用例就是按文件进行管理的。

举例来说,模型类一般会提供CRUD的接口,可以组织对增删查改四个测试用例集合;就查找来讲,又可以单独测试whereorderlimit等接口;拿where来讲,又需要测试默认where(id, 1)where(id, ">", 1)这样的分支情形。总之,按层次来组织测试用例可以更清晰的帮助我们测试代码,避免遗漏某些地方,也是下面要提到的。

3.2. 如何相信测试用例

如果测试代码本身有问题,那么测试不仅毫无意义,还会浪费更多的调试时间(调试源码,调试测试代码),因此,我们必须确保测试用例的正确性。关于这个问题,知乎上有一个回到:如何保证测试用例又少又准确?

精简

测试用例应当一眼就能看出其测试母的,尽量避免任何分支逻辑,因为我们需要的只是传入确定的参数,断言预期的结果而已,对于分支的测试应当另外写测试用例,而不是在同一个测试用例中增加逻辑操作。

考虑全面,逐步完善

测试用例需要尽可能考虑全面,避免遗漏某些分支;此外在接口的使用过程中如果出现了超出预期的结果,也可以回头补充测试用例。换句话说,测试也是一个逐渐完善的过程,我是在边开发边测试的,即完成一个接口就立即测试,遇见后面接口调整的时候(比如参数位置,输出结果格式化等),会回过头来重新调整测试用例。

3.3. 如何写断言

断言就是断定程序在这个条件下必定会输出的结果,断言的正确与否直接影响测试用例是否通过,一个好的断言的结果应该是固定的,即测试用例本身没有问题的情况下,无论运行多少次,都会得到统一的结果:要么通过,要么失败。

这个问题在我写测试用例的时候十分纠结,因此并不能保证数据库的数据是不会变化的,也就是说即使现在id=1的数据必定会返回root,在将来的某个时候数据发生改变,则这个断言必定失败。这种“硬编码”的形式明显是很不合理的。

promiseDescribe("=", function (globalVal, done) {
    admin.where("id", 1).select().then((res) => {
        globalVal.name = res[0].name;
        done();
    });
}, function (globalVal) {
    it("'id = 1' should return root account", function () {
        assert.equal("root", globalVal.name);
    });
});

也许使用钩子函数,在用例执行之前插入数据,然后再测试查询并将结果与插入的数据进行比较要更合理一些。但是这样测试用例就会加入大量的逻辑代码,测试的时间也会更长~

总之,如何写好断言不是一件简单的事情。

4. 小结

由于单元测试这块也是刚接触,上面的整理会有理解错误和遗漏的地方,后面再回来填(这个梗我都用了无数次了,貌似很少有填坑成功的~)。不过单元测试对于开发而言确实十分有帮助,在开发完成之后跑一通测试,看见满屏幕的勾,成就感简直爆棚,哈哈。

磨刀不误砍柴工,不要嫌写测试用例麻烦。PS:今后也会尝试一些其他的测试框架,以及了解框架的一些原理,总之,要想正儿八经的写代码,单元测试时必不可少的(不只是写JS哦)。