乐趣区

关于javascript:前端单元测试技术方案总结

本文作者: 江水

本文次要介绍前端 单元测试 的一些技术计划。

单元测试的技术计划很多,不同工具之间有相互协同,也存在性能重合,给咱们搭配测试计划带来不小的艰难,而且随着 ES6, TypeScript 的呈现,单元测试又减少了很多其余步骤,残缺配置起来往往须要很大的工夫老本。我心愿通过对这些工具的各自作用的把握,理解残缺的前端测试技术计划。前端单元测试的畛域也很多,这里次要讲对于前端组件如何进行单元测试,最初会次要介绍下对于 React 组件的一些测试方法总结。

通用测试

单元测试最外围的局部就是做断言,比方传统语言中的 assert 函数,如果以后程序的某种状态合乎 assert 的冀望此程序能力失常执行,否则间接退出利用。所以咱们能够间接用 Node 中自带的 assert 模块做断言。

用最简略的例子做个验证

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));

这种例子可能满足根底场景的应用,也能够作为一种单元测试的办法。

nodejs 自带的 assert 模块提供了上面一些断言办法,只能满足一些简略场景的须要。

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 最终执行下来会产生上面的报告:

$ 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 端的断言库。同时察看下面的输入能够发现,这个报告更像是程序的错误报告,而不是一个单元测试报告。而咱们在做单元测时往往须要断言库可能提供良好的测试报告,这样能力高深莫测地看到有哪些断言通过没通过,所以应用业余的单元测试断言库还是很有必要。

chai

chai 是目前很风行的断言库,相比于同类产品比较突出。chai 提供了 TDD (Test-driven development)和 BDD (Behavior-driven development) 两种格调的断言函数,这里不会过多介绍两种格调的优缺,本文次要以 BDD 格调做演示。

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 是一个经典的测试框架(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

AMD 是 RequireJS 推广过程中风行的一个比拟老的标准,目前无论浏览器还是 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);
    })
})

并且关上了本地浏览器

能够看到当初曾经在实在浏览器中运行测试程序了。

因为图形化的测试对 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 中执行 DOM 级别的测试,就仍然须要借助 KarmaJSDOM了,具体的配置这里就不再赘述。

总结下 Jasmine 的工具链

  1. Node 环境下测试 : Jasmine + babel
  2. 模仿 JSDOM 测试 : Jasmine + JSDOM + babel
  3. 实在浏览器测试 : Karma + Jasmine + webpack + babel

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 生成测试报告

对于 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根底配置如下:

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 ![]({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 的内部结构无奈感知。

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 组件节点构造。

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. https://medium.com/building-i…
  2. https://medium.com/@turhan.oz…
  3. https://www.liuyiqi.cn/2015/1…
  4. https://jestjs.io/docs/en
  5. https://blog.bitsrc.io/how-to…
  6. https://www.freecodecamp.org/…
  7. https://www.reddit.com/r/reac…

本文公布自 网易云音乐大前端团队,文章未经受权禁止任何模式的转载。咱们长年招收前端、iOS、Android,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!


title: 前端单元测试技术计划总结

description: 单元测试的技术计划很多,不同工具之间有相互协同,也存在性能重合,给咱们搭配测试计划带来不小的艰难,而且随着 ES6, TypeScript 的呈现,单元测试又减少了很多其余步骤,残缺配置起来往往须要很大的工夫老本。

本文作者: 江水

前端单元测试技术计划总结

本文次要介绍前端 单元测试 的一些技术计划。
单元测试的技术计划很多,不同工具之间有相互协同,也存在性能重合,给咱们搭配测试计划带来不小的艰难,而且随着 ES6, TypeScript 的呈现,单元测试又减少了很多其余步骤,残缺配置起来往往须要很大的工夫老本。我心愿通过对这些工具的各自作用的把握,理解残缺的前端测试技术计划。前端单元测试的畛域也很多,这里次要讲对于前端组件如何进行单元测试,最初会次要介绍下对于 React 组件的一些测试方法总结。

通用测试

单元测试最外围的局部就是做断言,比方传统语言中的 assert 函数,如果以后程序的某种状态合乎 assert 的冀望此程序能力失常执行,否则间接退出利用。所以咱们能够间接用 Node 中自带的 assert 模块做断言。
用最简略的例子做个验证

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));

这种例子可能满足根底场景的应用,也能够作为一种单元测试的办法。
nodejs 自带的 assert 模块提供了上面一些断言办法,只能满足一些简略场景的须要。

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 最终执行下来会产生上面的报告:

$ 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 端的断言库。同时察看下面的输入能够发现,这个报告更像是程序的错误报告,而不是一个单元测试报告。而咱们在做单元测时往往须要断言库可能提供良好的测试报告,这样能力高深莫测地看到有哪些断言通过没通过,所以应用业余的单元测试断言库还是很有必要。

chai


chai 是目前很风行的断言库,相比于同类产品比较突出。chai 提供了 TDD (Test-driven development)和 BDD (Behavior-driven development) 两种格调的断言函数,这里不会过多介绍两种格调的优缺,本文次要以 BDD 格调做演示。

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 是一个经典的测试框架(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

AMD 是 RequireJS 推广过程中风行的一个比拟老的标准,目前无论浏览器还是 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);
 })
})


并且关上了本地浏览器

能够看到当初曾经在实在浏览器中运行测试程序了。
因为图形化的测试对 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 中执行 DOM 级别的测试,就仍然须要借助 KarmaJSDOM了,具体的配置这里就不再赘述。
总结下 Jasmine 的工具链

  1. Node 环境下测试 : Jasmine + babel
  2. 模仿 JSDOM 测试 : Jasmine + JSDOM + babel
  3. 实在浏览器测试 : Karma + Jasmine + webpack + babel

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 生成测试报告

对于 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根底配置如下:

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 的内部结构无奈感知。

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 组件节点构造。

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. https://medium.com/building-i…
  2. https://medium.com/@turhan.oz…
  3. https://www.liuyiqi.cn/2015/1…
  4. https://jestjs.io/docs/en
  5. https://blog.bitsrc.io/how-to…
  6. https://www.freecodecamp.org/…
  7. https://www.reddit.com/r/reac…

本文公布自 网易云音乐大前端团队,文章未经受权禁止任何模式的转载。咱们长年招收前端、iOS、Android,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!

退出移动版