react-redux單元測試(基於react-addons-test-utils,mocha)


今天補上上次新聞客戶端欠下的單元測試。新聞客戶端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個部分,測試actionscomponentscontainersreducers

與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,沒啥好說的。其實還有好幾個測試,我只是寫了代表性的一部分。剩下的都是雷同的,就不寫了~

 

完畢~謝謝~

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM