首页 » Vue2实践揭秘 » Vue2实践揭秘全文在线阅读

《Vue2实践揭秘》 第5章 Vue的测试与调试技术

关灯直达底部

我从事软件开发10多年来一直倡导敏捷开发,从测试驱动开发到行为驱动式开发,我始终都是一名践行者。很多开发者都认为测试对于项目来说是可有可无的,甚至不将测试工作纳入到开发进度中。

其实,测试是一个非常美妙的世界,一旦进入根本停不下来!

对于Vue这一类重度采用前端技术的项目而言,测试更加是一个开发的加速器。代码写完了我们要确认是否符合要求,始终得一边运行一边测试或者调试。一个功能点可能需要调试十几次甚至上百次,每次都做相同的人工操作,再有耐心的人都会随便填写一些测试数据,在逻辑上跑通就算过关了。这也导致软件发布后,在生产环境中出现了各种“不可预知”的问题。而最坏的情况是,如果手工重现这些问题并且修复后,遇到项目迭代要重新检测程序是否能正常运作,你还能记得这些问题的操作过程和输入的数据吗?

我经常看到不少的前端开发人员基本上都只进行人工测试,屏幕左边开一个编码窗口,右边打开一个浏览器,左边写代码右边看效果。当然这种编码场景我也用,但仅限于制作或者调试界面样式和页面布局的时候,而不是从开始到最终都这样做!支持“热加载”是让我喜欢上Vue开发的其中一个原因,因为有了这个功能,无论是编写样式又或者需要用视觉检查功能时都将无比高效。我更愿意让程序帮我检查代码是否正确,虽然程序是自己写的,但人是会出错的,何况只是用视觉判断程序的正确性,出错几乎是无可避免的。其次,我们总会遇到这样一种情况,一个项目完成上线后就去忙其他项目了,突然某天收到一个紧急的命令要在原来那个程序上加点功能或者修改一个小bug,如果没有测试,谁遇到这种情况都心慌,甚至连改程序的勇气都没有,谁说得准程序一改又会出现什么错误呢?而且还得凭着记忆去将以前做过的人工测试重新做一次才能安心。

测试不单单是一个质检员,它还是一个异常备忘录,甚至可以说是一个为我们保驾护航的好助手。

你曾试遇到过修改代码后,导致其他地方出现问题的时候吗?相信绝大多数程序员都遇到过。因为这几乎是不可避免的,特别是在规模庞大的代码面前,代码与代码之间可能是环环相扣的,改变一处会影响另一处。

但如果这种情况不会发生呢?如果你有一种方法能知道改变后会出现的结果呢?这无疑是极好的。因为修改代码后无须担心会破坏什么东西,从而程序出现bug的概率更低,在debug上花费的时间更少。

这就是单元测试的魅力,它能自动检测代码中的任何问题。在修改代码后进行相应的测试,若有问题,能立刻知道问题是什么,问题在哪和正确的做法是什么。这可以完全消除任何猜测!

5.1 Mocha入门

在开始介绍Vue的单元测试之前,我们需要做一些基本知识的准备,第2章介绍了vue-cli脚手架建立的单元测试骨架的组成与运行原理。仅仅了解这些内容是不够的,编写单元测试之前必须了解测试框架的用法和与之配套的编程工具的使用方法。

基本的测试骨架

单元测试文件都放在test/unit/specs目录下,这是在第2章就已经定下的工程目录使用约定,且每个测试文件要以*spec.js文件名结尾。创建test/unit/specs/array.spec.js文件,写一个对JavaScript的Array对象的基本功能的测试示例,只有实践才是快速学习的捷径。

每个测试案例文件都会遵循以下基本模式。首先,有个describe块:

    describe('Array',  => {      // 测试序列    })  

describe用于把单独的测试聚合在一起,在TDD中称之为测试序列(Suite),也可以将它看作功能分组。第一个参数用于指示测试什么,第二个参数是一个匿名函数。这里我们先来举一个最简单的列子,在本例中,由于我们打算测试Array功能,那么将以Array这个对象的名称作为测试序列的名称。

序列的嵌套

测试序列是一种分组方式,它也允许以树状结构对子序列进行嵌套,从而可以将一个大的测试序列划分为更小的组成部分。例如:

    describe('User', => {      describe('Address',=>{        // ...      })    })  
测试用例

测试序列内至少有一个it块,否则Mocha会忽略测试序列内的内容,不会执行任何的动作。例如:

    describe('Array',  => {      it('应该在初始化后长度为0',  => {        // 这里编写测试代码的实现      })    })  

it用于创建实际的测试,它在TDD中被称为测试用例(Test-Case)。其第一个参数是对该测试的描述,且该描述的语言应该是我们日常用语的句式(而非编程语言)。测试用例用it作为函数名,其用意就是希望通过程序引导开发人员用书面语言去描述测试用例的作用,如:“It should be done with ...”,或者“It should be have some value”等。直接翻译成我们中国人的句式就将变成:“应该…输出XXX结果”,或者“应该…完成XXX操作”这样的句式。

这是一种偏向于行为式的描述方式(虽然Mocha号称是支持行为式驱动的测试框架,然而其只能属于类行为式测试,而非真正的行为驱动式测试框架,关于行为式驱动开发在下文自有交待,在此暂且放下),对于单元测试来说可以将其归类为一种“一般性的通用描述”。顾名思义,单元测试的对象是某个特定的类或者模板,因此describe内才直接用类名进行描述,那么it内最应该用的描述方式是“方法名”或“属性名”。仍然以Array为例(因为它的方法属性JS程序员应该都懂):

    describe('Array',  => {      it('#slice', => {        // ...      })      it('#join', => {        // ...      })    })  

为什么用方法名或者属性名是最好的呢?因为好的程序应该是能达到自描述的,如果从名称上都看不出其用法,那么是否就可以从使用上验证这个方法或属性的命名有问题而需要重构呢?这不正是写测试的其中一种目的所在吗?

所有Mocha测试都以同样的骨架编写,虽然它还有其他的写法,但上述写法是一种推荐用法,所以我们都应该遵循这个相同的基本模式:

(1)测试序列用类名命名。

(2)当测试用例用于测试指定方法或属性默认效果时用“#+成员名”方式命名。

(3)当测试用例的测试内容不能归属于某个方法或属成员时用“应该…输出XXX或应该…完成XXX操作”的句式陈述。

在某些情况下我们希望Mocha跳过功能测试,那么我们可以使用xdescribe函数取代describe,这样Mocha将不会执行它们,而单纯将其视为“跳过”的状态,同理it也可以用xit来表达忽略执行。

这是写好单元测试的一个重点,“思路决定行为”,文字性的内容表达准确清楚才能得到正确的测试结果。文字性表述就是测试的架构设计,这也是为何要耗费这么多文字来论述这个文字性描述规则的缘由。

断言

现在我们已经知道如何组织与编写测试用例了,接下来就需要对测试的结果进行判断,我们称这一过程为“断言”(Assertion)。Mocha自身并没有配置断言库,在第2章中我们也了解到,vue-cli的webpack模板已经通过Karma为我们的测试框架配置了Sinon-Chai这个库了,它基于Sinon和Chai两个库的合成优化版本,所以我们并不需要引入其他的断言库了。当前的测试用例的上下文this内被注入了Chai,所以可以直接使用Chai提供的标准断言语法。其他的断言库还有很多,也可以按自己的喜好定制,在此就不做过多的表述,毕竟以一书之篇幅也难以尽述。

Chai断言库有两种语法,一种是expect语法,另一种是should语法,两种语法只是在表达顺序上略有不同,expect语法则更加通用,毕竟这些都是仿照“Rspec”做出来的,我们就采用正统的最佳实践。

具体的句式是expect([实际被检测值]).to语法,其表达的语法都是期待一个“实际”(向expect传递的参数)值等于一个“期待”值。

上例中测试Array的初始值应为空,即我们需要创建一个数组并确保它为空:

    describe('Array',  => {      it('应该初始化为空数组',  => {        var arr =         expect(arr).to.be.lengthOf(0)      })    })  

实际值是测试代码的结果,期待值是预想的结果。由于数组的初始值应为空,因此,在该案例中的期待值是0。

上述的to是一个陈述式的链式接口,它们只是为了让断言更加容易理解,将它们添加进断言中使句子变得啰唆但是增加了易读性,它们并不会提供任何测试功能:

● to;

● be;

● been;

● is;

● that;

● and;

● have;

● with;

● at;

● of;

● same;

● a;

● an。

紧跟在上述这些链式接口后的才是断言方法,Chai中的断言方法非常多,下文中还补充了Sinon-Chai为仿真测试而加入的其他的一些基于Sinon的断言。为了保持阅读的一致性,Chai的断言API放在了本书的“附录A——Chai断言参考”内,当你开始使用断言时它们就在那里等着你。

钩子

在功能测试内,每个测试场景运行在一个独立的进程内,测试之间是不应该存在依赖关系的。但是经常会出现这样的情况,多个场景之间可能会执行同样的初始化操作,或者测试完成后的变量或数据清理工作。面对这些情况,Mocha提供了4个钩子函数在describe内进行统一的调用。

● beforeEach——在每个场景测试执行之前执行;

● afterEach——在每个场景执行完成之后执行;

● before——在所有场景执行之前执行(仅执行一次);

● after——在所有场景执行之后执行(仅执行一次)。

    describe("Array",  => {        var expectTarget =         beforeEach( => {            expectTarget.push(1)        });        afterEach( => {            expectTarget =         });        it("应该存有一个为1的整数",  => {            expect(expectTarget[0]).to.eqls(1)        });        it("可以有多个的期望值检测",  => {            expect(expectTarget[0]).to.eqls(1)            expect(true).to.eqls(true)        });    });  

例如,我们可以创建一个Vue实例,在各个测试用例中共享:

    describe("UkButton",  => {        let vm = undefined        before( => {            vm = new UkButton({propsData:{              color:'primary'            }}).$mount        })        after( => {            vm.destroy        })        it("设置Button的颜色",  => {            expect(vm.$el.getAttribute('class')).to.eqls('uk-buttonuk-button-primary')            vm.componentOptions.propsData.color = 'success'        })        it("Button的颜色应该被改成了success",  => {            expect(vm.$el.getAttribute('class')).to.eqls('uk-buttonuk-button-success')        })    })  
异步测试

Vue代码中会在很多情况下出现异步调用,例如上传、API调用、加载一个外部资源等,对这类代码进行测试时就需要异步测试用例的支持。Mocha的异步测试用法与Jasmine一模一样,就是在it内加入一个done函数,在所有的断言执行完成后调用done就可以释放测试用例并告知Mocha测试的执行结果。

例如,我们有一个User对象,这个对象具有一个save方法,这个方法通过AJAX将数据保存到服务端。如果服务端没有返回错误,那么我们就认为这个save方法是成功的,具体的代码如下所示。

    describe('User',  => {      describe('#save',  => {        it('应该成功保存到服务端且不会返回任何错误信息', done => {          const user = new User('Luna')          user.save(err => {            if (err) done(err) // 如果返回错误码直接将错误码输出至控制台            else done          })        })      })    })  

将上述代码写得更简洁一点,可以将done作为回调参数使用:

    describe('User',  => {      describe('#save',  => {        it('应该成功保存到服务端且不会返回任何错误信息', done => {          const user = new User('Luna')          user.save(done)        })      })    })  

使用Promises的另一种替代方案就是将Chai断言作为it的返回值,将Chai断言作为一个Promises对象返回让Mocha进行链式处理,但要实现这样的效果,你需要一个叫chai-as-promised的库支持(https://www.npmjs.com/package/chai-as-promised)。在Mocha v3.0.0以后的版本中,如果直接构造ES6上的Promise对象则会被Mocha认为是非法的:

       it('应该完成此测试', (done) => {         return new Promise(resolve => {          assert.ok(true);          resolve        })          .then(done)      })  

这样做的结果将得到如下的异常信息:"Resolution method is overspecified. Specify a callback orreturn a Promise; not both.. In versions older than v3.0.0, the call to done is effectively ignored."。

待定的测试

“待定测试”是实际开发过程中用得很多的一种方式,测试驱动的开发简单来说就是:编写失败的测试→实现代码→使测试通过→重构。然而,在实现过程中你会发现很难分清楚哪些“失败”的测试是要实现的,哪些是因为代码有问题无法通过测试而要重构的。为了将它们区分开来,我们可以将第一步中的“编写失败的测试”调整为“编写待定的测试”,这样就能很清楚地将它们区分开来。

在Mocha中只要我们不向it函数传入实现测试的函数(第二个参数),Mocha就会默认这个测试是待定的。

    describe('Array', => {      describe('#indexOf', => {        // pending test below        it('should return -1 when the value is not present');      })    })  

在输出时待定的测试显示的字体颜色是黄色的(失败是红色,通过是绿色),这样我们一眼就能看出哪些测试的功能是要去实现的了。

重试

对于端到端测试,由于要使用Selenium向外部或者其他服务发起请求,而且Selenium的运行性能并不高,很可能导致我们的测试由于超时或者运行过快而出现我们并不希望看到的失败结果。Mocha提供了一个retries的方法,在指定的次数内如果出现失败,Mocha并不会直接报告测试错误,而是在指定次数内重新尝试运行测试直至运行成功为止:

    describe('重试',  => {      // 指定最大的重试次数为4次      this.retries(4)      beforeEach(=>{        browser.get('http://www.yahoo.com')      })      it('应该在第三次重试后成功',  => {        this.retries(2)        expect($('.foo').isDisplayed).to.eventually.be.true      })    })  

5.2 组件的单元测试方法

这是一项在Vue开发中必须掌握的技能,掌握了组件的单元测试就能独立地运行一个组件,并且测试你编写的所有的方法、属性和事件是否与你的设计相符。而且这些是自动化运行的,运行一个指令就能知道所有被测组件是否正常。如果没有组件单元测试而只是在页面运行时观察组件是否正常,一旦出现错误就很难判断这些错误是由页面引发的还是组件本身所引起的。“保障一台汽车的生产质量得从一颗螺丝钉开始。”

如何对 Vue 组件进行测试

假设我们有以下的一个组件:

    // ~/src/components/my-component.js    export default {        template: '<span>{{msg}}</span>',        props: ['msg'],        created  => {            console.log("Created");        }    }  

问题

我们应如何测试my-component在页面中的实际运行效果?

分析

(1)只运行<my-component>组件,在它通过测试前不需要放到真正的页面上运行。

(2)只需要检测这个组件最终输出的HTML内容就可判定是否通过测试。

    <my-component msg='你好'></my-component>  

正确输出的HTML应为:

    <span>你好</span>  

这就是我们对这个组件的最基本测试需求,先来建立一个单元测试程序的基本结构:

    // test/unit/spec/my-component.spec.js    import Vue from 'vue/dist/vue'    import MyComponent from 'components/my-component'    describe('my-component',  => {        it('$mount',  => {          // 此处填写测试代码        })    })  

注意:Karma配置了只加载具有*.spec.js后缀的文件,所以我们的测试文件都必须以*.spec.js结尾,否则会被Karma忽略。

接下来通过Vue.extend方法构建一个继承至VComponent的测试容器组件,在template属性中直接写<my-component>在Vue组件内的真实用法,然后实例化这个容器组件,最后从$el变量中获取Vue最终生成的内容,具体代码如下:

    const expectedMsg = '你好'    // 构造测试容器组件    const HtmlContainer = Vue.extend({        data  {            return {              text:expectedMsg            }        },        template:`<my-component :msg="text"></my-component>`,    })    const vm = new HtmlContainer    epxect(vm.$el.querySelector('span').textContent).to.be.eq(expectedMsg)  

运行这个测试:

    $ npm run unit  

输出结果如下图所示。

在项目代码中我们要追求代码的简洁,其实写测试更需要严格地遵守这一原则,我们应该不惜一切地用更少的代码来完成同样的事情。用这种眼光来看,上述代码就显得有点冗余了。对于这种只读型的组件我们其实可以写得更加简单,根本不需要测试容器,直接构造MyComponent实例就够了:

    const vm = new MyComponent({        propsData : {            msg: expectedMsg        }    })    expect(vm.$el.textContent).to.be.eq(expectedMsg)  

这样是不是更直接?这里需要注意的是,Vue组件用props定义公共属性,但实例化时传入的却是propsData,如果你不仔细地阅读Vue的官方API,很可能就会错用这个构造函数了(https://vuejs.org/v2/api/#propsData)。

在测试时通过程序直接构造Vue实例,引用Vue实例时一定要引用/vue/dist/vue.js而不能采用vue.common.js,否则会出现“You are using the runtime-only build of Vue where the template option is not available. Either pre-compile the templates into render functions, or use the compiler-included build. (found in root instance)”的警告提示。

创建帮助方法

我们在很多的单元测试中都需要手工创建一个Vue的实例入口作为测试容器,这种代码非常冗余,因此我们可以创建一个帮助方法来去除这种重复性。

    import Vue from 'vue'    export const getVM = (render, components) => {      return new Vue({        el: document.createElement('p'),        render,        components      }).$mount    }  

有了这个getVM的帮助方法,我们就可以在单元测试中直接写入渲染的调用逻辑和使用的依赖组件,这样一来就能在很大程度上减轻单元测试的代码量:

    import {getVM} from '../helper'    import Hello from 'src/Hello.vue'    describe("Render",=>{        it("#mount",=> {            const vm = getVM(<hello>            </hello>)            expect(vm.$el.textContent).to.eq('Hello')        })    })  

5.3 单元测试中的仿真技术

仿真技术就是用代码工具模拟出实际运行环境中存在的支撑服务,最常用的就是后端服务仿真,在完全没有运行任何后端的情况下“伪造”出一个给测试专用的后端。

那为什么要在测试中运用仿真技术呢?

我们都知道一个完整的Web程序必然由后端与前端组成,采用Vue这种富前端框架我们可以将前后端的开发独立进行。在这种开发模式下,前后端的开发进度未必是对称的,那就有可能出现前端的某个组件开发完成,它所依赖的后端API可能还没有开发出来,此时前端程序该如何测试呢?还有另一种情况就是后端服务已经存在了并且已投入实际生产运行中,此时的前端开发可能只是一种应用扩展或者升级,一旦需要运行前端进行测试,有可能会向后端发送一些对实际运行毫无用处的数据,甚至会导致数据的混乱。这些情况下测试前端程序就一定要与后端程序脱离,让前端从开发到测试的整个过程完全独立,摆脱所有的外部依赖,此时就需要用仿真技术去模拟这些必要的支持服务了。

为JS世界提供仿真技术的最优秀的库就离不开Sinon,Sinon(http://sinonjs.org)提供了一系列的代码工具帮助你很容易地创建“测试替身”来消除复杂性,像它名字暗示的一样,测试替身用来替换测试中的部分代码。简单来说,Sinon允许你把代码中难以被测试的部分替换为更容易测试的内容。测试一段代码时,你不希望它被测试以外的部分影响。如果有任何外部因素可以影响测试,那么这个测试就会变得更复杂并且很容易失败。

如果你想测试一段发送AJAX的代码,该怎么做呢?你需要运行一个服务器并且确保它返回了测试需要的数据。这种方式导致准备测试环境变得很复杂,同时也给编写和执行单元测试带来很大的不便。如果你的代码依赖于时间(例如重复和超时)又会怎么样呢?假设一段代码在执行操作之前要等待1秒钟,该怎么办?你可能会通过setTimeout来把测试代码的执行延迟1秒钟,但这样会导致测试变慢。如果这个等待间隔比1秒钟更长呢?比如5分钟。我猜你一定不想在每次执行测试代码前都等上5分钟。

通过使用Sinon,我们可以解决以上这些(还有很多其他的)问题,并降低测试的复杂性。

Sinon有很多功能,但是大部分都是建立在它自身之上的。在掌握了一部分之后,就自然而然地了解下一部分。因此当学习了Sinon的基础知识并了解各个组件的功能之后,使用Sinon就会变得很容易。

为了让关于这些被调用函数的讨论变得更简单,我会称它们为依赖。我们要测试的方法依赖于另一个方法的返回值。

可以说,使用Sinon的基本模式就是使用测试替身替换掉不确定的依赖,例如:

● 当测试AJAX时,把XMLHttpRequest替换为一个模拟发送AJAX请求的测试替身。

● 当测试定时器时,把setTimeout替换为一个伪定时器。

● 当测试数据库访问时,把mongodb.findOne替换为一个可以立即返回伪数据的测试替身。

由于JavaScript是非常灵活的,我们可以把任何方法替换成其他内容。测试替身只不过是把这个想法更进一步罢了。使用Sinon,我们可以把任何JavaScript函数替换成一个测试替身。通过配置,测试替身可以完成各种各样的任务来让测试复杂代码变得简单。

Sinon将测试替身分为3种类型:

● Spies——可以模拟一个函数的实现,检测函数调用的信息。

● Stubs——与Spies类似,但是会完全替换目标函数。这使得一个被stubbed的函数可以执行任何你想要的操作,例如抛出一个异常,返回某个特定值等。

● Mocks——通过组合Spies和Stubs,使替换一个完整对象更容易。

此外,Sinon还提供了其他的辅助方法:

● Fake timers——可以用来穿越时间,例如触发一个setTimeout;

● Fake XMLHttpRequest and server——用来伪造AJAX请求和响应。

有了这些功能,Sinon就可以解决外部依赖在测试时带来的难题。如果掌握了有效利用Sinon的技巧,你就不再需要任何其他工具了。

揭秘 Sinon

Sinon功能强大,可能看上去很难理解它是如何工作的。为了更好地理解Sinon的工作原理,让我们看一些和它工作原理有关的例子。这将有利于我们更好地理解Sinon究竟做了哪些工作并在不同的场景中更好地利用它。

我们也可以手工创建spy、stub或是mock。使用Sinon的原因在于它使得这个过程更简单了——手工创建通常比较复杂。不过为了理解Sinon,还是让我们看看如何进行手工创建。

首先,spy在本质上就是一个函数包装器:

    // 一个简单的spy辅助函数    function createSpy(targetFunc) {        const spy =  => {          spy.args = arguments          spy.returnValue = targetFunc.apply(this, arguments)          return spy.returnValue        };        return spy    }    // 基于一个函数创建spy    const sum =(a, b) => a + b    const spiedSum = createSpy(sum)    spiedSum(10, 5)    console.log(spiedSum.args) // 输出: [10, 5]    console.log(spiedSum.returnValue) // 输出: 15  

使用一个像这样的方法可以很容易地创建spy。但是要明白,Sinon的spy提供了包括断言在内的丰富得多的功能,这使得使用Sinon相当容易。

那么Stub呢?

要创建一个简单的stub,只需把一个函数替换成另一个:

    const stub =  => { }    const original = thing.otherFunction    thing.otherFunction = stub    // 现在开始,所有对thing.otherFunction的调用都会被stub的调用所取代  

但同样需要指出的是,Sinon的stub有若干优势:

● 包含了spy的所有功能;

● 可以使用stub.restore轻松地恢复原始函数;

● 可以针对Sinon stub使用断言。

Mock只不过是把spy和stub组合在一起,使得可以灵活地使用它们的功能。

虽然Sinon某些时候看起来使用了很多“魔法”,但大多数情况下,你都可以使用自己的代码实现相同的功能。与自己开发一个库比起来,使用Sinon只不过是更方便罢了。

5.3.1 调用侦测(Spies)

Spies是Sinon中最简单的功能,其他功能都是建立在它之上的。Spies的主要用途是收集函数调用的信息,也可以用它来验证诸如某个函数是否被调用过。

    const handler = sinon.spy    // 我们可以像调用函数一样调用一个spy    handler('Hello', 'World')    // 现在我们可以获取关于这次调用的信息    console.log(handler.firstCall.args);    // 输出: ['Hello', 'World']  

sinon.spy返回一个spy对象。该对象不仅可以像函数一样被调用,还可以收集每次被调用时的信息。在上边的例子中,firstCall属性包含了关于第一次调用的信息,比如firstCall.args包含了这次调用传递的参数。

虽然可以像上例中一样利用sinon.spy创建一个匿名spy,但更常见的做法是把一个现有函数替换成一个spy。

    let user = {      // ...      setName (name) {        this.name = name      }    }    // 用setNameSpy替换掉原有的setName方法    const setNameSpy = sinon.spy(user, 'setName')    // 现在开始,每次调用这个方法时,相关信息都会被记录下来    user.setName('Darth Vader')    // 通过spy对象可以查看这些记录的信息    console.log(setNameSpy.callCount)    // 输出: 1    // 重要的最后一步,移除spy    setNameSpy.restore  

把一个现有函数替换成一个spy与前一个例子相比并没有什么特殊之处,除了一个关键步骤:当spy使用完成后,切记把它恢复成原始函数,就像上边例子中最后一步那样。如果不这样做,你的测试可能会出现不可预知的结果。

在实践中,你可能不会经常用到spy,往往更多地用到stub。但需要验证某个函数是否被调用过时,spy还是很方便的:

    const myFunction = (condition, callback) => {      if (condition) {        callback      }    }    describe('myFunction',  => {      it('should call the callback function',  => {        let callback = sinon.spy        myFunction(true, callback)        assert(callback,calledOnce)      });    });  

5.3.2 Sinon的断言扩展

在继续讨论stubs之前,让我们快速看一看Sinon的断言,使用spies(和stubs)的大多数环境中,需要通过某种方式来验证结果。

可以使用任何类型的断言来验证。在上一个关于callback的例子中,我们使用了Chai提供的assert方法来验证值是否为真。

    assert(callback.calledOnce)  

这种断言方式的问题在于测试失败时的错误信息不够明确。我们只会得到一条类似“false不是true”这样的信息。你可能已经想到了,这样的信息对于确定测试为何会失败并没有什么帮助,我们还是不得不查看测试代码来找到哪里出错了。这可不好玩。

为了解决这个问题,我们可以在断言中加入一条自定义的错误信息。

    assert(callback.calledOnce, '回调函数尚没有被调用')  

但是为何不用Sinon提供的断言呢?

    describe('myFunction',  => {      it('应该调用回调函数',  => {        const callback = sinon.spy        myFunction(true, callback)        expect(callback).to.have.been.calledOnce      });    });  

像这样使用Sinon的断言可以为我们提供一种更加友好的错误信息。当你需要验证更复杂的情况,比如某个函数的调用参数时,这将变得非常有用。

以下是另外一些Sinon提供的实用断言:

● sinon.assert.calledWith可以用来验证某个函数被调用时是否传入了特定的参数(这很可能是我最常用的了);

● sinon.assert.callOrder用来验证函数是否按照一定顺序被调用。

和spies一样,Sinon的断言文档列出了所有可用的选项。如果你习惯使用Chai,那么有一个sinon-chai插件可供选择,它可以让你通过Chai的expect和should接口使用Sinon的断言。

断 言说 明spy.should.have.been.called应该被调用spy.should.have.callCount(n)应该被调用n次spy.should.have.been.calledOnce应该被调用一次spy.should.have.been.calledTwice应该被调用二次spy.should.have.been.calledThrice应该被调用三次spy1.should.have.been.calledBefore(spy2)应该在spy2调用前先被调用spy1.should.have.been.calledAfter(spy2)应该在spy2调用后被调用spy.should.have.been.calledWithNew如果spy被new运算符调用则返回truespy.should.always.have.been.calledWithNewspy应该总被构造函数调用spy.should.have.been.calledOn(context)spy至少采用this关键字被调用一次spy.should.always.have.been.calledOn(context)spy总是被作为this关键字引用调用spy.should.have.been.calledWith(...args)函数调用时应该带有指定的参数args(一个或多个)spy.should.always.have.been.calledWith(...args)函数调用时应该总是带有指定的参数args(一个或多个)spy.should.have.been.calledWithExactly(...args)函数调用时应该明确地指定args参数(一个或多个)spy.should.always.have.been.calledWithExactly(...args)函数调用时应该总是明确地指定args参数(一个或多个)spy.should.have.been.calledWithMatch(...args)调用的函数参数必须与指定的args参数匹配spy.should.always.have.been.calledWithMatch(...args)调用的函数参数必须总是与指定的args参数匹配spy.should.have.returned(returnVal)应该返回returnVal的值spy.should.have.always.returned(returnVal)应该总是返回returnVal的值spy.should.have.thrown(errorObjOrErrorTypeStringOrNothing)应该抛出指定类型的异常spy.should.have.always.thrown(errorObjOrErrorTypeStringOrNothing)应该总是抛出指定类型异常

如果用expect式的语法则是用expect(value)取代spy.should,例如:

    const = hello(name, cb) => {        cb(`hello ${name}`)    }    describe("hello",  => {        it('应该在回调后输出问候信息',  => {            const cb = sinon.spy            hello('world', cb)            expect(cb).to.have.been.calledWith('hello world')            // 或者用Chai的should语法            cb.should.have.been.calledWith('hello world')        });    });  

5.3.3 存根(stub)

由于其灵活和方便,stubs成为了Sinon中最常用的测试替身类型。它拥有spies提供的所有功能,区别在于它会完全替换掉目标函数,而不只是记录函数的调用信息。换句话说,当使用spy时,原函数还会继续执行,但使用stub时就不会。

这使得stubs非常适用于以下场景:

● 替换掉那些使测试变慢或是难以测试的外部调用;

● 根据函数返回值来触发不同的代码执行路径;

● 测试异常情况,例如代码抛出了一个异常。

我们可以用类似创建spies的方法创建stubs:

    const stub = sinon.stub    stub('hello')    console.log(stub.firstCall.args)    // 输出: ['hello']  

我们可以创建匿名stubs,和使用spies时一样,但只有当你用stubs替换一个现有函数时它才开始真正地发挥作用。

举例来说,如果有一段代码使用了vue-resource的$http功能,那这段代码就会很难被测试。这段代码会向某台服务器发送请求,你不得不保证测试期间该服务器的可用性。或者你可能会想到在代码里增加一段特殊逻辑以便在测试环境下不会真正地发送请求——这可犯了大忌。在绝大多数情况下你应该保证代码中不会出现针对测试环境的特殊逻辑。

我们可以通过Sinon把$http功能替换为一个stub,而不是寻求其他糟糕的实现方式。这会使得测试变得很简单。

以下是一个我们要测试的函数。它接受一个对象作为参数,并通过$http把该对象发送给某个预定的URL。

    export default {      methods: {        saveUser (user) {         this.$http.post('/users', {            first: user.firstname,            last: user.lastname          })        }      }    }  

通常情况下,由于涉及AJAX调用和某个特定的URL,对它进行测试是比较困难的。但如果我们使用了stub,这就变得很简单。

比方说我们要确保传给saveUser的回调函数在请求结束后被正确执行。

    describe('saveUser',  => {      it('应该在保存成功后进行回调',  => {        // stub $.post,这样就不用真正地发送请求        const post = sinon.stub($, 'post')        post.yields        // 针对回调函数使用一个spy        const callback = sinon.spy        saveUser({            firstname: 'Han',            lastname: 'Solo'        }, callback)        post.restore        expect(callback).to.have.been.calledOnce      })    })  

这里我们把AJAX方法替换成了一个stub。这意味着代码里并不会真的发出请求,因此也就不需要相应的服务器了,这样我们就对测试代码里的逻辑取得了完全控制。

由于我们要确保传给saveUser的回调函数被执行了,我们指示stub要设置为yield。这意味着stub会自动执行作为参数传入的第一个函数。这就模拟了$http.post的行为——请求一旦完成就执行回调函数。

除了stub,我们还在测试中创建了一个spy。也可以使用一个普通函数作为回调,但是使用了spy后利用Sinon提供的sinon.assert.calledOnce断言可以很容易地验证结果。

在使用stub的大多数情况下,可以遵循以下模式:

(1)找到导致问题的函数,比如$http.post。

(2)观察它是如何工作的以便在测试中模拟它的行为。

(3)创建一个stub。

(4)配置stub以便按照期望的方式工作。

Stub不必模拟目标对象的所有行为。只要模拟在测试中用到的行为就够了,其他的都可以忽略。

Stub的另一个常见使用场景是验证某个函数被调用时是否传入了正确的参数。

例如,针对AJAX的功能,我们想验证发送的数据是否正确:

    describe('saveUser',  => {      it('应该向指定的URL发送正确的参数',  => {        // 像之前一样为$.post设置stub        const post = sinon.stub(vm.$http, 'post');        // 创建变量,保存我们期望看到的结果        const expectedUrl = '/users'        const expectedParams = {          first: 'Expected first name',          last: 'Expected last name'        }        // 创建将要作为参数的数据        const user = {          firstname: expectedParams.first,          lastname: expectedParams.last        }        saveUser(user, =>{} )        post.restore        sinon.assert.calledWith(post, expectedUrl, expectedParams)      })    })  

同样,我们又为$http.post创建了一个stub,但这次我们没有设置它为yield。这是因为此次的测试我们并不关心回调函数,因此设置yield就没有意义了。

我们创建了一些变量用来保存期望得到的数据——URL和参数。创建这样的变量是一种不错的做法,因为这样就可以很容易看出这个测试要测哪些数据。我们还可以利用这些值创建user变量,从而避免重复输入。

这次我们使用了sinon.assert.calledWith断言。我们把stub作为第一个参数传入,因为我们要验证这个stub被调用时是否传入了正确的参数。

5.3.4 接口仿真(Mocks)

Mocks是使用stub的另一种途径。如果你曾经听过“mock对象”这种说法,这其实是一码事——Sinon的mock可以用来替换整个对象以改变其行为,就像函数stub一样。

基本上只有需要针对一个对象的多个方法进行stub时才需要使用mock。如果只需要替换一个方法,使用stub更简单。

使用mock时要很小心。由于mock强大的功能,它很容易导致你的测试过于具体——测试了太多、太细节的内容——这很容易在不经意间导致你的测试变得脆弱。

与spy和stub不同的是,mock有内置的断言。你需要预先定义好mock对象期望的行为并在测试结束前执行验证函数。

比方说我们代码中使用了store.js来向localStorage中写入数据,我们希望测试一个与这部分内容相关的函数。我们可以使用一个mock来协助测试:

    describe('incrementStoredData',  => {      it('应该将存储值递增1',  => {        const storeMock = sinon.mock(store)        storeMock.expects('get').withArgs('data').returns(0)        storeMock.expects('set').once.withArgs('data', 1)        incrementStoredData        storeMock.restore        storeMock.verify      });    });  

使用mock时,我们使用链式调用的方式定义一系列方法以及相应的返回值。除了预先定义好行为并在测试结束前调用storeMock.verify来验证结果,这和使用断言验证测试结果没什么两样。

在Sinon的mock对象术语中,执行mock.expects('something')创建了一个预期。例如,函数mock.something期望被调用。每一个预期除了mock特殊的功能,还支持spy和stub的功能。

你可能会发现大多数时候使用stub比使用mock简单得多,这很正常。mock应该被小心地使用。

最佳实践:使用sinon.test

无论何时使用spy、stub还是mock,都有一条重要的最佳实践需要牢记。

如果你使用测试替身替换了一个现有函数,记得使用sinon.test。

在前面的示例中,我们使用了stub.restore或mock.restore来执行清理操作。这个操作是必要的,否则测试替身会一直存在并给其他测试带来负面影响或是导致错误。

但是直接使用restore方法是有问题的。有可能在restore执行之前测试代码就因为错误提前结束执行了。

有两种方法可以解决这个问题:把所有的代码放在一个try...catch块中,这样就可以在finally块中执行restore而不用担心测试代码是否报错。

还有一种更好的方式,就是把测试代码包裹在sinon.test中:

    it('应该用存根做点什么', sinon.test( => {      const stub = this.stub(vm.$http, 'post')      doSomething      sinon.assert.calledOnce(stub)    })    )  

在上边的示例中,需要注意的是,传递给it的第二个参数被包装在sinon.test中。另一点要注意的是我们使用的是this.stub而不是sinon.stub。

把测试代码包装在sinon.test中后,我们就可以使用Sinon的沙盒特性了。它允许我们通过this.spy、this.stub和this.mock来创建spy、stub和mock。任何使用沙盒特性创建的测试替身都会被自动清理。

注意上边的例子中没有stub.restore操作——因为在沙盒特性下的测试里它变得不必要了。

如果在所有地方都使用了sinon.test,那么就可以避免由于某个测试未能清理它内部的测试替身而导致后续测试随机失败的情况。

5.3.5 后端服务仿真

Sinon将这种技术称为Faker(骗子),如果直接翻译过来有点贬义,我更喜欢将之称为仿真。

前置仿真也就是请求仿真,这个过程并不会真正地产生XMLHttpRequest对象,因为这个对象会被Sinon产生的FakeXMLHttpRequest所取代。

    describe("Home",  => {        before  {            this.xhr = sinon.useFakeXMLHttpRequest            const requests = this.requests =             this.xhr.onCreate = xhr => {                requests.push(xhr)            }        },        after  {            this.xhr.restore        }        it("应该从服务器中图书数据",  => {            const callback = sinon.spy            expect(this.requests).to.lengthOf(1)            this.requests[0].respond(200, { "Content-Type": "application/json" },                                 '[{ "id": 12, "title": "Vue2实践揭秘" }]')            expect(callback).to.be.calledWith([{ id: 12, title: "Vue2实践揭秘" }])        })    })  

前置仿真的检测标志在于对请求内容的正确性的检测。

服务端仿真也就是后置仿真,不管前端发出什么样的请求,我们只仿真后端接收请求的处理。

后置仿真与前置仿真最大的不同之处是它完全让前端产生一个真实的XMLHTTPRequest对象,在这个对象真正向服务端发出之前进行截获,然后进行结果仿真并返回。

    describe('Comments',=>{        before  => {            this.server = sinon.fakeServer.create        },        after  => {            this.server.restore        },        it("应该从服务器中获取评论" ,  => {            // 模拟服务器的最终输出效果            this.server.respondWith("GET", "/some/article/comments.json",                [200, { "Content-Type": "application/json" },                 '[{ "id": 12, "comment": "Hey there" }]'])            const callback = sinon.spy            myLib.getCommentsFor("/some/article", callback)            this.server.respond            sinon.assert.calledWith(callback, [{ id: 12, comment: "Hey there" }])        })    })  

5.4 调试

对于调试相信每一位程序员都不会陌生,作为前端开发者浏览器的调试窗口就更是不可或缺了。

Vue-DevTools

Vue-DevTools是官方提供的实时调试工具,它是一个Chrome的应用插件,可嵌入到Chrome的调试器内使用。你需要在Chrome网上应用商店内安装vue-devtools(https://chrome. google.com/webstore/search/vue-devtools?utm_source=chrome-ntp-icon):

只要当前打开的网页有Vue实例,这个Chrome插件就会自动解释实例结构以及Vue实例内的变量,以便我们观测实例运行的情况。

Vue-DevTools对于初学者来说是一个很不错的选择,可以很好地辅助理解Vue的运行原理。

运行期调试

另一个更实用的选择是设置调试的断点,要知道Vue是被webpack进行实时编译后加载到浏览器的,如果在浏览器内的调试窗口中直接设置断点,经常会由于代码刷新后Sourcemap的定位发生变化或者hash发生了改变而导致断点无法成功启动。

别忘了vue-cli webpack模板对我们构建的Vue环境可是有一个极为好用的功能的——“热加载”。当启动npm run dev后,所有代码的改变会自动地进行局部的刷新与载入,而不需要人工刷新浏览器。我们可以利用这一功能在源代码内直接设置断点,在热加载运行重新载入代码后直接在浏览器中启动断点。这是一个通用方案,在Vue或React中都可以执行,那就是ES的debugger关键字。

它就相当于一个编码式的断点设置,只要ES解释器一遇到debugger关键字就会自动启用当前宿主环境中的调试断点功能,打断程序的运行。

如下图所示,一旦在左侧的代码窗口内加入debugger,然后按Ctrl+S保存,右侧的浏览器运行窗口就会自动载入断点并跳转到设置断点的代码上,这是一个极为方便也是常用的开发功能。

Debugger在Chrome的效果

测试期与 IDE 的集成调试

在浏览器中调试很明显只适用于对界面的精细调整,或者更准确地说是一种手工模式。但对于我们采用Karma测试加载器来执行的全自动化测试就不太适用了。作为专业的开发人员应该使用专业的开发工具,所以JetBrains的开发工具集可谓是开发必备的IDE,我们做前端开发当然也少不了WebStrom这一强大的助手。借助WebStrom,我们摆脱了使用浏览器自带的JavaScript调试器这种传统的手工式做法,这种做法最大的缺陷是难以正确定位我们的源码,尤其是那些没有页面的组件测试!

首先将JetBrains IDE Support工具安装到Chrome中:

回到WebStrom,在主菜单中选择“Run/EditConfigurations”选项,弹出以下的配置对话框,点击左上角的+号增加一个Karma的配置,然后在Configuration file输入框中选择当前项目下的karma.conf.js,如下图所示。

然后在代码中需要添加断点的地方加入debugger关键字:

    export default {        created  {          // 断点          debugger          console.log("Created")        }    }  

然后按Ctrl+D,WebStrom就会引导Karma启动一个Chrome实例作为宿主程序,当程序执行到debugger处时就会自动调入WebStrom内,此时我们就可以用WebStrom自带的调试器进行变量的观察与单步的执行操作了。

这个方法无论是Vue1.0还是Vue2.0都可用。

5.5 Nightwatch入门

Nightwatch的开发非常容易学,具体的应用我会在下一章中详细地嵌入到示例中。为了下文描述的方便,我们需要普及一些Nightwatch简单的背景知识。如果你已经完全掌握了Nightwatch的开发,那么可以跳过以下这一部分。

5.5.1 编写端到端测试

从代码结构上说,端到端测试与单元测试的写法是基本一致的,但在测试的设计思路上却有着巨大的差别。单元测试标注的是局部的代码,即对某个(些)类或者某个(些)具体方法的调用方式及其输出结果进行测试,必要时可以挂入调试器进行断点调试,运行测试前只需要准备某些输入数据或者变量即可。而端到端测试则是一种相对完整的外部操作模拟过程,它要借助Selemium服务器和WebDriver,通过代码模拟用户的界面操作,然后检测界面元素应该出现的变化,要确保测试的正常运行就需要模拟整个程序运行时所有需要配置的数据或者参数。

简言之,端到端测试侧重于检测界面的交互效果与操作逻辑是否正确。

在第2章工程化JS开发中已经介绍过如何将Nightwatch的默认测试运行器配置为Mocha,这样一来端到端测试的结构就可以与单元测试的写法一致,以下就为第1章中的待办事项示例来写一个简单的端到端测试:

    describe('TODO界面测试', => {        it('应该正确TODO界面',(client,done) => {            client.url('http://localhost:8080')                 .waitForElementVisible('body', 1000)                 .assert.elementPresent('input[type="text"]')                 .getAttribute('input[type="text"]','placeholder',result => {                   this.assert.equl(result.value,'快写下您要我记住的事吧')                 })                 .end        })    })  

这个测试的作用是运行并在浏览器加载待办事项示例,加载完成后检测是否具有一个带有“快写下您要我记住的事吧”提示的文本输入框。端到端测试完全取代了我们用眼睛判断界面是否输出正确这一动作,也就是说,只要将所有人工检测的过程转化为端到端测试,那么我们就有了一套对业务逻辑进行全自动化检测的机制!

作为一名前端开发者,进行运行测试是最平常不过的事了,这个过程大约就是执行以下的步骤:

(1)打开浏览器查看运行界面。

(2)输入仿真数据(大多数开发者通常随便敲入一些所谓的测试数据)。

(3)用眼睛查看输出结果,判断界面是否正确。

相信没有多少开发人员是喜欢做上述这些工作的!不少软件企业雇佣一些刚入门的菜鸟们来做这种测试,企图保证程序的业务正确性,这往往是事与愿违的。只有通过端到端测试取代人们最讨厌做的重复性工作,预先输入最正确的仿真数据才是确保业务逻辑能正确运行的关键!

Nightwatch使用一个浏览器仿真对象(上述代码中的client)的url函数打开开发服务器地址,此时开发服务器会自动引导webpack进行编译输出,waitForElementVisible这个函数将等待浏览器加载完成,这个过程是相当缓慢的。为了避免等待超时可以用retry(2)函数进行保护,或者将waitForElementVisible的第二个等待参数的时长增加到5秒左右,加载完成后用Nightwatch的断言工具对目标元素的属性或者文字内容进行检验。

XPath 与 CSS 选择器

由于使用断言判断都是针对单个元素进行的,Nightwatch提供的方法的第一个参数一般都是一个选择器参数,如assert.elementPresent('input[type="text"]'),这个选择器可以是XPath选择器也可以是CSS类选择器,默认情况下采用CSS选择器作为元素定位的方法。如果要切换为XPath的方式对元素进行定位,可以先调用useXpath函数,使用useCss就可以重新切换为CSS选择器,具体做法如以下代码所示。

如果要将XPath作为默认选择器,可以在配置文件内将use_xpath设置为true。

    this.todoDemoTest = browser => {      browser        .useXpath // 使用XPath选择器        .click("//tr[@data-search]/span[text='Search Text']")        .useCss // 切换回CSS选择器        .setValue('input[type=text]', 'Vue')    }  
BDD 式断言

Nightwatch在v0.7版本后加入了BDD(行为式驱动)式的断言库,我们能采用expect语法来使用代码断言:

    describe('图书视图', => {        it('快速搜索',client=> {            client              .url('http://localhost:8080')              .pause(1000)            // 期待元素将在1秒内显示            client.expect.element('body').to.be.present.before(1000)            // 期待元素#app具有display的样式类            client.expect.element('#app').to.have.css('display')            // 期待元素具有class属性并且包含有示例文字            client.expect.element('body').to.have.attribute('class').which.contains('示例')            // 期待#searchbox元素是一个input类型的标记            client.expect.element('#searchbox').to.be.an('input')            // 期待#searchbox元素是可见的            client.expect.element('#searchbox').to.be.visible            client.end;        })    })  

expect接口提供了一种更加灵活、流畅并且更接近自然语言的方式来定义断言,比原有断言接口有着显著的改进。唯一的缺点是它不能进行链式断言,这样会产生大量的重复性代码。

5.5.2 钩子函数与异步测试

Nightwatch中可以完全兼容原有Macha语法提供的before/after和beforeEach/afterEach钩子函数。每个钩子函数都会传入一个Nightwatch的浏览器实现参数:

    describe('测试示例', => {      before(browser => {        console.log('准备...')      })      after(browser => {        console.log('清理...')      })      beforeEach(browser => {      })      afterEach( => {      })      it('第一步',browser => {        browser         // ...      })      it('第二步', browser => {        browser        // ...          .end      })    })  

在上面的例子中,方法调用的顺序如下:before→beforeEach→“第一步”→afterEach→beforeEach→“第二步”→afterEach→after。

为了增加向后兼容性,afterEach钩子内是不会传有browser实例参数的,只有异步钩子afterEach(browser,done)内才会传入该参数。

所有的钩子及测试函数都具有与Mocha一样的异步调用能力,每个函数的第二个参数done作为异步调用结束的回调函数。

进行异步调用时切记一定要调用done通知Nightwatch完成调用,否则会导致测试执行超时。

    describe('示例', => {      beforeEach((browser, done) => {        // 执行异步操作        setTimeout(=>{          // 完成异步任务          done        }, 100)      })      afterEach((browser, done) => {        // 执行异步操作        setTimeout( => {          // 完成异步任务          done        }, 200)      })    })  

默认情况下Nightwatch会将超时控制在10秒内(测试单元为2秒)。在某些情况下,这可能不足以避免超时错误,可以通过在外部全局变量中定义一个asyncHookTimeout属性(以毫秒为单位)来增加超时量。

另外,我们可以在钩子函数中向done函数传入Error对象通知Nightwatch捕获到不明确的错误:

    describe('示例', => {      afterEach((browser, done) => {        performAsync(err=>{          if (err) {            done(err)          }          // ...        })      })    })  

5.5.3 全局模块与Nightwatch的调试

我在最初使用vue-cli脚手架来创建项目时遇到一个很大的困惑,就是Nightwatch无法在Webstorm中调试!多次翻阅Nightwatch的官方文档,发现官方有一篇专门的文章讲述如何在Webstorm中启动Nightwatch的调试(https://github.com/nightwatchjs/nightwatch/wiki/Debugging-Nightwatch-tests-in-WebStorm),可惜的是按照Nightwatch提供的方法我一直无法成功开启Nightwatch的调试模式,这也意味者Nightwatch在Vue项目中变得极为鸡肋。经过反复的实验,最后我才发现了这并不是Nightwatch不能开启调试,而是vue-cli初始化的Nightwatch存在问题!

vue-cli会为我们创建一个nightwatch.conf.js和一个runner.js文件,而问题就出在runner.js文件中,打开这个文件:

    // 1. start the dev server using production config    process.env.NODE_ENV = 'testing'    var server = require('../../build/dev-server.js')    // 2. run the nightwatch test suite against it    // to run in additional browsers:    //   1. add an entry in test/e2e/nightwatch.conf.json under "test_settings"    //   2. add it to the --env flag below    // or override the environment flag, for example: `npm run e2e -- --envchrome,firefox`    // For more information on Nightwatch's config file, see    // http://nightwatchjs.org/guide#settings-file    var opts = process.argv.slice(2)    if (opts.indexOf('--config') === -1) {      opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js'])    }    if (opts.indexOf('--env') === -1) {      opts = opts.concat(['--env', 'phantom'])    }    var spawn = require('cross-spawn')    var runner = spawn('./node_modules/.bin/nightwatch', opts,{stdio:'inherit'})    runner.on('exit', function (code) {      server.close      process.exit(code)    })    runner.on('error', function (err) {      server.close      throw err    })  

会发现这个runner实际上是用子进程加载Nightwatch和启动开发服务器,以避免开发服务器启动后将线程独占。而WebStorm的调试器只能嵌入到启动的主进程中,也就是说,如果用debug模式来运行runner的话,调试器就只能进入到开发服务器dev-server.js引导的后端模拟程序内,而被编译后的Vue程序将失去调试的机会!这是问题的症结所在。

也就是说,如果要启动Nightwatch就必须用Nightwatch作为主进程来引导所有的测试文件,而不能是开发服务器。幸好,我们并不需要去独立编写一个runner来引导Nightwatch,因为Nightwatch有另一个机制让我们在钩子里去执行开发服务器的启动这一异步性的操作。

大多数时候,在globals_path的配置属性中指定一个外部文件定义全局变量会比在nightwatch.json中定义更好。你可以根据需要针对不同的测试环境定义全局变量。例如在本地运行的测试和针对远程生产服务器上运行的测试可能存在着不同的全局变量配置策略(http://nightwatchjs.org/)。

以上是我在Nightwatch上找到的一段关于“全局钩子”的解析,官网上还提供了一个示例。可能本人比较愚钝,看完他们提供的这个解释和示例源代码后还是觉得一头雾水,根本不知所云,只是凭着一个老程序员的直觉感到这可能是解决异步服务启动的一个办法。实践才是检验真理的唯一标准,动手开干!果然,这个全局钩子真的是一个可以进行异步配置的解决方案。

Nightwatch将其分为两个部分:“全局钩子”与“全局配置”,但实质上就是一个东西。Nightwatch允许声明一个全局的模块文件,通过globals_path配置项引入到nightwatch.conf.js内,这个模块文件可以重写nightwatch配置文件中的内容,最重要的是它可以提供全局性的begin、beginEach、after和afterEach这4个重要的钩子函数。这意味着在执行所有的E2E测试文件之前我们可以引导开发服务器启动,执行webpack编译这一系列的动作而不至于导致线程的死锁,因为上文已经提到过钩子函数是异步的!

另外,全局配置模块文件的存在意义有点像我们的环境配置,有了全局配置模块,我们可以根据不同的环境策略来配置不同运行环境下的配置参数。例如,在生产环境下我们就完全不需要启动开发服务,而是直接连接到生产服务器就可以了。

利用全局模块这一强大的配置功能,只需要在nightwatch.conf.js中加入以下声明:

    module.exports = {      "globals_path":"test/e2e/globalsModule.js",      "selenium": {          // ... 省略        }      },      // ... 省略    }  

然后创建一个globalsModule.js文件,并加入以下的代码:

    process.env.NODE_ENV = 'testing'    const server = require('../../build/dev-server.js')    const config = require('../../config');    module.exports = {      before: function (done) {        server.listen(config.dev.port, function (err) {          if (err) {            console.log(err);            done(err);          } else {            console.log('开发服务器启动侦听...');            done;          }        })      },      after: function (done) {        server.close;        done;      }    }  

这样Nightwatch就会引导开发服务器启动。最后还得对dev-server.js做一个小小的修改,因为dev-server.js一旦被引入就会自动启动,我们需要对此进行调整,如果是“testing”环境就只导出express服务对象的实例而不是返回express应用对象的侦听方法的返回值:

    // build/dev-server.js    // ... 省略    module.exports =process.env.NODE_ENV !== 'testing' ? app.listen(port,function (err) {      if (err) {        console.log(err)        return      }      var uri = 'http://localhost:' + port      console.log('Listening at ' + uri + '/n')      opn(uri)    }) : app  

接下来只要按照Nightwatch的官方文档在WebStorm中配置运行器就可以了,具体做法如下所示。

(1)在“Run”菜单内点击“Edit Configurations...”。

(2)创建一个Node.js的运行配置项。

(3)在“JavaScript file”内填入node_modules/nightwatch/bin/nightwatch。

(4)在“Application parameters”内填入 --config test/e2e/nightwatch.conf.js --env phantom。

(5)点击“OK”保存。

在测试文件内直接点击代码行断点(无须debugger关键字),运行“Run→Debug Nightwatch”就可以启动Nightwatch的调试模式了。

如果要在命令行运行E2E测试,可以在项目的根目录执行以下的语句:

    $ nightwatch --conifg test/e2e/nightwatch.conf.js --env phantom  

5.5.4 Page Objects模式

Page Objects模式是由软件大师Martin Fowler在2013年提出的一种专门用于端到端测试的设计模式(https://martinfowler.com/bliki/PageObject.html)。Page Objects模式是通过将Web应用程序的页面或页面片段包装成一个可实例化的对象以供端到端测试调用的一种模式。它的目的是通过Page对象将端到端测试中大量用于查找、定位元素的操作抽象并封装为一些方法,避免由于页面的变更而导致大量代码逻辑分散性地发生变化。

当我们试图测试一个Web页面时,不得不依赖页面上的元素去进行交互并确认程序应用是否正常运行。然而当你的脚本试图直接操作页面上的HTML元素时,一旦有相关UI的变更,那测试将会变得十分脆弱。Page object模式就是对HTML页面以及元素细节的封装,并对外提供应用级别的API,使你摆脱与HTML的纠缠。

——Martin Fowler

Page Objects模式已被广泛应用在各种主流的端到端测试框架内,当然包括Nightwatch。

Nightwatch对Page Objects的支持做得相当之好,可以说是与测试实例已经融为一体了!

元素(Elements)

大多数时候,我们需要定义一些元素,并且在测试中通过命令和断言与之交互。如果我们将元素定义放在一个地方,这样就有利于我们集中维护或使用元素的具体属性,特别是在较大的集成测试中,使用元素对象将大大有助于保持测试代码的整洁。需要指出的一点是,这里的元素并不是指DOM元素,这是由Nightwatch抽象出来的一个元素概念,一个Page对象内的元素可以是单个的DOM元素,也可以是由多个DOM元素复合而成的结合体。

例如,可以用XPath和CSS混合式地定义元素中的成员:

    module.exports = {      elements: {        searchBar: {          selector: 'input[type=text]'        },        submit: {          selector: '//[@name="q"]',          locateStrategy: 'xpath'        }      }    };  

或者,可以更简单地只用CSS选择器来定义:

    module.exports = {      elements: {        searchBar: 'input[type=text]'      }    };  

不错,Page对象的元素实际上就是一个选择器而已!

还可以定义一个返回DOM集合的选择器元素:

    var sharedElements = {      mailLink: 'a[href*="[email protected]"]'    };    module.exports = {      elements: [        sharedElements,        {          searchBar: 'input[type=text]'        }      ]    };  

每个页面当然要有一个URL地址:

    module.exports = {      url: 'http://localhost:8080/search',      elements: {        searchBar: {          selector: 'input[type=text]'        },        submit: {          selector: '//[@name="q"]',          locateStrategy: 'xpath'        }      }    };  

在端到端测试中就可以这样来使用它:

    describe('图书视图', => {        it('快速搜索',client => {            const searcher = client.page.searcher            searcher.navigate                .assert.title('图书视图')                .assert.visible('@searchBar')                .setValue('@searchBar','Vue')                .click('@submit')                .end        })    })  

所有的Page对象通过nightwatch.conf.js内的page_objects_path配置项指定并由Nightwatch在运行时自动加载并注入到client.page属性内,所以我们无须用import去导入它们,只要放在一个统一的文件夹内就可以了。

分段(Sections)

很多时候将一个页面定义为多个分段是非常有用的归类手法,分段主要负责两项工作:

(1)为页面划分出一个层级式的“命名空间”。

(2)从逻辑上将抽象的元素分布于一个新的树状的对象结构内。

例如:

    export default {      sections: {        menu: {          selector: '#gb',          elements: {            mail: {              selector: 'a[href="mail"]'            },            images: {              selector: 'a[href="imghp"]'            }          }        }      }    }  

在测试文件中会这样使用它:

    describe('图书视图', => {        it('快速搜索',client => {            const searcher = client.page.searcher            searcher.navigate            searcher.expect.section('@menu').to.be.visible            const menuSection = searcher.section.menu            menuSection.expect.element('@mail').to.be.visible            menuSection.expect.element('@images').to.be.visible            menuSection.click('@mail')            client.end        })    })  

需要注意的是,分段对象上的命令与断言将会返回分段对象本身,用作链式调用。有需要的话,还可以在分段内定义更小的分段用于分解复杂的页面:

    export default {      sections: {        app: {          selector: '#app',          elements: {            searchbox: {              selector: 'a[href="mail"]'            }          },          sections: {            view: {              selector: 'p.dataview',              elements: {                headers: {                  selector: 'th'                },                rows:{                  selector: 'tbody>tr'                },                newbook_button: {                  selector: 'button.uk-button'                }              }            }          }        }      }    }  

在测试文件中引用嵌套式分段对象:

    describe('图书视图', => {      it('', client => {        var books = client.page.books        books.expect.section('@app').to.be.visible        var appSection = books.section.app        var viewSection = appSection.section.view        viewSection.click('@newbook_button')        viewSection.expect.element('@rows').to.have.lengthOf(20)        viewSection.expect.element('@headers').to.have.lengthOf(5)        client.end      })    })  
命令(Commands)

Nightwatch的命令概念实质上是页面对象上的链式方法,它可以有效地封装与页面相关的逻辑行为,每个命令方法执行完成后都将返回页面对象、分段或者元素本身。

命令只是与普通的JavaScript对象定义有点差别,通过commands属性指定到页面对象内:

    const bookCommands = {      search:  => {        this.api.pause(1000)        return this.waitForElementVisible('@searchButton', 1000)                  .click('@searchButton')      }    }    module.exports = {      commands: [bookCommands],      elements: {        searchBar: {          selector: 'input[type=text]'        },        searchButton: {          selector: '#go_search'        }      }    };  

那么测试代码中的使用示例就应该为:

    describe('图书视图',=> {      it('快速搜索',(client)=> {        const books = client.page.books        books.setValue('@searchBar','Vue')          .search        client.end      })    })