淺談前端測試

orangexc發表於2019-03-04

淺談前端測試

前端測試或許被好多人誤解,也許大家更加傾向於編寫面向後端的測試,邏輯性強,測試方便等

聊到這導致了好多前端從來不寫測試(測試全靠手點~~~)

其實沒必要達到測試驅動開發的程度,只要寫完程式碼可以補測試,並且補出高效的測試,前端或許真的不需要手點

大前端時代不談環境不成方圓,本文從下面幾個環境一一分析下如何敏捷測試

  • node 環境
  • vue 環境
  • nuxt 服務端渲染環境
  • react 環境
  • next 服務端渲染環境
  • angular 環境

理解測試前需要補充下單元測試(unit)和端到端測試(e2e)的概念,這裡不贅述

node 環境

推薦測試框架 jest

jest 是 FB 的傑作之一,方便各種場景的 js 程式碼測試,這裡選擇 jest 是因為確實方便

使用方法及配置資訊可以去官方文件

配置的注意事項

{
  testEnvironment: 'node' // 如不宣告預設瀏覽器環境
}
複製程式碼

針對 node 只聊一下單元測試,e2e 測試比較少見

當決定寫一個 npm 模組時,程式碼完成後必不可少的就是單元測試,單元測試需要注意的問題比較瑣碎

mock

當引入三方庫時,不得不 mock 資料,因為單元測試更多講求的是區域性測試,不要受外界三方引入包的影響

例如:

const { readFileSync } = require('fs')

const getFile = () => {
  try {
    const text = readFileSync('text.txt', 'utf8')
  } catch (err) {
    throw new Error(err)
  }

  console.log(text)
}

module.exports = getFile
複製程式碼

這時我們並不需要關心 text.txt 是否真的存在,也不需要關係 text 的內容具體是什麼,我們的關注點應該在於讀取檔案錯誤時能否及時丟擲異常,以及 console.log() 是否如預期執行

對應到測試

const getFile = require('./getFile')

describe('readFile', () => {
  const mocks = {
    fs: {
      readFileSync: jest.fn()
    },
    other: {
      text: 'Test text'
    }
  }

  beforeAll(() => {
    jest.mock('fs', () => mocks.fs)
  })

  test('read file success run console.log', () => {
    mocks.fs.readFileSync.mockImplementation(() => this.mocks.other.text)

    getFile()

    expect(console.log).toBeCalled()
  })
})
複製程式碼

上面程式碼簡單的實現了一個讀取檔案是否成功的測試,先別急著糾錯,這段測試本身是錯的,下面慢慢分析

我們在最開始建立了一個 mocks 物件,用來模擬資料,由於 readFileSync 方法可能存在多種返回結果(成功或報錯),所以暫時用 jest.fn() 模擬

other 裡面則是放一些固定的測試資料(不會隨著測試過程而改變)

beforeAll 鉤子裡面執行我們的 mock,把 require 進來的 fs 模組攔截調,也是本測試用例中的關鍵步驟

在第一個 test 裡面我們改寫 mocks.fs.readFileSync 的返回形式,這裡使用的 mockImplementation 是直接模擬了一個執行函式,當然也可以模擬返回值,具體可以到 jest 官網

expect 用來斷言我們的 console.log 方法執行了

解釋了這麼多測試新手們應該也都看的明白了,下面聊一下錯在哪,怎麼改進

  1. mockImplementation 最好替換為 mockReturnValueOnce,注意這裡出現了 Once 結尾,也就是僅模擬一次返回值,mockImplementation 最好使用在複雜場景,所謂的複雜就是我們手動實現一個 readFileSync 方法使得測試達到我們預期的目的,在這個簡單的場景裡面我們只需要模擬返回值就好
  2. expect(console.log) 這裡會報錯,因為 jest 斷言的內容只能是 mock function 或 spy,這裡 console 是全域性物件 global 上的方法,我們沒有 require 將其引入,所以 jest.mock 顯然處理上有些吃力,這時候 spy 就派上用場了,beforeAll 鉤子裡直接執行 jest.spyOn(global.console, 'log'),接下來我們就能監聽到 console.log 的執行了 expect(global.console.log)
  3. 斷言的目的是測試 console.log 的執行,這是不嚴謹的測試,我們需要使用 toBeCalledWith 來代替 toBeCalled,不僅要測試執行了,而且要測試引數正確,簡單修改為 expect(global.console.log).toBeCalledWith(this.mocks.other.text)

下面補一下 read file 失敗的測試

test('read file fail throw error', () => {
  mocks.fs.readFileSync.mockImplementationOnce(() => { throw new Error('readFile error') })

  expect(getFile()).toThrow()
  expect(global.console.log).not.toBeCalled()
})
複製程式碼

讀取檔案失敗的測試就好理解的多,注意的就是對一個 jest.fn() 多次進行修改會導致測試用例之間的相互影響,這裡儘量使用 Once 結尾方法,複雜場景可以如下

beforeEach(() => {
  mocks.fs.readFileSync.mockReset()
})
複製程式碼

每次執行 test 前先清除 mock,避免多個測試用例之間複雜化 mock 導致錯誤

小結:單元測試中的 mock 是個測試思路,我們無需關心外部檔案和依賴是什麼,只要能模擬出正確的情況程式是否按規則執行,錯誤的情況程式是否有異常處理,邏輯是否正確等。這樣就能排除外界干擾,使得我們測試的當前一小部分是可靠的,穩定的即可。

引用外部檔案

單拿出一個小結說下 require 的問題,node 9 之前不支援 es6 的 import,這裡也不詳細說明了。

require 本身並不複雜,但是如果搞不清楚執行時機,那麼測試將無法進行,來一個例子

const env = process.env.NODE_ENV

module.export = () => env
複製程式碼

測試如下

const getEnv = require('./getEnv')

describe('env', () => {
  test('env will be dev', () => {
    process.env.NODE_ENV = 'dev'

    expect(getEnv()).toBe('dev')
  })

  test('env will be pord', () => {
    process.env.NODE_ENV = 'pord'

    expect(getEnv()).toBe('pord')
  })
})
複製程式碼

十分簡單的測試,拋開了 mock 的流程,這裡會報測試未通過,原因是 require 同時 env 已經被賦值為 undefined,我們再試著改變 NODE_ENV 環境變數時,程式不會再次執行,當然了,處理起來也十分簡單

let getEnv

test('env will be dev', () => {
  process.env.NODE_ENV = 'dev'
  getEnv = require('./getEnv')

  expect(getEnv()).toBe('dev')
})

test('env will be pord', () => {
  process.env.NODE_ENV = 'pord'
  getEnv = require('./getEnv')

  expect(getEnv()).toBe('pord')
})
複製程式碼

順帶說了一下,希望大家不要在這種低階錯誤上浪費時間

其實引用外部檔案還有些場景會對測試帶來困惑,比如動態路徑,場景如下

const packageFile = `${process.cwd()}/package.json`

const package = require(packageFile)
複製程式碼

讀取當前路徑下的 package.json,當測試真正跑到這段程式碼時會到當前目錄下找 package.json,這裡儘量 mock 掉 package.json 為我們自己的模擬資料,但是 jest 不支援動態路徑的 mock,試著這樣寫 jest.mock(${process.cwd()}/package.json, () => mockFile) 會報錯,所以儘量使用可以 mock 的方案,保證單元測試可以順利進行,修改如下

const path = require('path')

const filePath = path.join(process.cwd(), 'package.json')
複製程式碼

這樣就可以 mock,path 了,和上面 mock 章節,大致思想都差不多

覆蓋率

單元測試覆蓋率不達標等於白測,測試過程儘量覆蓋所有判斷條件,而不是全部通過了就不管了,在進一階說,100% 的測試覆蓋率並不證明一定覆蓋到位了,因為順帶執行的程式碼也會算進覆蓋率,例如

module.export = (list) => list.map(({ id }) => id)
複製程式碼

我們先不考慮這個 list 型別是不是陣列,只是簡單的例子,避免過度設計帶來複雜化,我們測試可以這樣

const getId = require('./getId')
const mocks = {
  list: [{
    id: 1,
    name: 'vue'
  }, {
    id: 2,
    name: 'react'
  }]
}

test('return id', () => {
  expect(getId(mocks.list)).toEqual([1, 2])
})
複製程式碼

直到有一天程式碼變成了 module.export = (list) => [1, 2]

這時候測試還能通過,並且覆蓋率 100%,的確不會有人蠢到把程式碼改成這樣,只是一個例子,實際上邏輯會比這個複雜的多

那就聊一聊解決方案

  • mock 資料的隨機化,每次測試生成隨機的 list 進行測試,現有庫 mockjs
  • 強關聯測試,證明 map 方法的確執行了,並且引數正確,先 spy spyOn(Array.prototype, 'map') 然後斷言

聊了一圈從覆蓋率聊到了測試健壯性的問題,可以思考下寫過的測試是否真的滿足註釋或修改任何一行程式碼都能引起測試的 pass 報錯

關於 node 就聊這麼多,其實下文主要思想都一樣,更多的是介紹些簡單可行的方案,以及可能會踩坑的地方

vue 環境

在 vue 使用場景下,無非就是元件庫和業務邏輯,元件庫偏向於 unit 測試,業務邏輯偏向於 e2e 測試,當然兩者並不衝突

unit 測試

推薦神器:vue-test-utils

README 給了多個測試庫配置的例子,這裡還是推薦使用 jest,給個例子

export default {
  props: ['value'],
  data () {
    return {
      currentValue: 0
    }
  },
  watch: {
    value (val) {
      this.currentValue = val
    }
  }
}
複製程式碼

測試如下

import { mount } from '@vue/test-utils'
import Test from './Test.vue'

test('props value', () => {
  const options = { propsData: { value: 3 } }

  const wrapper = mount(Test)

  expect(wrapper.vm.currentValue).toBe(3)
})
複製程式碼

十分簡單的例子,亮點在測試檔案的 wrapper 上,通過 mount 方法建立了一個元件例項,建立過程中允許加入一些配置資訊,甚至是 mock 元件中的 method 方法

vue 單元測試的範圍僅限於資料流動是否正確,邏輯渲染是否正確(v-if v-show v-for),style 和 class 是否正確,我們並不需要關係這個元件在瀏覽器渲染中的位置,也不需要關係對其它元件會造成什麼影響,只要保證元件本身正確即可,前面說的斷言,vue-test-utils 都能提供對應的方案,總體上節約很多測試成本

e2e 測試

也是推薦尤大基於最新腳手架的 @vue/cli-plugin-e2e-nightwatch

e2e 測試的重點在於判斷真實 DOM 是否滿足預期要求,甚至很少出現 mock 場景,不可或缺的是一個瀏覽器執行環境,具體細節不贅述,可以看官方文件。

nuxt 服務端渲染環境

nuxt 官方推薦 ava,順勢帶出 ava 的方案

unit 測試

麻煩在配置上面,先給出需要安裝的依賴

"@vue/test-utils",
"ava",
"browser-env",
"require-extension-hooks",
"require-extension-hooks-babel",
"require-extension-hooks-vue",
"sinon"
複製程式碼

在 package.json 里加幾行 ava 配置

"ava": {
  "require": [
    "./tests/helpers/setup.js"
  ]
}
複製程式碼

下面來寫 ./tests/helpers/setup.js

const hooks = require('require-extension-hooks')

// Setup browser environment
require('browser-env')()

// Setup vue files to be processed by `require-extension-hooks-vue`
hooks('vue').plugin('vue').push()
// Setup vue and js files to be processed by `require-extension-hooks-babel`
hooks(['vue', 'js']).plugin('babel').push()
複製程式碼

上面的程式碼唯獨沒看到 sinon 這個庫,說到 ava 是沒有 mock 功能的,這就給單元測試的 mock 帶來巨大困難,不過我們可以通過引入 sinon 來解決 mock 資料的問題,在 mock 方面上 sinon 做的比 jest 還要優秀,支援沙箱模式,不影響外部資料

給個簡單點的例子

<template>
  <el-card v-for="item in topicList" :key="item.id">
    <div class="card-content">
      <span class="link" @click="toMember(item.member.username)">{{ item.member.username }}</span>
    </div>
  </el-card>
</template>

<script>
export default {
  props: {
    topicList: {
      type: Array,
      required: true
    }
  },
  methods: {
    toMember (name) {
      this.$router.push(`/member/${name}`)
    }
  }
}
</script>
複製程式碼

對應的測試程式碼如下

import { shallowMount } from '@vue/test-utils'
import test from 'ava'
import sinon from 'sinon'

test('methods: toMember', t => {
  const { topicList } = t.context
  const $router = {
    push: () => {}
  }
  const spy = sinon.spy($router, 'push')

  const wrapper = shallowMount(TopicListChalk, {
    propsData: { topicList },
    mocks: {
      $router
    }
  })

  topicList.forEach((item, index) => {
    const toMemberText = wrapper.findAll('.card-content').at(index).find('.link')

    toMemberText.trigger('click')

    t.true(spy.withArgs(`/member/${item.member.username}`).calledOnce)
  })
})
複製程式碼

這裡直接將 $router mock 掉,並且使用 sinon.spy 監聽執行,至於 this.$router.push 後瀏覽器有沒有跳轉並不是單元測試需要關心的,這裡的寫法也比較特別,test 方法在回撥裡預設引數為 t,對應的方法都掛載在 t 物件上,上下文可通過 t.context 傳遞

nuxt 單元測試相關就聊這麼多

e2e 測試

這裡有個歧義點,nuxt 官網只給出了 e2e 的測試案例 end-to-end-testing

當使用預設腳手架構建的專案,也就是沒有 server 端入口檔案的專案,這個方案確實可行

但是涉及到其它框架(express|koa)的時候就顯得不夠用了,很有可能在自定義 server 入口是加入了大量中介軟體,這對於官網給出的例子是個巨大考驗,不可能在每個測試檔案裡實現一遍 new Nuxt,所以需要更高層的封裝,也就是忽略 server 啟動流程的差異性,直接在瀏覽器中抓取頁面

推薦:nuxt-jest-puppeteer

react 環境

unit 測試

這一波沒得可選,jest 完勝,人家官網就有 React,RN 的支援文件

文件的案例也是十分全面,沒得講,不贅述

e2e 測試

其實上面講了兩個 e2e 的方案選擇,大同小異,需要一個能在 node 跑的無頭瀏覽器,官方沒有推薦,這裡站 vue 一票選擇 nightwatchjs

next 服務端渲染環境

unit 測試

主要講一下如何配置,先是依賴包

"babel-core",
"babel-jest",
"enzyme",
"enzyme-adapter-react-16",
"jest",
"react-addons-test-utils",
"react-test-renderer"
複製程式碼

在 package.json 裡面加 script "test": "NODE_ENV=test jest"

在跟路徑下加 jest.config.js

module.exports = {
  setupFiles: ['<rootDir>/jest.setup.js'],
  testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/']
}
複製程式碼

在跟路徑下加 jest.setup.js

import { configure } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'

configure({
  adapter: new Adapter()
})
複製程式碼

接下來就可以愉快的寫測試了

e2e 測試

跳過了~~~

angular 環境

之所以加了這一節,還是因為多少寫過一些 angular,angular 作為框架本身就是全面的,cli 新建的專案自身就帶有 unit 測試和 e2e 測試

unit 測試預設是 karma + jasmine e2e 測試預設是 protractor

也沒什麼可爭辯的,這就是官方解決方案,用起來也方便順手

總結

聊了好多個環境,其實行文目的主要有兩方面

  • 測試思想,如何寫好單元測試,主要集中在前半文
  • 測試工具推薦和相應配置

測試本身並不複雜,但是想寫出高效測試並不容易,千萬不要形成為了測試而測試的想法

用謊言去驗證謊言得到的還是謊言。。。

大多數情況下都是專案在趕進度沒空寫測試,抽空把測試補上真的是一件值得去做的事情

相關文章