前端单元测试技术方案总结

本文做者: 江水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!

相关文章
相关标签/搜索