今天补上上次新闻客户端欠下的单元测试。新闻客户端github地址:点我,接上篇博客。
本次单元测试用到了单元测试比较流行的测试框架mocha,用到的是expect断言库,和react官方的测试插件:react-addons-test-utils。
那本次单元测试的地址在github上另起一个分支,来区别一下两次提交。本次单元测试地址:点我
npm install && npm test 即可测试该项目
通过本次单元测试,不仅添加了测试,还发现了原先作品的一些问题,这也是函数式编程所注意的地方。
这是test文件夹的目录:
和redux官方例子的目录是一样的,我仅仅把内容改了一下。
react-redux的作品相对来说还是很好写测试的,由于redux是函数式编程的思想,关于redux的单元测试就像测试js函数一样方便。麻烦的就是react的测试,它需要模拟用户的操作,而且还需要区分虚拟dom和真实dom,在测试的时候我们会把它渲染在真实dom当中。
那么问题来了,测试环境并没有浏览器的dom环境,没有window,document这些东西咋办呢,node有个包叫jsdom,我们在测试之前,先用jsdom模拟一下浏览器的环境:
import { jsdom } from 'jsdom' global.document = jsdom('<!doctype html><html><body></body></html>') global.window = document.defaultView global.navigator = global.window.navigator
一些jsdom的api的应用,其中模拟了document,window和navigator,怎么把它添加到测试中呢?
我们再看一下测试mocha的测试命令,摘抄自package.json
"scripts": { "start": "node server.js", "test": "NODE_ENV=test mocha --recursive --compilers js:babel-core/register --require ./test/setup.js", "test:watch": "npm test -- --watch" },
首先设置NODE_ENV,关于webpack热启动,如果是winodws用户遇到NODE_ENV不是命令请看关于windows下NODE_ENV=test无效的情况解决办法
启动mocha -h可以看到--require来加载setup.js也就是jsdom模拟的环境。这句命令就是先把这些文件通过babel编译一下,再引入jsdom模拟环境。
那我们开始正式来说测试吧。
看目录可知,我们测试分4个部分,测试actions,components,containers,reducers
与redux有关的就是actions和reducers,components是测试组件是否正确调用了该方法,containers是测试组件是否正常工作。后面2个都是react-redux的东西啦,跟我们函数式的redux可没关系。
我们先把函数式的redux的相关测试写上--
一,测试actions
我们需要知道每个actionCreator是否正确了返回action,我觉得这东西用眼就能看出来。。真是没有必要测试,不过人家官网写了,我也写上吧。
顺便说一句,expect断言库真的是蛮好用的,和chai的expect类似。推荐一下expect和chai。
import expect from 'expect' import * as actions from '../../actions/counter' describe('actions', () => { it('increment1 should create increment1 action', () => { expect(actions.increment1()).toEqual({ type: actions.LOVE_COUNTER_ONE }) }) it('increment2 should create increment1 action', () => { expect(actions.increment2()).toEqual({ type: actions.LOVE_COUNTER_TWO }) }) it('increment3 should create increment1 action', () => { expect(actions.increment3()).toEqual({ type: actions.LOVE_COUNTER_THREE }) }) it('text1 should create text1 action', () => { expect(actions.text1("any1")).toEqual({ type: actions.TXST_COUNTER_ONE,text:"any1" }) }) it('text2 should create text1 action', () => { expect(actions.text2("any2")).toEqual({ type: actions.TXST_COUNTER_TWO,text:"any2" }) }) it('text3 should create text1 action', () => { expect(actions.text3("any3")).toEqual({ type: actions.TXST_COUNTER_THREE,text:"any3" }) }) it('hf1 should create hf1 action', () => { expect(actions.hf1(1,"any1")).toEqual({ type: actions.HF_COUNTER_ONE,id:1,hf:"any1" }) }) it('hf2 should create hf1 action', () => { expect(actions.hf2(2,"any2")).toEqual({ type: actions.HF_COUNTER_TWO,id:2,hf:"any2" }) }) it('hf3 should create hf1 action', () => { expect(actions.hf3(3,"any3")).toEqual({ type: actions.HF_COUNTER_THREE,id:3,hf:"any3"}) }) })
二,测试reducers
reducers也比较简单,首先引入reducers文件和相关action
import expect from 'expect' import {counter} from '../../reducers/counter' import {content} from '../../reducers/counter' import { LOVE_COUNTER_ONE,LOVE_COUNTER_TWO,LOVE_COUNTER_THREE } from '../../actions/counter' import { TXST_COUNTER_ONE,TXST_COUNTER_TWO,TXST_COUNTER_THREE } from '../../actions/counter' import { HF_COUNTER_ONE,HF_COUNTER_TWO,HF_COUNTER_THREE } from '../../actions/counter'
首先是counter的测试,它功能是啥来?就是点击心♥,♥后面的数字会加,然后根据心来排序。
describe('reducers', () => { describe('counter', () => { const initailState={ one:{id:1,counter:0,title:"好险,库里将到手的锅一脚踢飞!",time:1 }, two:{id:2,counter:0,title:"中国男足赔率1:501!",time:42}, three:{id:3,counter:0,title:"为什么要善待高洪波和宫鲁鸣",time:1} }; it('should handle initial state', () => { expect(counter(undefined, {})).toEqual(initailState); }) it('should handle LOVE_COUNTER_ONE', () => { const state={ one:{id:1,counter:1,title:"好险,库里将到手的锅一脚踢飞!",time:1 }, two:{id:2,counter:0,title:"中国男足赔率1:501!",time:42}, three:{id:3,counter:0,title:"为什么要善待高洪波和宫鲁鸣",time:1} }; expect(counter(initailState, { type: LOVE_COUNTER_ONE })).toEqual(state); }) it('should handle LOVE_COUNTER_TWO', () => { const state={ one:{id:1,counter:0,title:"好险,库里将到手的锅一脚踢飞!",time:1 }, two:{id:2,counter:1,title:"中国男足赔率1:501!",time:42}, three:{id:3,counter:0,title:"为什么要善待高洪波和宫鲁鸣",time:1} }; expect(counter(initailState, { type: LOVE_COUNTER_TWO })).toEqual(state); }) it('should handle LOVE_COUNTER_THREE', () => { const state={ one:{id:1,counter:0,title:"好险,库里将到手的锅一脚踢飞!",time:1 }, two:{id:2,counter:0,title:"中国男足赔率1:501!",time:42}, three:{id:3,counter:1,title:"为什么要善待高洪波和宫鲁鸣",time:1} }; expect(counter(initailState, { type: LOVE_COUNTER_THREE })).toEqual(state); }) it('should handle unknown action type', () => { expect(counter(initailState, { type: 'unknown' })).toEqual(initailState); }) })
看代码这么多,其实主要的代码就几句,首先给reducer一个初始state。
然后期望传入初始state,传入每个action.type得到不同的state。就像普通的js函数那么好测试。
expect(counter(initailState, { type: LOVE_COUNTER_THREE })).toEqual(state);
type是每个action的type,看是不是toEqual改变后的state。
当时我在这里出错了,写单元测试,怎么改都不对,mocha提示我说那里出错了,真是一找就找到错误了(再次推荐抹茶~)
原来是我的reducers不”纯“。(错误的代码不影响效果,但是是错误的,错误代码可以看本github的master分支)
函数式编程的要求就是函数要纯。restful的API大火,它强调状态要幂等。类似函数的“纯”。
我看了一下第一个确实有问题,这里上一下代码片段:
return Object.assign({},state,{one:{id:1,counter:++state.one.counter,title:state.one.title,time:state.one.time }})
虽然我用了assign函数保证了state是不变的,但是还是顺手写了个++,然后state就变了。晕。。然后就改为了+1,测试果然过了。
然后第二个就郁闷了,第二个仔细看确实没啥问题,大家看出哪不纯了么?
const newState=state.concat(); newState[0].push({text:action.text,huifu:[]}) return newState; // var data=[ // [ // { // text:"这里是评论1", // huifu:["huifuxxxxxxxxxxxxx",'2xxxxxxxxxxxxxxxxxxxx','3xxxxxxxxxxxxxxxxx'] // }, // { // text:"这里是评论1.2", // huifu:["huifuxxxxxxxxxxxxx"] // } // ],[ // { // text:"这里是评论2", // huifu:["huifuxxxxxxxxxxxxx",'2xxxxxxxxxxxxxxxxxxxx','3xxxxxxxxxxxxxxxxx'] // } // ],[ // { // text:"这里是评论3", // huifu:["huifuxxxxxxxxxxxxx",'2xxxxxxxxxxxxxxxxxxxx','3xxxxxxxxxxxxxxxxx'] // } // ] // ] /*
其实你只看reducer代码是看不出啥的,state是个数组,我concat()复制一个数组,再操作复制后的newState,有啥问题??
然而固执的单元测试就说我这不纯,。后来仔细看才发现,确实不纯。。
newState.push(xxxx),ok没问题,纯的,newState[0].push(xxx),不行,不纯了,state已经改变了。好吧,确实改变了。因为数组里面的数组没复制,newState还是引用原来的地址。。
于是牵扯到对象的深克隆。。于是手写了一个深克隆,果然测试通过了。上一次我的deepClone:
function deepClone(obj){ var res=Array.isArray(obj)?[]:{}; for(var key in obj){ if (typeof obj[key]=="object") { res[key]=deepClone(obj[key]); }else{ res[key]=obj[key]; } } return res; }
这里巧妙地用了typeof的坑,typeof obj和array都会返回“object”。
然后reducer的state.concat()就变成了deepClone(state);
三,测试components
这个是测试compoents的,就是说测试react组件的运行情况,原理就是看它是不是dispatch了相应事件。
首先引入react-addons-test-utils,和Counter组件,还findDOMNode,这是react提供的获得真实组件的方法,现在被转移到react-dom里面,后来又推荐用refs获取真实dom了,包括在
react-addons-test-utils API上面都是用的refs。
import expect from 'expect' import React from 'react' import TestUtils from 'react-addons-test-utils' import Counter from '../../components/Counter' import {findDOMNode} from 'react-dom'
react-addons-test-utils有啥用呢?该api地址:点我
列出我们用到的方法:
renderIntoDocument() //渲染组件到真实dom环境
scryRenderedDOMComponentsWithClass() //从渲染的dom环境中根据class选取真实dom,它的结果是个结果集
//相对的还有findRenderedDOMComponentsWithClass,不同的是它结果只有一个而已
Simulate.click() //模拟用户点击
Simulate.change() //用于改变对应dom
准备活动~
function setup() { const actions = { increment1: expect.createSpy(), increment2: expect.createSpy(), increment3: expect.createSpy(), text1: expect.createSpy(), text2: expect.createSpy(), text3: expect.createSpy(), hf1: expect.createSpy(), hf2: expect.createSpy(), hf3: expect.createSpy() } const initailCounter={ one:{id:1,counter:0,title:"xxxx" ,time:1}, two:{id:2,counter:0,title:"xxxx", time:1}, three:{id:3,counter:0,title:"xxxx",time:1} } const initailContent=[ [{text:"这里是评论1",huifu: ["huifuxxxxxxxxxxxxx"] },{text:"这里是评论1.2",huifu:[]}], [{text:"这里是评论2",huifu:["huifuxxxxxxxxxxxxx"]}], [{text:"这里是评论3",huifu:["huifuxxxxxxxxxxxxx"]} ] ]; const component = TestUtils.renderIntoDocument(<Counter content={initailContent} counter={initailCounter} {...actions} />) return { component: component, actions: actions, heart:TestUtils.scryRenderedDOMComponentsWithClass(component,"heart"), heartNum: TestUtils.scryRenderedDOMComponentsWithClass(component, 'heart') } }
expect.createSpy()创建一个可以追踪的函数,用这个可以看到它是不是被调用了。
然后是TestUtils.renderIntoDocument(<Counter content={initailContent} counter={initailCounter} {...actions} />);
渲染完组件,导出一些用到的东西,heart是渲染组件里的class为heart的dom,点击它心会+1;heartNum就是存放心数量的div啦。
describe('Counter component', () => { it('should display heart number', () => { const { heartNum } = setup() expect(heartNum[0].textContent).toMatch(/^0/g) }) it('click first heart should call increment1', () => { const { heart, actions } = setup() TestUtils.Simulate.click(heart[0]) expect(actions.increment1).toHaveBeenCalled() }) it('pinglun2 buttons should call text2', () => { const {actions,component } = setup() const realDom=findDOMNode(component); const plbtn=realDom.querySelectorAll('.plbtn'); TestUtils.Simulate.click(plbtn[1]) const pingl=TestUtils.scryRenderedDOMComponentsWithClass(component, 'pingl'); TestUtils.Simulate.click(pingl[0]); expect(actions.text2).toHaveBeenCalled() }) it('huifu3 button should call hf3', () => { const { actions,component } = setup() const realDom=findDOMNode(component); const plbtn=realDom.querySelectorAll('.plbtn'); TestUtils.Simulate.click(plbtn[2]);//点击评论 const hf=TestUtils.scryRenderedDOMComponentsWithClass(component, 'hf'); TestUtils.Simulate.click(hf[0]);//点击回复 const hfBtn=TestUtils.scryRenderedDOMComponentsWithClass(component, 'hf-btn'); TestUtils.Simulate.click(hfBtn[0]);//点击回复 expect(actions.hf3).toHaveBeenCalled() }) })
第一个希望心的数量match 0,初始化的时候。然后是模拟点击,点击心会触发increment1,点击评论2号的评论的提交按钮会调用text2方法。点击回复3号的按钮会触发hf3方法。
就是自己点击写期望的结果,就像真正在点击浏览器一样,不多说了。
注意一点,scryRenderedDOMComponentsWithClass支持的css选择器很少,一般可以用findDOMNode这个东西,找到该渲染后的dom,用querySelectorAll就方便多了。
四,测试containers
这个测试就像是测试了,,它是关注你组件的结果,不管程序咋样,我满足你的条件,你得给我我想要的结果。
原理就是把组件渲染到dom里,dispatch一下,然后查看结果。结果咋查看?就看该出现评论的地方有没有输入的字样。match匹配一下。
准备工作~
import expect from 'expect' import React from 'react' import TestUtils from 'react-addons-test-utils' import { Provider } from 'react-redux' import App from '../../containers/App' import configureStore from '../../store/configureStore' import { findDOMNode } from "react-dom"
看到了,我们在这个测试里面直接把react-redux那一套创建store的方法拿出来了。
function setup(initialState) { const store = configureStore(initialState) const app = TestUtils.renderIntoDocument( <Provider store={store}> <App /> </Provider> ) return { app: app, heart: TestUtils.scryRenderedDOMComponentsWithClass(app, 'heart'), heartNum: TestUtils.scryRenderedDOMComponentsWithClass(app, 'heart') } }
把组件渲染进去,开始测试。
也是蛮简单的。我这里就只测试评论和回复的功能了。
const { buttons, p,app } = setup() const realDom=findDOMNode(app); const plbtn=realDom.querySelectorAll('.plbtn'); TestUtils.Simulate.click(plbtn[0]);//点击评论 const plInput=realDom.querySelectorAll(".pl-input")[0]; plInput.value="any111"; TestUtils.Simulate.change(plInput);//input输入any111 const pingl=TestUtils.scryRenderedDOMComponentsWithClass(app, 'pingl'); TestUtils.Simulate.click(pingl[0]);//点击提交 const text=realDom.querySelectorAll('.body-text p'); expect(text[text.length-1].textContent).toMatch(/^any111/)
这里只列出测试评论的代码吧。
和上个一样,乱七八糟的获取dom,然后模拟点击,这里用到了模拟输入,plInput.value="any111";TestUtils.Simulate.change(plInput);
固定api,没啥好说的。其实还有好几个测试,我只是写了代表性的一部分。剩下的都是雷同的,就不写了~
完毕~谢谢~