《JavaScript测试驱动开发》读书笔记(上)

之前写过一篇正儿八经地写JavaScript之单元测试的博客,并信誓旦旦的表示以后要用单元测试来保证自己代码的可靠性,后来却由于工期忙、没精力等各种理由将单元测试选择性忘记了,每次的代码都是自己简单测一下,就交给测试同事进行测试了。

最近图灵社区推送,刚好看见了《JavaScript测试驱动开发》这本书半价,于是入手,顺便整理读书笔记。

PS:以后的读书笔记,会整理内容概览的脑图,方便日后回顾。

<!--more-->

在阅读过程中还新建了一个项目用于练习,github传送门

1. 为什么要进行自动测试

良好的代码设计应当是灵活的、可扩展的、可维护的。

代码在其生命周期会经过多次迭代和修改,每次修改都可能影响到其他地方,为了保证代码的可靠性,必须进行全面的测试,然而手动测试是一个费时费力的问题

根据测试金字塔(底层测试越多,高层次的端到端的测试越少)的概念,在合适的层次编写测试,单元测试最多,功能测试其次,端到端的UI层测试最少。

要想代码具有测试性,则代码必须具备高内聚、低耦合的特性

如何实现自动化测试:

  • 细化测试,为了测试某个功能,我们可能需要编写多个测试用例
  • 分而治之,如果很难为一个函数编写测试,可能是因为这个函数的功能太多了,可以考虑将功能拆分成多个小功能再单独测试
  • spike解决方案,如果遇见一个很困难的功能,可以转而实现一个小demo,并对demo进行测试和验证

2. 编写测试代码

mocha是测试工具,chai是一个断言库,这两个工具之前都使用过,这里不算陌生。书中使用的断言风格是except,我更习惯使用`assert,因此后面的练习与书中的代码可能有出入。

在项目目录下新建test目录,然后执行mocha命令时会自动执行test目录下的所有测试文件。

2.1. 测试驱动开发流程

测试驱动的开发流程是

  • 先编写不能通过的测试用例,最简单的方法就是不编写需要测试的功能。
  • 再以最小的代码编写通过的测试用例
  • 然后通过重构保证测试用例通过,且代码更完善

可以通过beforeEach和afterEach两个钩子函数来处理测试用例公共的一些逻辑,比如实例化对象和对象回收等。

编写测试用例要遵循3-AS模式,即

it('1 + 2 should return 3', function(){
    // Arrange 设定测试时需要用到的数据
    var a = 1,
        b = 2
    // Act 执行需要测试的功能代码
    var result = Util.add(a, b)

    // Assert 验证执行结果
    assert(result === 3)
})

还需要注意下面几点

  • 测试用例应该是职责单一的,我们需要同时注意功能代码和测试代码的质量
  • 测试应该与代码相关,并验证代码的正确行为
  • 独立的测试可以按照任意顺序执行
  • 每个测试都应该尽量少包含几个断言(只包含相关的断言),避免将多个独立的断言放在一个测试环境中

先编写测试,我们就能以代码使用者的角度思考问题,这能确保我们设计的每一个函数都是由一系列测试驱动的。

每一个测试都应该能够让我们用一点额外的功能来改进代码,换句话说,测试应该能够让我们将想到的问题提出来,帮助发现细节,然后在这个过程中梳理出代码的接口。

初始的实现代码控制在最小限度,我们就有更多精力放在函数的接口上,包括函数名、参数、返回值类型等,而不用过早关注具体实现。

在改进代码的过程中,新的测试帮助我们逐渐深入设计细节,而已有测试则确保代码仍旧能满足之前的需求,这样的反馈能够让我们坚信代码在每次修改之后都能正确运行。

2.2. 三种测试形式

当设计一个函数时,可以先思考一下使用何种类型的测试来验证这个函数。如果后续需要修改函数内部的实现,则必须保证修改后的代码也能够跑通所有的测试用例。

正向测试

正向测试,当前置条件满足时,验证代码的结果符合预期。一般情况下就是给定输入,然后判断输出是否符合预期

it("mom is palindrom", function () {
    let input = "mom";

    let res = isPalindrom(input);

    assert(res === true);
});

反向测试

当输入或前置条件不满足时,代码能优雅地进行处理。在编写功能代码时,我们需要考虑到一些边界情况、非法输入和可能导致程序出错的一些其他情况。

it("empty string is not palindrom", function() {
    let input = "";

    let res = isPalindrom(input);

    assert(res === false);
});

反向测试就是用来测试一些与常规输入有偏差的设计。如果将来需要修改边界情况的处理方式,则必须修改相关的测试代码。

总之,如果需要检测边界条件等,也是先编写测试用例,再编写实现代码。

异常测试

代码在应该抛出异常的地方抛出了异常。JavaScript是动态灵活的,也是很容易出错的,因此我们需要保证代码在预期的错误下会抛出异常。

chai.assert提供了throwsdoesNotThrow两个方法用于判断函数是否扔出了异常

it("empty args should throw expection", function () {
    let call=  function(){
        isPalindrom()
    }

    assert.throws(call, "invalid argument");
});

2.3. 测试代码覆盖率

代码覆盖率报告可以快速标识出哪些代码没有被测试覆盖到,书中推荐的istanbul是一个非常好用的工具

在根目录下的/coverage/lcov-report/index.html下可以看到每一个文件、每一行的测试覆盖了,超级好用。

如果发现没有被覆盖的代码,这意味着我们测试用例的不充分,隐藏着代码设计的漏洞。

2.4. 浏览器代码的测试

上面的测试用例是在node环境下执行的,可以使用CommonJS模块和一系列node环境提供的接口,那么浏览器环境下的代码怎么测试呢?

比如我们编写了一个操作DOM的函数,由于mocha运行在node下,并没有相关的dom接口,整个测试环境都无法执行,别灰心,此时可以使用karma

首先安装相关依赖

"karma": "^2.0.4",
"karma-chai": "^0.1.0",
"karma-chrome-launcher": "^2.2.0",
"karma-clear-screen-reporter": "^1.0.0",
"karma-cli": "^1.0.1",
"karma-coverage": "^1.1.2",
"karma-mocha": "^1.3.0",

然后编写karma的配置文件

node node_moudles/karma/bin/karma init

然后测试框架选择mocha,浏览器选择Chrome,取消选择RequireJS,其他选项默认值即可,确认后会在目录下生成karma.conf.js文件。

然后修改该配置文件

module.exports = function(config) {
  config.set({
      // ... 其他未列出的选项保持默认值
      frameworks: ["mocha", "chai"],
      // karama需要测试的代码
      files: ["./browser/**/*.js", "./browser-test/**/*.js"],

      // 增加测试代码覆盖率
      preprocessors: {
          "browser/**/*.js": ["coverage"]
      },
      reporters: ["progress", "coverage"],
      browsers: ["Chrome"],
  });
}

然后编写相关测试文件,执行测试命令karma start即可。

karma会启动一个服务器,内部生成一个html文件,加载配置文件中files列举的js文件,启动对应的浏览器,执行测试,报告结果。

需要注意的是,服务端和客户端测试代码本身没有多大区别,最大的区别在于如何引入待测函数。由于karma是把功能文件注入到html页面上,并不支持CommonJS等模块语法,所以我们应该按照在浏览器script标签中封装模块一样实现功能代码,包括使用DOM、BOM等接口。

3. 异步测试

JavaScript中存在大量的异步代码,异步代码的测试与同步代码的测试有很大区别,mocha提供了done帮助我们实现异步代码的测试

3.1. 基于回调的异步测试

// 需要测试的异步函数
module.exports = {
    pow: function(a, cb) {
        setTimeout(() => {
            let res = a * a;
            cb(res);
        }, 1000);
    }
};

// 测试用例
it('10 * 10 should return 100', function (done) {
    let a = 10

    // 允许指定异步的超时时限
    this.timeout(10000)
    Util.pow(a, function (res) {
        done()  // 默认2000ms的超时时限,或者自己手动调用来实现
        assert(res === 100)
    })
})

3.2. 基于Promise的回调测试

mocha的异步测试有两种,

  • 一种是上面通过done参数,表示测试已结束;
  • 除此之外,还可以使测试用例返回一个Promise对象,表示最后的测试已经结束
module.exports = {
    pow(a){
        return new Promise((res, rej)=>{
            setTimeout(() => {
                res(a*a)
            }, 1000);
        })
    }
}
// 测试用例
it('10 * 10 should return 100', function () {
    let a = 10
    let cb = function(res) {
        assert(res === 100);
    };
    Util.pow(a).then(cb)
})

通过返回promise对象进行异步代码的判断,不需要额外的done参数,更加简洁

4. 处理依赖

代码中处处存在依赖,他们会让测试变得不确定,因为依赖改动可能会影响测试用例的执行。

4.1. 优化代码设计

代码是否具有可测试性是设计问题。

  • 承担了大量工作的代码是难以测试的,他们违背了单一职责原则
  • 编写测试用例的首先需要确定一个或多个不具有内部依赖的函数
  • 尽可能除去被测代码中的依赖,抽取函数,并将最少的数据作为参数传递给它
  • 函数的功能越少,我们越容易为其编写正向、反向测试

这里突然想起之前看过的王银的一篇博客,里面提到,函数就是每门编程语言最基础的模块化工具。将功能通过函数封装起来,我们只需要测试对应的函数接口就行了。

4.2. 使用测试替身

测试替身用来代替真正的依赖对象,测试中用来代替依赖的测试替身有以下几种不同的类型

  • fake,能用于测试但不能用于生产环境的实现
  • stub,并不是真正的实现,但被调用时可以快速返回预设数据,用于验证状态
  • mock,与stub类似,可以返回预设数据,且可以对交互进行跟踪,用于验证行为
  • spy,代理真实的依赖

依赖注入

可以通过依赖注入技术使用测试替身来代替依赖。依赖注入的意思就是依赖在调用时作为参数传递

module.exports = {
    redirect(url){
        location.href = url
    }
}

上面的函数依赖于window.location,在node环境下测试很麻烦,自动化测试基本是不可能的,但是如果我们使用依赖注入

// 将window.location依赖作为参数传入,实现依赖注入
redirect(location, url){
    location.href = url
}

这样测试用例就很容易编写

it('location.href should change', function () {
    var location = {
        href: '//a.com'
    }
    var url = '//b.com'
    Spike.redirect(location, url)

    assert(location.href === url);
})

为了便于测试,在设计函数时应尽量使用依赖注入,而不是直接创建一个依赖对象或者从全局引用获取。

交互测试 在很多情况下,函数内部的依赖决定了函数的返回结果,这往往让代码不确定且难以预测。此时依赖注入也不一定能够奏效,除非我们再模拟一个与依赖具有相同方法的对象。

这里需要认清一点,函数的测试关注的是函数的行为,而不是其内部的依赖对象是否正确,因此,测试代码只需要检测函数内部代码是否能够以正确的方式与依赖对象进行交互就够了。

// 实现
var Spike =  {
    success(){},
    error(){},
    // 此处依赖注入很难实现,可以使用交互测试
    locate(){
        navigator.geolocation.getCurrentPosition(this.success, this.error)
    }
}
// 这个测试用例主要测试getCurrentPosition是否成功执行并注册相关回调
it("locate test", function(done) {
    var origin = navigator.geolocation.getCurrentPosition
    navigator.geolocation.getCurrentPosition = function(success, error){
        assert(success === Spike.success);
        assert(error === Spike.error);
        done()
    }
    Spike.locate()
    // 测试完毕需要还原
    navigator.geolocation.getCurrentPosition = origin
});

OK~这样就可以测试在locate函数内部是否正确执行力注册回调的相关工作。

上面的做法存在的问题是,手动模拟一个函数代替真正的函数,然后在测试结束后还需要重置代码,这样十分冗余。更好的做法是使用Sinon框架

4.3. Sinon

首先安装依赖

npm i sinon sinon-chai karma-sinon karma-sinon-chai -D

记得修改karma.conf的配置

frameworks: ["mocha", "chai", "sinon", "sinon-chai"],

然后使用sinon来编写测试用例

// 使用sinon解决上面手动模拟函数的麻烦
it("sinon spy test getCurrentPosition is called", function() {
    var aSpy = sandbox.spy(navigator.geolocation.getCurrentPosition);
    Spike.locate();
    assert(aSpy.called === true);
});

注意书中的某些sinon的API已经过时了,最好参考最新的官方文档

5. NodeJS 测试驱动开发实例

书中列举了一个输出股票价格的应用,并引导读者如何进行测试驱动开发。

5.1. 初步分析

从策略设计开始,分析需求,初步设计需要哪些函数实现

  • 首先我们需要一个读取文件的函数,readStockFile
  • 然后我们需要一个从文件内容中解析出股票代码的函数,parseStockFile
  • 然后需要对每个股票进行处理,processStockTickets
  • 为每个股票去请求网络接口,获取股票价格,getStockPrice
  • 最后输出股票价格,进行展示,showStockPrice

根据初步设计,编写测试用例及初步实现

  • 第一批测试应该帮助我们梳理接口,
  • 后续测试应该帮助我们实现代码,并在增加测试用例的修改过程中逐渐细化设计
  • 每次都应该只关注一个函数的设计,依赖的函数通过测试替身实现
  • 当对一个函数进行了正向测试、反向测试,并跟随测试用例优化了代码之后,就可以开始进行下一个函数的测试和设计了,牢记一点:测试驱动开发

5.2. 每个函数测试用例的侧重点

readStockFile

例子中的读取文件函数readStockFile,先编写测试用例,测试内部需要调用parseStockFileprocessStockTickets,在测试用例中,都是通过stub来实现的。

换句话说,测试用例也应该遵循单一职责原则,减少内部的依赖

it("read函数读取正常文件后应该调用parse函数", function() {
    // 模拟文件数据和parseStockFile函数返回数据
    let rawData = `GOOG\nORCL\nMSFT`
    let parseData = ["GOOG", "ORCL", 'MSFT'];

    // 实现parseStockFile和processStockTickets,fs.readFile的测试替身
    sandbox.stub(app,'parseStockFile')
        .withArgs(rawData)
        .returns(parseData)

    sandbox.stub(app, "processStockTickets").callsFake(function(data){
        assert(data = parseData)
    })
    sandbox.stub(fs,'readFile').callsFake(function(fileName, callback){
        callback(null, rawData)
    })

    app.readStockFile('tickers.txt')
});

parseStockFile

这个函数用来解析文本内容并输出一个外部数据,整个函数没有外部依赖,且是同步实现的,因此只需要进行正向、反向测试,测试函数的输出和异常处理即可

getStockPrice

这个函数传入一个股票数组,并依次调用getStockPrice,由于不存在输出值,因此只需要进行交互测试即可

getStockPrice

由于改函数依赖于网络请求接口,通过依赖注入的方式,然后使用stub来处理接口中的依赖,这里的处理方式是将http作为对应的一个属性app.http进行挂载的

it('getStockPrice应该使用axios调用合法的url', function(done){
    let ticket = "GOOD";
    let httpStub = sandbox.stub(app.http, 'get').callsFake(function(url){
        assert(url === "http://www.mock_request.com/api/getPrice?s=GOOD");
        done()
        return Promise.resolve(true)
    })
    let res = app.getStockPrice(ticket);
})
getStockPrice(ticket) {
    let url = "http://www.mock_request.com/api/getPrice?s=" + ticket;
    return this.http.get(url).then((res)=>{
        this.processResponse(res)
    }).catch(err=>{
        this.processError(res)
    });
},

getStockPrice函数中,对请求成功和失败的处理都封装成了单独的方法,这样做的好处是可以很方便地编写测试,通过模块,我们可以直接进行stub生成测试替身,而不必关注其具体实现,且这样跟符合单一职责原则。

一定要注意,我们不需要在测试用例中去发送真正的网络请求。

其他

其他包括显示函数、成功回调、错误回调等测试,这里不再一一列举。

5.3. 小结

编写测试是一项运用良好设计原则的工作,最终的结果就是简洁、模块化、易维护的代码

  • 为了单一职责功能,函数必须简洁
  • 为了编写测试用例,需要实现模块化
  • 覆盖率高的测试代码保证了代码的维护性

6. 总结

到目前为止,才看了书的前半部分,主要了解到单元测试中的一些概念和工具,最后通过一个练习项目,对接口进行单元测试,并根据测试实现和优化接口。其中关于sinon的一些使用方法,还有些生疏,需要多加练习。

本书的后半部分讲述的是如何在生产环境中使用测试驱动开发,包括后台Express、前端DOM和jQuery、AngularJS等的单元测试,接下来再进行整理,用作练习。

学会测试是成为一个合格开发者的必备之路,千万不能有“接口我直接实现只需要两分钟,我为什么要花10分钟来写测试用例”的念头,殊不知,这个10分钟可以再后面为我们节省多少个两分钟呢?