如何在React+Redux的項(xiàng)目中更優(yōu)雅的實(shí)現(xiàn)前端自動(dòng)化測試

乘著改革開放的浪潮,這段時(shí)間我們終于接觸到非?;馃岬那岸隧?xiàng)目構(gòu)架React+Redux。

這個(gè)構(gòu)架下的前端項(xiàng)目,最大的優(yōu)點(diǎn)就是Redux鼓勵(lì)各個(gè)組件無狀態(tài)化(no state),利用store統(tǒng)一管理state,從而使各個(gè)組件之間相對更加獨(dú)立和易于維護(hù),使得前端的構(gòu)架更加簡單化。下圖中左邊是經(jīng)典React中各組件的層級關(guān)系,右邊是引入Redux之后的層級。在左圖中,當(dāng)修改父節(jié)點(diǎn)/組件時(shí),子組件也可能會(huì)被破壞掉;而右圖中能夠影響到各個(gè)組件的因素只有state。

經(jīng)典react架構(gòu)和加入redux架構(gòu)后的層級

在傳統(tǒng)JS Web項(xiàng)目中的自動(dòng)化測試,通常會(huì)有這些比較突出的問題

  • UI自動(dòng)化功能測試受制于環(huán)境(運(yùn)行os,瀏覽器等)維護(hù)困難,運(yùn)行緩慢,而且非常容易因?yàn)榍岸俗兓黄茐摹?/li>
  • 單元測試覆蓋點(diǎn)有限,無法覆蓋所有的測試點(diǎn)。

那么作為新技術(shù)的React+Redux的出現(xiàn),會(huì)不會(huì)給也測試帶來一些新的思路或者機(jī)會(huì)來解決這些問題呢?例如放棄掉UI自動(dòng)化測試?

作為浸泡在測試金字塔理論中多年的吃瓜的測試群眾,我對越低層的測試成本更少、反饋問題更快這個(gè)道理深以為然。所以在面對這樣的新鮮事物的時(shí)候,總愿意去分析下是否可以結(jié)合項(xiàng)目的技術(shù)特點(diǎn),盡量把自動(dòng)化測試往低層移,減少成本,加速反饋周期。還可以把鍋甩給研發(fā)同學(xué)

分析可行性

為了分析技術(shù)上實(shí)現(xiàn)的可行性,我們至少需要知道React和Redux的一些基本概念:

  • Store : 全局唯一的對象,用來保存state
  • State : 某個(gè)時(shí)間點(diǎn)上state的快照,和改時(shí)間點(diǎn)上的view應(yīng)該是一一對應(yīng)的
  • Action : view 通過store.dispatch(action)發(fā)出的通知,表示 state 應(yīng)該要發(fā)生變化了。
  • Reducer : 接受action和當(dāng)前state,返回新的state的函數(shù)
  • UI Component : 純負(fù)責(zé)顯示UI,無狀態(tài)
  • Container (Component): 負(fù)責(zé)一些業(yè)務(wù)邏輯和connect UI組件
  • Provider : React-Redux庫的讓react組件拿到新的state的方法

還需要了解Redux大致的工作流程:

  1. 用戶操作view觸發(fā)action
  2. store被action通知state要變化了,調(diào)用reducer
  3. reducer計(jì)算新的 state應(yīng)該是啥樣,返回新的state給store
  4. store通過react組件把新的state對應(yīng)的view顯示給客戶

我剛好寫了個(gè)簡單的demo,能大概看懂這個(gè)demo中,各個(gè)組件是做什么的,如何工作的,對后面的內(nèi)容有極大幫助。demo使用了webpack作為打包和本地運(yùn)行工具

這么看起來,redux通過state-view一一對應(yīng)的架構(gòu)保證了只要view變,state一定變,反之亦然。這種一一對應(yīng)的關(guān)系減少了組件之間發(fā)生關(guān)聯(lián)(變化)的可能性,從而減輕了測試復(fù)雜度。最后再總結(jié)一下,發(fā)現(xiàn)針對該架構(gòu)的自動(dòng)化測試其實(shí)只需要保證下面幾點(diǎn)就夠了:

  • 各個(gè)單獨(dú)組件能夠正常顯示DOM元素
  • 如果state改變了, 那么我只需要確保相應(yīng)的view發(fā)生了變化
  • 如果view發(fā)生了改變, 那么我只需要確保相應(yīng)的state存進(jìn)了store

驗(yàn)證分析結(jié)果

從上面兒的分析結(jié)果來看,只用低層測試來保證質(zhì)量的想法,好像有點(diǎn)兒靠譜的樣子。接下來就是做一些小的demo來驗(yàn)證下真實(shí)項(xiàng)目中是否行得通。

好的單元測試應(yīng)該有哪些特點(diǎn)呢?

  1. 簡單易懂。最好是BDD風(fēng)格的,一眼就可以看出你在測試什么,減少維護(hù)成本
  2. 高覆蓋率,研發(fā)重構(gòu)的時(shí)候會(huì)更有信心
  3. 跑的快,不要有額外的工作(例如維護(hù)復(fù)雜的環(huán)境依賴等)
  4. 從客戶價(jià)值(business value)角度出發(fā),確保軟件的可交付性。

那么首先,我們應(yīng)該是盡量選擇一款滿足上面需求的測試工具。

測試工具選擇

滿足上面條件的JS前端單元測試工具/框架很多,比較流行的是mocha+chai、JEST等。這里我們使用JEST測試框架和Enzyme測試工具庫。

經(jīng)過實(shí)際使用后發(fā)現(xiàn),JEST對比Mocha來說,雖然運(yùn)行速度上感覺比Mocha稍慢,但是因?yàn)槿缦聨讉€(gè)優(yōu)點(diǎn)最后勝出:

  • 和React師出同門,F(xiàn)B官方支持
  • 已經(jīng)集成了測試覆蓋率檢查、mock等功能,不需要安裝額外的庫
  • 文檔完備,官方提供了和babel、webpack集成情況下以及異步調(diào)用的測試解決方案
  • 官方提供snapshot testing解決方案
安裝

JEST 和 Enzyme 官方提供了詳細(xì)的安裝指導(dǎo),實(shí)際安裝完成后發(fā)現(xiàn)還是有坑。這里把安裝過程重新梳理下。

首先是JEST,

$ npm install --save-dev jest

如果需要在測試項(xiàng)目中使用babel,還需要額外安裝babel-jest,

$ npm install --save-dev babel-jest

然后是Enzyme,

$ npm install enzyme --save-dev

如果使用的是react13以上的版本,則需要額外安裝react-addons-test-utils

$ npm i --save-dev react-addons-test-utils
配置

安裝完成后,就可以開始寫測試?yán)瞺
不要方!JEST運(yùn)行基礎(chǔ)功能雖然無需配置,但是官方依然提供了配置選項(xiàng)來實(shí)現(xiàn)個(gè)性化需求。

例如,在單元測試覆蓋率檢查的時(shí)候,默認(rèn)只檢查被測試文件所使用到的源文件的覆蓋率。然而,我們可以通過在package.json文件中配置jest的collectCoverageFrom參數(shù),來指定檢查所有需要測試的文件(無論源文件有沒有被測試文件使用到)

以上面提到的demo為例。我們需要確定單元測試的范圍--目標(biāo)測試的文件是src文件夾下面的.jsx或者js文件,同時(shí)需要忽略其中的一些配置性質(zhì)的jsx/js,比如store.js、provider.jsx和用于合并reducer的index.js。另外,還有覆蓋率檢查的時(shí)候生成coverage文件夾下面的js,編譯后在dist文件夾下面生成的js文件,以及webpack的config文件都不需要測試。那么我們就在package.json里面加上這樣一段內(nèi)容

"jest": {
    "collectCoverageFrom" : [
      "**/*.{js,jsx}",
      "!**/coverage/**",
      "!**/dist/**",
      "!**/store.js",
      "!**/provider.jsx",
      "!**/index.js",
      "!**/webpack.config.js"
    ]
  }

然后給我們單元測試的覆蓋率定個(gè)小目標(biāo),95%吧。只有當(dāng)測試覆蓋率大于等于這個(gè)比例的時(shí)候測試才會(huì)通過。

  "jest": {
    "collectCoverageFrom" : [
      "**/*.{js,jsx}",
      "!**/coverage/**",
      "!**/store.js",
      "!**/provider.jsx",
      "!**/index.js",
      "!**/webpack.config.js"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 95,
        "functions": 95,
        "lines": 95,
        "statements": 95
      }
    }
  }

JEST配置選項(xiàng)有很多有用的功能,例如指定加載啟動(dòng)文件、指定moduleNameMapper、指定別名等。詳見這里。

最后,我們還要給執(zhí)行JEST加上一個(gè)命令:在package.json文件的scripts區(qū)域中增加一句

"scripts": {
  ...
  "test": "jest"
}

這樣,我們就可以通過 npm test 命令來跑測試了。

實(shí)現(xiàn)單元測試

工具準(zhǔn)備完成,就可以開始寫測試?yán)瞺
不要方!我們知道Redux基本概念中的store等組件的功能和目的各不相同,那么針對各種組件的特性,我們分別應(yīng)該如何測試呢?翻看Redux官網(wǎng),發(fā)現(xiàn)這里有詳細(xì)的例子和介紹,附上官網(wǎng)傳送門

需要注意的是,UI Component由于無狀態(tài)化,和只負(fù)責(zé)顯示DOM的作用,所以針對它們的單元測試只需要驗(yàn)證是否按預(yù)期顯示了DOM就行了。組件中的props、方法等則無需測試。

還是以demo中的代碼為例子。我們footer.jsx組件的代碼是醬紫的:

import React from 'react'

//這里是太長不想貼上來的css代碼..

export default class Footer extends React.Component {
  constructor() {
    super()
    this.handleClick = this.handleClick.bind(this)
  }

  handleClick(){
    this.props.onClick()
  }

  render(){
    return(
      <div style={styles.base}>
        <footer>
          <a onClick={this.handleClick}>click footer to back</a>
        </footer>
      </div>
      )
  }

}

Footer.propTypes = {
  onClick: React.PropTypes.func.isRequired
}

其中render()是React中負(fù)責(zé)顯示DOM的代碼, handleClick()是一個(gè)自定義方法,onClick則是一個(gè)props。再來看看測試代碼footer.test.js

import React from 'react'
import { shallow } from 'enzyme'

import Footer from '../../src/components/UIs/footer'
const props = {
  onClick: jest.fn()
}

describe('Footer component', () => {
  it('should render dom', () => {
    const wrapper = shallow(<Footer {...props}/>)
    expect(wrapper.find('a').text()).toContain('click footer')
  })
})

測試代碼中使用了enzyme庫中的shallow功能。shallow是官方測試工具庫react-addons-test-utils中shallow rendering的封裝。是將一個(gè)組件渲染成虛擬DOM對象的“淺渲染”。這種渲染不會(huì)涉及子組件,不需要DOM,速度非??臁?/p>

源代碼中onClick后調(diào)用的方法,在這里被JEST自帶的mocked方法jest.fn()代替掉了,我們這個(gè)測試只測試了組件是否被正常顯示出來了。expect部分是斷言,實(shí)現(xiàn)內(nèi)容是在被渲染出的footer組件中找到a標(biāo)簽,然后斷言它的text()中有沒有包含期望的文字。通過這種方式我們可以得知組件是否有被顯示出來。

除了text()屬性以外,還可非常靈活的通過其他方式來得知組件是否被正常顯示。例如:

    expect(wrapper.find('button').exists()).toBeTruthy()
    expect(wrapper.find('input').props().type).toBe('text')

前者是斷言被渲染出的組件中是否有button標(biāo)簽的存在;后者是斷言組件中的input標(biāo)簽是否有type="text"這個(gè)屬性。

針對各個(gè)action、reducer和UI Component的測試寫完成后,我們來運(yùn)行下測試,查看覆蓋率。

Tips: 可以通過npm test <測試文件名> 運(yùn)行單個(gè)測試

完成獨(dú)立組件測試后的覆蓋率結(jié)果

這個(gè)時(shí)候我們就看到了之前配置的測試覆蓋率檢查范圍的作用了:報(bào)告明確告訴了我們,app.jsx沒有被測試到。另外,在footer.jsx中還有第19行以及userName.jsx第34行也沒有被測試到,覆蓋率一片紅..

檢查了下未被覆蓋的footer 19行和userName 34行,發(fā)現(xiàn)正是之前特意忽略掉的UI Component內(nèi)的方法。app.jsx是一個(gè)Container組件,還沒有寫任何測試。

實(shí)現(xiàn)功能測試

發(fā)現(xiàn)問題了,那就趕緊補(bǔ)測試吧~
不要方!我們先來仔細(xì)分析下。

  • 容器(container)組件的主要作用是鏈接UI組件,里面可能也包含了一些業(yè)務(wù)邏輯
  • UI Component中的方法,最終會(huì)通過容器組件對組件的調(diào)用而被調(diào)用到。

那么換句話說,我只需要按照功能測試的方法,以user journey的角度來測試這個(gè)組件,就可以覆蓋到所有東西咯?再拿demo來練練手。

先確定demo的功能和user journey是:

  • 用戶輸入任意字符,輸入的同時(shí),會(huì)在下方顯示出輸入的值
  • 點(diǎn)擊Submit按鈕后提交form,更新界面顯示
  • 點(diǎn)擊footer后可以回到首頁
首頁同步顯示輸入的內(nèi)容
提交表單后顯示新的視圖

那么我們測試的內(nèi)容就應(yīng)該是:

import React from 'react'
import { createStore } from 'redux'
import { mount } from 'enzyme'
import { Provider } from 'react-redux'
import ReactDom from 'react-dom'

import App from '../../src/components/app'
import Reducer from '../../src/reducers'

let store
let wrapper

const fillin = (byCssSelector, text) => {
  wrapper.find(byCssSelector).simulate('change', {target: {value:text}})
}

beforeEach( () => {
  store = createStore(Reducer)
  wrapper = mount (
    <Provider store={store}>
    <App />
    </Provider>
  )
})

describe('User journey', ()=> {
  describe('user input a string in the field', ()=> {
    it('should display inputed name', ()=> {
      fillin('#userName', 'Han mei 妹@')
      expect(wrapper.find('#userInput').text()).toContain('Han mei 妹@')
    })
  })

  describe('click submit button', ()=> {
    it('should show welcome page, and original view disappear', ()=> {
      fillin('#userName', '李磊@_@')
      wrapper.find('form').simulate('submit')
      expect(wrapper.find('#welcome').text()).toContain('李磊@_@')
      expect(wrapper.find('#userInput').exists()).toBeFalsy()
    })
  })

  describe('click footer to back', ()=> {
    it('should show welcome page, and original view disappear', ()=> {
      fillin('#userName', 'linTao123')
      wrapper.find('form').simulate('submit')
      wrapper.find('a').simulate('click')
      expect(wrapper.find('#userInput').exists()).toBeTruthy()
    })
  })
})

simulate方法是enzyme封裝好的模擬頁面元素事件的方法,用來模擬"click","submit","change","doubeClick"等事件。

測試代碼中的fillin方法實(shí)現(xiàn)的是在渲染后的DOM中找到一個(gè)web element,然后用simulate方法模擬綁定在該元素上面的onChange()事件。

beforeEach方法是一個(gè)JEST的Hook,在每一個(gè)it/test開頭的測試之前都會(huì)執(zhí)行里面的內(nèi)容。在demo的案例中,我把store.jsprovider.js里面的內(nèi)容照搬了過來,每個(gè)測試執(zhí)行前都會(huì)新生成一個(gè)store,實(shí)現(xiàn)重置store的功能(重置測試環(huán)境)。

功能測試看上去也沒問題了,所有的用戶場景貌似都覆蓋完了。這個(gè)時(shí)候我們再來檢查下覆蓋率

$ npm test -- --coverage
功能測試完成后的覆蓋率

100%覆蓋率了有木有!完美啊有木有!
為何加上功能測試以后,之前UI組件里面沒有測試到的方法也被覆蓋到了呢?分析下產(chǎn)品代碼,原來是在頁面元素上執(zhí)行操作的時(shí)候,就會(huì)調(diào)用到UI組件上的這些方法,而這些操作后來被功能測試覆蓋到了。

這么一來,又避免了重復(fù)的測試代碼 :)

收尾

那么對測試群眾來說,從質(zhì)量保證的角度出發(fā),單元測試覆蓋率100%是否就足夠了呢?

肯定不夠啊!

結(jié)合實(shí)際的項(xiàng)目經(jīng)驗(yàn)來看,JEST的測試還可以根據(jù)產(chǎn)品的實(shí)際需求,做一些諸如:

  • 點(diǎn)擊某個(gè)頁面元素后,需要在頁面上顯示新的區(qū)塊,并且要加載指定的的css的測試。
  • 點(diǎn)擊某個(gè)link,需要跳轉(zhuǎn)到指定的網(wǎng)站的測試
  • 等等

這些測試原本在UI自動(dòng)化功能測試中也比較常見,這里我們都可以把它們挪到低層中去。所以具體的測試用例,在單元測試覆蓋率超級高的前提下,我們測試的群眾還可以跟研發(fā)結(jié)對完成。或者指導(dǎo)研發(fā)完成,要不干脆自己加上去算了。

另外,產(chǎn)品的功能性測試完成的情況下,我們還需要考慮下非功能性的問題,例如兼容性、性能、安全性等。再加上測試金字塔的頂端之上,其實(shí)還有探索性測試的位置。產(chǎn)品的基本功能由單元測試保障了,剩下的時(shí)間,我們可以做更多的探索性測試了不是嗎~

總之,干掉UI自動(dòng)化功能測試只是一個(gè)加速測試反饋周期、減少投入成本的嘗試。軟件的質(zhì)量不僅僅是測試攻城獅的事情,而是整個(gè)團(tuán)隊(duì)的責(zé)任。堅(jiān)持一些重要的編碼實(shí)踐,比如state less的組件、build security in等,也是提高質(zhì)量的重要手段。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容