前端單元測試技術方案總結

本文做者: 江水javascript

本文主要介紹前端單元測試的一些技術方案。css

單元測試的技術方案不少,不一樣工具之間有互相協同,也存在功能重合,給咱們搭配測試方案帶來不小的困難,並且隨着 ES6, TypeScript 的出現,單元測試又增長了不少其餘步驟,完整配置起來每每須要很大的時間成本。我但願經過對這些工具的各自做用的掌握,瞭解完整的前端測試技術方案。前端單元測試的領域也不少,這裏主要講對於前端組件如何進行單元測試,最後會主要介紹下對於 React 組件的一些測試方法總結。html

通用測試

單元測試最核心的部分就是作斷言,好比傳統語言中的 assert 函數,若是當前程序的某種狀態符合 assert 的指望此程序才能正常執行,不然直接退出應用。因此咱們能夠直接用 Node 中自帶的 assert 模塊作斷言。前端

用最簡單的例子作個驗證java

function multiple(a, b) {
    let result = 0;
    for (let i = 0; i < b; ++i)
        result += a;
    return result;
}
複製代碼
const assert = require('assert');
assert.equal(multiple(1, 2), 3));
複製代碼

這種例子可以知足基礎場景的使用,也能夠做爲一種單元測試的方法。node

nodejs 自帶的 assert 模塊提供了下面一些斷言方法,只能知足一些簡單場景的須要。react

assert.deepEqual(actual, expected[, message])
assert.deepStrictEqual(actual, expected[, message])
assert.doesNotMatch(string, regexp[, message])
assert.doesNotReject(asyncFn[, error][, message])
assert.doesNotThrow(fn[, error][, message])
assert.equal(actual, expected[, message])
assert.fail([message])
assert.ifError(value)
assert.match(string, regexp[, message])
assert.notDeepEqual(actual, expected[, message])
assert.notDeepStrictEqual(actual, expected[, message])
assert.notEqual(actual, expected[, message])
assert.notStrictEqual(actual, expected[, message])
assert.ok(value[, message])
assert.rejects(asyncFn[, error][, message])
assert.strictEqual(actual, expected[, message])
assert.throws(fn[, error][, message])
複製代碼

自帶的 assert 不是專門給單元測試使用, 提供的錯誤信息文檔性很差,上面的 demo 最終執行下來會產生下面的報告:webpack

$ node index.js
assert.js:84
  throw new AssertionError(obj);
  ^

AssertionError [ERR_ASSERTION]: 2 == 3
    at Object.<anonymous> (/home/quanwei/git/index.js:4:8)
    at Module._compile (internal/modules/cjs/loader.js:778:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:789:10)
    at Module.load (internal/modules/cjs/loader.js:653:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
    at Function.Module._load (internal/modules/cjs/loader.js:585:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:831:12)
    at startup (internal/bootstrap/node.js:283:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:623:3)
複製代碼

因爲自帶的模塊依賴 Node 自身的版本,沒辦法自由升級,因此使用內置的包靈活性有時候不太夠,另外咱們不少斷言函數也須要在瀏覽器端執行,因此咱們須要同時支持瀏覽器和 Node 端的斷言庫。同時觀察上面的輸出能夠發現,這個報告更像是程序的錯誤報告,而不是一個單元測試報告。而咱們在作單元測時每每須要斷言庫可以提供良好的測試報告,這樣才能一目瞭然地看到有哪些斷言經過沒經過,因此使用專業的單元測試斷言庫仍是頗有必要。git

chai

chai

chai 是目前很流行的斷言庫,相比於同類產品比較突出。chai 提供了 TDD (Test-driven development)和 BDD (Behavior-driven development) 兩種風格的斷言函數,這裏不會過多介紹兩種風格的優缺,本文主要以 BDD 風格作演示。es6

TDD 風格的 chai

var assert = require('chai').assert
  , foo = 'bar'
  , beverages = { tea: [ 'chai', 'matcha', 'oolong' ] };

assert.typeOf(foo, 'string'); // without optional message
assert.typeOf(foo, 'number', 'foo is a number'); // with optional message
assert.equal(foo, 'bar', 'foo equal `bar`');
assert.lengthOf(foo, 3, 'foo`s value has a length of 3');
assert.lengthOf(beverages.tea, 3, 'beverages has 3 types of tea');
複製代碼

chaiNode 自帶的 assert 增長了一個斷言說明參數,能夠經過這個參數提升測試報告的可讀性

$ node chai-assert.js

/home/quanwei/git/learn-tdd-bdd/node_modules/chai/lib/chai/assertion.js:141
      throw new AssertionError(msg, {
      ^
AssertionError: foo is a number: expected 'bar' to be a number
    at Object.<anonymous> (/home/quanwei/git/learn-tdd-bdd/chai-assert.js:6:8)
    at Module._compile (internal/modules/cjs/loader.js:778:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:789:10)
    at Module.load (internal/modules/cjs/loader.js:653:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
    at Function.Module._load (internal/modules/cjs/loader.js:585:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:831:12)
    at startup (internal/bootstrap/node.js:283:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:623:3)
複製代碼

BDD 風格的 chai

chaiBDD 風格使用 expect 函數做爲語義的起始,也是目前幾乎全部 BDD 工具庫都遵循的風格。

chaiexpect 斷言風格以下

expect(foo).to.be.a('string');
expect(foo).to.equal('bar');
expect(foo).to.have.lengthOf(3);
複製代碼

BDD 的思想就是寫單元測試就像寫產品需求,而不關心內部邏輯,每個用例閱讀起來就像一篇文檔。例以下面的用例:

  1. foo 是一個字符串 ->expect(foo).to.be.a('string')
  2. foo 字符串裏包含 'bar' ->expect(foo).to.include('bar')
  3. foo 字符串裏不包含 'biz' -> expect(foo).to.not.include('biz')

能夠看到這種風格的測試用例可讀性更強。

其餘的斷言庫還有 expect.js should.js better-assert , unexpected.js 這些斷言庫都只提供純粹的斷言函數,能夠根據喜愛選擇不一樣的庫使用。

有了斷言庫以後咱們還須要使用測試框架將咱們的斷言更好地組織起來。

mocha 和 Jasmine

mocha jasmine

mocha 是一個經典的測試框架(Test Framework),測試框架提供了一個單元測試的骨架,能夠將不一樣子功能分紅多個文件,也能夠對一個子模塊的不一樣子功能再進行不一樣的功能測試,從而生成一份結構型的測試報告。例如 mocha 就提供了describeit 描述用例結構,提供了 before, after, beforeEach, afterEach 生命週期函數,提供了 describe.only ,describe.skip , it.only, it.skip 用以執行指定部分測試集。

const { expect } = require('chai');
const { multiple } = require('./index');

describe('Multiple', () => {
    it ('should be a function', () => {
        expect(multiple).to.be.a('function');
    })

    it ('expect 2 * 3 = 6', () => {
        expect(multiple(2, 3)).to.be.equal(6);
    })
})
複製代碼

測試框架不依賴底層的斷言庫,哪怕使用原生的 assert 模塊也能夠進行。給每個文件都要手動引入 chai 比較麻煩 ,這時候能夠給 mocha 配置全局腳本,在項目根目錄 .mocharc.js 文件中加載斷言庫, 這樣每一個文件就能夠直接使用 expect 函數了。

// .mocharc.js
global.expect = require('chai').expect;
複製代碼

使用 mocha 能夠將咱們的單元測試輸出成一份良好的測試報告 mocha *.test.js

當出現錯誤時輸出以下

由於運行在不一樣環境中須要的包格式不一樣,因此須要咱們針對不一樣環境作不一樣的包格式轉換,爲了瞭解在不一樣端跑單元測試須要作哪些事情,能夠先來了解一下常見的包格式。

目前咱們主流有三種模塊格式,分別是 AMD, CommonJS, ES Module

AMD

AMDRequireJS 推廣過程當中流行的一個比較老的規範,目前不管瀏覽器仍是 Node 都沒有默認支持。AMD 的標準定義了 definerequire函數,define用來定義模塊及其依賴關係,require 用以加載模塊。例如

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8"/>
        <title>Document</title>
+ <script
+ src="https://requirejs.org/docs/release/2.3.6/minified/require.js"></script>
+ <script src="./index.js" />
</head>
    <body></body>
</html>
複製代碼
// index.js
define('moduleA', ['https://some/of/cdn/path'], function() {
    return { name: 'moduleA' };
});

define(function(require) {
    const fs = require('fs');
    return fs;
})

define('moduleB', function() {
    return { name: 'module B' }
});

require(['moduleA', 'moduleB'], function(moduleA, moduleB) {
    console.log(module);
});
複製代碼

這裏使用了RequireJS 做爲 AMD 引擎, 能夠看到 define 函數會定義當前依賴了哪些模塊並將模塊加載完成後異步回調給當前模塊,這種特性使得 AMD 尤其適合瀏覽器端異步加載。

咱們可使用 webpack 打包一份 amd 模塊看下真實代碼

// entry.js
export default function sayHello() {
    return 'hello amd';
}
複製代碼
// webpack.config.js
module.exports = {
    mode: 'development',
    devtool: false,
    entry: './entry.js',
    output: {
        libraryTarget: 'amd'
    }
}
複製代碼

最終生成代碼(精簡了不相關的邏輯)

// dist/main.js
define(() => ({
    default: function sayHello() {
        return 'hello amd';
    }
}));
複製代碼

在瀏覽器/Node 中想要使用 AMD 須要全局引入 RequireJS,對單元測試而言比較典型的問題是在初始化 karma 時會詢問是否使用 RequireJS ,不過通常如今不多有人使用了。

CommonJS

能夠縮寫成CJS , 其 規範 主要是爲了定義 Node 的包格式,CJS 定義了三個關鍵字, 分別爲 requireexports, module, 目前幾乎全部Node 包以及前端相關的NPM包都會轉換成該格式, CJS 在瀏覽器端須要使用 webpack 或者 browserify 等工具打包後才能執行。

ES Module

ES ModuleES 2015 中定義的一種模塊規範,該規範定義了 表明爲 importexport ,是咱們開發中經常使用的一種格式。雖然目前不少新版瀏覽器都支持<script type="module"> 了,支持在瀏覽器中直接運行 ES6 代碼,可是瀏覽器不支持 node_modules ,因此咱們的原始 ES6 代碼在瀏覽器上依然沒法運行,因此這裏我暫且認爲瀏覽器不支持 ES6 代碼, 依然須要作一次轉換。

下表爲每種格式的支持範圍,括號內表示須要藉助外部工具支持。

Node 瀏覽器
AMD 不支持(require.js, r.js) 不支持(require.js)
CommonJS 支持 不支持(webpack/browserify)
ESModule 不支持(babel) 不支持(webpack)

單元測試要在不一樣的環境下執行就要打不一樣環境對應的包,因此在搭建測試工具鏈時要肯定本身運行在什麼環境中,若是在 Node 中只須要加一層 babel 轉換,若是是在真實瀏覽器中,則須要增長 webpack 處理步驟。

因此爲了可以在 Node 環境的 Mocha中使用 ES Module 有兩種方式

  1. Node 環境天生支持 ES Module (node version >= 15)
  2. 使用 babel 代碼進行一次轉換

第一種方式略過,第二種方式使用下面的配置

npm install @babel/register @babel/core @babel/preset-env --save-dev
複製代碼
// .mocharc.js
+ require('@babel/register');
global.expect = require('chai').expect;
複製代碼
// .babelrc
+ {
+ "presets": ["@babel/preset-env" ,「@babel/preset-typescript」]
+ }
複製代碼

一樣地若是在項目中用到了 TypeScript, 就可使用ts-node/register 來解決,由於 TypeScript自己支持 ES Module 轉換成 CJS, 因此支持了 TypeScript後就不須要使用 babel 來轉換了。(這裏假設使用了 TypeScript 的默認配置)

npm install ts-node typescript --save-dev
複製代碼
// .mocharc.js
require('ts-node/register');
複製代碼

Mocha 自身支持瀏覽器和 Node 端測試,爲了在瀏覽器端測試咱們須要寫一個 html, 裏面使用 <script src="mocha.min.js"> 的文件,而後再將本地全部文件插入到html中才能完成測試,手動作工程化效率比較低,因此須要藉助工具來實現這個任務,這個工具就是 Karma

Karma 本質上就是在本地啓動一個web服務器,而後再啓動一個外部瀏覽器加載一個引導腳本,這個腳本將咱們全部的源文件和測試文件加載到瀏覽器中,最終就會在瀏覽器端執行咱們的測試用例代碼。因此使用 Karma + mocha +chai 便可搭建一個完整的瀏覽器端的單元測試工具鏈。

npm install karma mocha chai karma-mocha karma-chai --save-dev
npx karma init
// Which testing framework do you want to use: mocha
// Do you want to use Require.js: no
// Do you want capture any browsers automatically: Chrome
複製代碼

這裏 Karma 初始化時選擇了 Mocha 的支持,而後第二個 Require.js 通常爲否,除非業務代碼中使用了amd類型的包。第三個選用 Chrome 做爲測試瀏覽器。 而後再在代碼裏單獨配置下 chai

// karma.conf.js
module.exports = function(config) {
  config.set({

    // base path that will be used to resolve all patterns (eg. files, exclude)
    basePath: '',

    // frameworks to use
    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
- frameworks: ['mocha'],
+ frameworks: ['mocha', 'chai'],

    // list of files / patterns to load in the browser
    files: [],
複製代碼

Karmaframeworks 做用是在全局注入一些依賴,這裏的配置就是將 Mochachai 提供的測試相關工具暴露在全局上供代碼裏使用。 Karma 只是將咱們的文件發送到瀏覽器去執行,可是根據前文所述咱們的代碼須要通過 webpackbrowserify 打包後才能運行在瀏覽器端。

若是原始代碼已是 CJS了,可使用 browserify 來支持瀏覽器端運行,基本零配置,可是每每現實世界比較複雜,咱們有 ES6 JSX 以及 TypeScript 要處理,因此這裏咱們使用 webpack

下面是 webpack 的配置信息。

npm install karma-webpack@4 webpack@4 @babel/core @babel/preset-env @babel/preset-react babel-loader --save-dev
複製代碼
// karma.conf.js
module.exports = function(config) {
  config.set({

    // base path that will be used to resolve all patterns (eg. files, exclude)
    basePath: '',

    // frameworks to use
    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
    frameworks: ['mocha', 'chai'],


    // list of files / patterns to load in the browser
    files: [
+ { pattern: "test/*.test.js", watched: false }
    ],

    preprocessors: {
+ 'test/**/*.js': [ 'webpack']
    },

+ webpack: {
+ module: {
+ rules: [{
+ test: /.*\.js/,
+ use: 'babel-loader'
+ }]
+ }
+ },
複製代碼
// .babelrc
{
    "presets": ["@babel/preset-env", "@babel/preset-react"]
}
複製代碼

這裏咱們測試一個React 程序代碼以下

// js/index.js
import React from 'react';
import ReactDOM from 'react-dom';

export function renderToPage(str) {
    const container = document.createElement('div');
    document.body.appendChild(container);
    console.log('there is real browser');
    return new Promise(resolve => {
        ReactDOM.render(<div>{ str } </div>, container, resolve);
    });
}

// test/index.test.js
import { renderToPage } from '../js/index';

describe('renderToPage', () => {
    it ('should render to page', async function () {
        let content = 'magic string';
        await renderToPage(content);
        expect(document.documentElement.innerText).to.be.contain(content);
    })
})
複製代碼

而且打開了本地瀏覽器

karma browser

能夠看到如今已經在真實瀏覽器中運行測試程序了。

由於圖形化的測試對 CI 機器不友好,因此能夠選擇 puppeteer 代替 Chrome

再者這些都是很重的包,若是對真實瀏覽器依賴性不強,可使用 JSDOMNode 端模擬一個瀏覽器環境。

稍微總結下工具鏈

  • 在 Node 環境下測試工具鏈能夠爲 : mocha + chai + babel
  • 模擬瀏覽器環境能夠爲 : mocha + chai + babel + jsdom
  • 在真實瀏覽器環境下測試工具鏈能夠爲 : karma + mocha + chai + webpack + babel

一個測試流水線每每須要不少個工具搭配使用,配置起來比較繁瑣,還有一些額外的工具例如單元覆蓋率(istanbul),函數/時間模擬 (sinon.js)等工具。工具之間的配合有時候不必定可以完美契合,選型費時費力。

jasmine 的出現就稍微緩解了一下這個問題,但也不夠完整,jasmine提供一個測試框架,裏面包含了 測試流程框架,斷言函數,mock工具等測試中會遇到的工具。能夠近似地看做 jasmine = mocha + chai + 輔助工具

接下來試一試 jasmine 的工做流程。

使用 npx jasmine init 初始化以後會在當前目錄中生成spec目錄, 其中包含一份默認的配置文件

// ./spec/support/jasmine.json
{
  "spec_dir": "spec",
  "spec_files": [
    "**/*[sS]pec.js"
  ],
  "helpers": [
    "helpers/**/*.js"
  ],
  "stopSpecOnExpectationFailure": false,
  "random": true
}
複製代碼

若是但願加載一些全局的配置能夠在 spec/helpers 目錄中放一些js文件, 正如配置所言,jasmine 在啓動時會去執行 spec/helpers 目錄下的全部js文件。

好比咱們經常使用 es6語法,就須要增長es6的支持。

新增 spec/helpers/babel.js 寫入以下配置便可。

npm install @babel/register @babel/core @babel/preset-env --save-dev
複製代碼
// spec/helpers/babel.js
require('babel-register');
複製代碼
// .babelrc
{
    "presets": ["@babel/preset-env"]
}
複製代碼

mocha 同樣,若是須要 TypeScript 的支持,可使用以下配置

npm install ts-node typescript --save-dev
複製代碼
// spec/helpers/typescript.js
require('ts-node/register');
複製代碼

配置文件中的 spec_dirjasmine約定的用例文件目錄,spec_files規定了用例文件格式爲 xxx.spec.js

有了這份默認配置就能夠按照要求寫用例,例如

// ./spec/index.spec.js
import { multiple } from '../index.js';

describe('Multiple', () => {
    it ('should be a function', () => {
        expect(multiple).toBeInstanceOf(Function);
    })

    it ('should 7 * 2 = 14', () => {
        expect(multiple(7, 2)).toEqual(14);
    })

    it ('should 7 * -2 = -14', () => {
        expect(multiple(7, -2)).toEqual(-14);
    })
})
複製代碼

jasmine 的斷言風格和 chai 很不同,jasmineAPI 以下,與 chai 相比少寫了不少 . ,並且支持的功能更加清晰,不用考慮如何組合使用的問題,並且下文介紹的 jest 測試框架也是使用這種風格。

nothing()
toBe(expected)
toBeCloseTo(expected, precisionopt)
toBeDefined()
toBeFalse()
toBeFalsy()
toBeGreaterThan(expected)
toBeGreaterThanOrEqual(expected)
toBeInstanceOf(expected)
toBeLessThan(expected)
toBeLessThanOrEqual(expected)
toBeNaN()
toBeNegativeInfinity()
toBeNull()
toBePositiveInfinity()
toBeTrue()
toBeTruthy()
toBeUndefined()
toContain(expected)
toEqual(expected)
toHaveBeenCalled()
toHaveBeenCalledBefore(expected)
toHaveBeenCalledOnceWith()
toHaveBeenCalledTimes(expected)
toHaveBeenCalledWith()
toHaveClass(expected)
toHaveSize(expected)
toMatch(expected)
toThrow(expectedopt)
toThrowError(expectedopt, messageopt)
toThrowMatching(predicate)
withContext(message) → {matchers}
複製代碼

運行 jasmine 便可生成測試報告

默認的測試報告不是很直觀, 若是但願提供相似 Mocha 風格的報告能夠安裝 jasmine-spec-reporter ,在 spec/helpers 目錄中添加一個配置文件, 例如spec/helpers/reporter.js

const SpecReporter = require('jasmine-spec-reporter').SpecReporter;

jasmine.getEnv().clearReporters();               // remove default reporter logs
jasmine.getEnv().addReporter(new SpecReporter({  // add jasmine-spec-reporter
  spec: {
    displayPending: true
  }
}));
複製代碼

此時輸出的用例報告以下

jasmine

若是在 Jasmine 中執行 DOM 級別的測試,就依然須要藉助 KarmaJSDOM了,具體的配置這裏就再也不贅述。

總結下 Jasmine 的工具鏈

  1. Node 環境下測試 : Jasmine + babel
  2. 模擬 JSDOM 測試 : Jasmine + JSDOM + babel
  3. 真實瀏覽器測試 : Karma + Jasmine + webpack + babel

JEST

jest

Jestfacebook 出的一個完整的單元測試技術方案,集 測試框架, 斷言庫, 啓動器, 快照,沙箱,mock工具於一身,也是 React 官方使用的測試工具。JestJasmine 具備很是類似的 API ,因此在 Jasmine 中用到的工具在 Jest 中依然能夠很天然地使用。能夠近似看做 Jest = JSDOM 啓動器 + Jasmine

雖然 Jest 提供了很豐富的功能,可是並無內置 ES6 支持,因此依然須要根據不一樣運行時對代碼進行轉換,因爲 Jest 主要運行在 Node 中,因此須要使用 babel-jestES Module 轉換成 CommonJS

Jest 的默認配置

npm install jest --save-dev
npx jest --init
√ Would you like to use Jest when running "test" script in "package.json"? ... yes
√ Would you like to use Typescript for the configuration file? ... no
√ Choose the test environment that will be used for testing » jsdom (browser-like)
√ Do you want Jest to add coverage reports? ... no
√ Which provider should be used to instrument code for coverage? » babel
√ Automatically clear mock calls and instances between every test? ... yes
複製代碼

NodeJSDOM 下增長 ES6代碼的支持

npm install jest-babel @babel/core @babel/preset-env
複製代碼
// .babelrc
{
    "presets": ["@babel/preset-env"]
}
複製代碼
// jest.config.js
// 下面兩行爲默認配置,不寫也能夠
{
+ testEnvironment: "jsdom",
+ transform: {"\\.[jt]sx?$": "babel-jest"}
}
複製代碼

使用 Jest 生成測試報告

jest

對於 ReactTypeScript 支持也能夠經過修改 babel 的配置解決

npm install @babel/preset-react @babel/preset-typescript --save-dev
複製代碼
// .babrlrc
{
    "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"]
}
複製代碼

Jest 在真實瀏覽器環境下測試

目前 Jest 不支持直接在真實瀏覽器中進行測試,其默認的啓動器只提供了一個 JSDOM 環境,在瀏覽器中進行單元測試目前只有 Karma 方案能作到,因此也可使用 Karma + Jest 方案實現,可是不建議這麼作,由於 Jest 自身過重,使用 Karma + Jasmine 能達到基本同樣的效果。

另外還有一個比較流行的 E2E 方案 Jest + Puppeteer , 因爲 E2E 不屬於單元測試範疇,這裏再也不展開。

Jest 工具鏈總結

  • Node 環境下測試 : Jest + babel
  • JSDOM 測試 : Jest + babel
  • 真實瀏覽器測試(不推薦)
  • E2E 測試 : Jest + Puppeteer
稍做總結

上面的內容介紹了 chai , mocha , karma , jasminejest, 每種工具分別對應一些本身特有的工具鏈,在選取合適的測試工具時根據實際須要選擇, 測試領域還有很是多的工具數都數不過來,下面來看下 React 單元測試的一些方法。

使用 Jest + Enzyme 對 React 進行單元測試

enzyme

Enzyme基礎配置以下:

npm install enzyme enzyme-adapter-react-16 jest-enzyme jest-environment-enzyme jest-canvas-mock react@16 react-dom@16 --save-dev
複製代碼
// jest.config.js
{
- "testEnvironment": "jsdom",
+ setupFilesAfterEnv: ["jest-enzyme", "jest-canvas-mock"],
+ testEnvironment: "enzyme",
+ testEnvironmentOptions: {
+ "enzymeAdapter": "react16"
+ },
}
複製代碼

jest-canvas-mock 這個包是爲了解決一些使用 JSDOM 未實現行爲觸發警告的問題。

上面創建了一個使用 Enzyme 比較友好的環境,能夠直接在全局做用域裏引用 React , shallow, mountAPI。此外 Enzyme 還註冊了許多友好的斷言函數到 Jest 中,以下所示,參考地址

toBeChecked()
toBeDisabled()
toBeEmptyRender()
toExist()
toContainMatchingElement()
toContainMatchingElements()
toContainExactlyOneMatchingElement()
toContainReact()
toHaveClassName()
toHaveDisplayName()
toHaveHTML()
toHaveProp()
toHaveRef()
toHaveState()
toHaveStyle()
toHaveTagName()
toHaveText()
toIncludeText()
toHaveValue()
toMatchElement()
toMatchSelector()
複製代碼
// js/ClassComponent.js
import React from 'react';

export default class ClassComponent extends React.PureComponent {
    constructor() {
        super();
        this.state = { name: 'classcomponent' };
    }
    render() {
        return (
            <div> a simple class component <CustomComponent /> </div>
        );
    }
}

// test/hook.test.js
import HookComponent from '../js/HookComponent';

describe('HookComponent', () => {
    it ('test with shallow', () => {
        const wrapper = shallow(<HookComponent id={1} />);
        expect(wrapper).toHaveState('name', 'classcomponent');
        expect(wrapper).toIncludeText('a simple class component');
        expect(wrapper).toContainReact(<div>a simple class component</div>);
        expect(wrapper).toContainMatchingElement('CustomComponent');
    })
})
複製代碼

Enzyme 提供了三種渲染組件方法

  • shallow 使用 react-test-renderer 將組件渲染成內存中的對象, 能夠方便進行 props, state 等數據方面的測試,對應的操做對象爲 ShallowWrapper,在這種模式下僅能感知到第一層自定義子組件,對於自定義子組件內部結構則沒法感知。
  • mount 使用 react-dom 渲染組件,會建立真實 DOM 節點,比 shallow 相比增長了可使用原生 API 操做 DOM 的能力,對應的操做對象爲 ReactWrapper ,這種模式下感知到的是一個完整的 DOM 樹。
  • render 使用 react-dom-server 渲染成 html 字符串,基於這份靜態文檔進行操做,對應的操做對象爲 CheerioWrapper

Shallow 渲染

由於 shallow 模式僅能感知到第一層自定義子組件組件,每每只能用於簡單組件測試。例以下面的組件

// js/avatar.js
function Image({ src }) {
    return <img src={src} />;
}

function Living({ children }) {
    return <div className="icon-living"> { children } </div>;
}

function Avatar({ user, onClick }) {
    const { living, avatarUrl } = user;
    return (
        <div className="container" onClick={onClick}> <div className="wrapper"> <Living > <div className="text"> 直播中 </div> </Living> </div> <Image src={avatarUrl} /> </div>
    )
}

export default Avatar;
複製代碼

shallow 渲染雖然不是真正的渲染,可是其組件生命週期會完整地走一遍。

使用 shallow(<Avatar />) 能感知到的結構以下, 注意看到 div.text 做爲 Living 組件的 children 可以被檢測到,可是 Living 的內部結構沒法感知。

shallow

Enzyme 支持的選擇器支持咱們熟悉的 css selector 語法,這種狀況下咱們能夠對 DOM 結構作以下測試

// test/avatar.test.js
import Avatar from '../js/avatar';

describe('Avatar', () => {
    let wrapper = null, avatarUrl = 'abc';

    beforeEach(() => {
        wrapper = shallow(<Avatar user={{ avatarUrl: avatarUrl }} />);
    })

    afterEach(() => {
        wrapper.unmount();
        jest.clearAllMocks();
    })

    it ('should render success', () => {
        // wrapper 渲染不爲空
        expect(wrapper).not.toBeEmptyRender();
        // Image 組件渲染不爲空, 這裏會執行 Image 組件的渲染函數
        expect(wrapper.find('Image')).not.toBeEmptyRender();
        // 包含一個節點
        expect(wrapper).toContainMatchingElement('div.container');
        // 包含一個自定義組件
        expect(wrapper).toContainMatchingElement("Image");
        expect(wrapper).toContainMatchingElement('Living');
        // shallow 渲染不包含子組件的內部結構
        expect(wrapper).not.toContainMatchingElement('img');
        // shallow 渲染包含 children 節點
        expect(wrapper).toContainMatchingElement('div.text');
        // shallow 渲染能夠對 children 節點內部結構作測試
        expect(wrapper.find('div.text')).toIncludeText('直播中');
    })
})

複製代碼

若是咱們想去測試對應組件的 props / state 也能夠很方便測試,不過目前存在缺陷,Class Component 能經過 toHaveProp, toHaveState 直接測試, 可是 Hook 組件沒法測試 useState

it ('Image component receive props', () => {
  const imageWrapper = wrapper.find('Image');、
  // 對於 Hook 組件目前咱們只能測試 props
  expect(imageWrapper).toHaveProp('src', avatarUrl);
})
複製代碼

wrapper.find 雖然會返回一樣的一個 ShallowWrapper 對象,可是這個對象的子結構是未展開的,若是想測試imageWrapper 內部結構,須要再 shallow render 一次。

it ('Image momponent receive props', () => {
  const imageWrapper = wrapper.find('Image').shallow();

  expect(imageWrapper).toHaveProp('src', avatarUrl);
  expect(imageWrapper).toContainMatchingElement('img');
  expect(imageWrapper.find('img')).toHaveProp('src', avatarUrl);
})
複製代碼

也能夠改變組件的 props, 觸發組件重繪

it ('should rerender when user change', () => {
    const newAvatarUrl = '' + Math.random();
    wrapper.setProps({ user: { avatarUrl: newAvatarUrl }});
    wrapper.update();
    expect(wrapper.find('Image')).toHaveProp('src', newAvatarUrl);
})
複製代碼

另外一個常見的場景是事件模擬,事件比較接近真實測試場景,這種場景下使用 shallow 存在諸多缺陷,由於 shallow 場景事件不會像真實事件同樣有捕獲和冒泡流程,因此此時只能簡單的觸發對應的 callback 達到測試目的。

it ('will call onClick prop when click event fired', () => {
    const fn = jest.fn();

    wrapper.setProps({ onClick: fn });
    wrapper.update();

    // 這裏觸發了兩次點擊事件,可是 onClick 只會被調用一次。
    wrapper.find('div.container').simulate('click');
    wrapper.find('div.wrapper').simulate('click');
    expect(fn).toHaveBeenCalledTimes(1);
})
複製代碼

關於這些網上有人總結了 shallow 模式下的一些不足

  1. shallow 渲染不會進行事件冒泡,而 mount 會。
  2. shallow 渲染由於不會建立真實 DOM,因此組件中使用 refs 的地方都沒法正常獲取,若是確實須要使用 refs , 則必須使用 mount
  3. simulatemount 中會更加有用,由於它會進行事件冒泡。

其實上面幾點說明了一個現象是 shallow 每每只適合一種理想的場景,一些依賴瀏覽器行爲表現的操做 shallow 沒法知足,這些和真實環境相關的就只能使用mount了。

Mount 渲染

Mount 渲染的對象結構爲 ReactWrapper 其提供了和 ShallowWrapper 幾乎同樣的 API , 差別很小。

API層面的一些差別以下

+ getDOMNode() 獲取DOM節點
+ detach() 卸載React組件,至關於 unmountComponentAtNode
+ mount() 掛載組件,unmount以後經過這個方法從新掛載
+ ref(refName) 獲取 class component 的 instance.refs 上的屬性
+ setProps(nextProps, callback)
- setProps(nextProps)
- shallow()
- dive()
- getElement()
- getElements()
複製代碼

另外因爲 mount 使用 ReactDOM 進行渲染,因此其更加接近真實場景,在這種模式下咱們能觀察到整個 DOM 結構和React組件節點結構。

mount

describe('Mount Avatar', () => {
    let wrapper = null, avatarUrl = '123';

    beforeEach(() => {
        wrapper = mount(<Avatar user={{ avatarUrl }} />);
    })

    afterEach(() => {
        jest.clearAllMocks();
    })

    it ('should set img src with avatarurl', () => {
        expect(wrapper.find('Image')).toExist();
        expect(wrapper.find('Image')).toHaveProp('src', avatarUrl);
        expect(wrapper.find('img')).toHaveProp('src', avatarUrl);
    })
})
複製代碼

shallow 中沒法模擬的事件觸發問題在 mount 下就再也不是問題。

it ('will call onClick prop when click event fired', () => {
    const fn = jest.fn();

    wrapper.setProps({ onClick: fn });
    wrapper.update();

    wrapper.find('div.container').simulate('click');
    wrapper.find('div.wrapper').simulate('click');
    expect(fn).toHaveBeenCalledTimes(2);
})
複製代碼

總結一下 shallow 中能作的 mount 都能作,mount中能作的 shallow 不必定能作。

Render 渲染

render 內部使用 react-dom-server 渲染成字符串,再通過 Cherrio 轉換成內存中的結構,返回 CheerioWrapper 實例,可以完整地渲染整個DOM 樹,可是會將內部實例的狀態丟失,因此也稱爲 Static Rendering 。這種渲染可以進行的操做比較少,這裏也不做具體介紹,能夠參考 官方文檔

總結

若是讓我推薦的話,對於真實瀏覽器我會推薦 Karma + Jasmine 方案測試,對於 React 測試 Jest + EnzymeJSDOM 環境下已經能覆蓋大部分場景。另外測試 React組件除了 Enzyme 提供的操做, Jest 中還有不少其餘有用的特性,好比能夠 mock 一個 npm 組件的實現,調整 setTimeout 時鐘等,真正進行單元測試時,這些工具也是必不可少的,整個單元測試技術體系包含了不少東西,本文沒法面面俱到,只介紹了一些距離咱們最近的相關的技術體系。

參考

  1. medium.com/building-ib…
  2. medium.com/@turhan.oz/…
  3. www.liuyiqi.cn/2015/10/12/…
  4. jestjs.io/docs/en
  5. blog.bitsrc.io/how-to-test…
  6. www.freecodecamp.org/news/testin…
  7. www.reddit.com/r/reactjs/c…

本文發佈自 網易雲音樂大前端團隊,文章未經受權禁止任何形式的轉載。咱們常年招收前端、iOS、Android,若是你準備換工做,又剛好喜歡雲音樂,那就加入咱們 grp.music-fe(at)corp.netease.com!

相關文章
相關標籤/搜索