Jest & enzyme 进行react单元测试

10,166 阅读12分钟

下面的文章会默认读者了解 React及其技术栈以及基本的前端单元测试,由于本文涉及到的技术较多,故不宜一一在文中介绍,谅解。

写在前面

在撰写单元测试用例之前,我们需要了解到撰写测试用例的原因。写测试用例的目的在于保证代码的迭代安全,并不是为了100%的coverage或者是case pass,coverage和case仅仅是为了实现代码安全的因素。

单元测试(Unit Test):前端单元测试,在以前也许是一个比较陌生的工作,但是前端在经历了这几年的发展之后,我们对于代码的鲁棒性要求逐渐提升,承载了更多的业务逻辑的同时,作为整个链路上最接近用户的部分,系统崩溃阻塞的成本非常之高。如果你采用的是SSR,那么直接在服务端渲染报错则是更为致命的。

前端的单元测试能够在一定程度上保证:

  • 在迭代过程中保证每次提交的代码的质量;
  • 在代码的重构过程中,原始功能的完整性;
  • 每次代码迭代的副作用可控;

相对于后端代码来说,前端代码更多地会涉及到DOM相关的内容,对于非结构化的内容如何进行测试呢?

airbnb提供了一个比较合适的React单元测试解决方案,结合Jest以及husky,可以保证每次commit的代码都符合规范,并且coverage内的代码功能完整。

UT之于library

库对于单元测试的要求是非常高的。因为一个lib可能被多个业务线以及工程所引入,一旦这个lib出现了任何问题,影响到的范围是非常大的。我们又不可能要求QA对于多个业务线进行回归(怕是他们要杀了我们祭天吧)。

为了保证lib的迭代不会影响到原有的业务功能,单元测试是一个非常好的方法。由于我们主要的技术栈还是基于React的各种解决方案,所以有比较多的业务组件以及公共组件,这些组件被多个业务线使用。lerna架构的组件工程在每次commit的时候都会跑UT,来进行功能回归。

UT之于业务

业务代码一般对于单元测试的需求并不如lib那样高,但是在某些核心业务逻辑中接入UT,也是可以保证代码整体的质量的。最起码可以保证业务代码在正常的渲染过程中不发生报错。

框架

前面简单描述了一下单元测试对于前端代码的重要性,很多人说现在的前端圈子和娱乐圈一样,确实,目前可选的测试框架林林总总有很多,经历了jasmine、mocha,现在来到了Jest。

TL;DR

9102年了,Jest可以说是目前前端最好的测试框架了。可以进行快速配置,和enzyme很好地结合,能够保证在React技术栈中,快速跑起来一个测试用例。

但是,最吸引人的还是其内置的coverage报告,可以快速生成代码覆盖率。

相比于测试框架,React的测试库似乎没有什么其他的选择了,enzyme基本可以满足任何前端的测试需求。但是对于异步强交互的页面来说,撰写测试用例的学习成本还是比较高的。

技术栈

最终我们为了各种场景下React的单元测试,集成了下面的lib:

  • Jest:单元测试框架
  • enzyme: React测试库
  • Nock: 异步请求模拟
  • Async-wait-until: 异步操作结束通知
  • Husky: pre-commit阶段执行单元测试

配置

Jest

Jest本身就以配置简单著称,而enzyme更是可以即插即用的测试库。所以配置过程要比较轻松。

module.exports = {
    // 单元测试环境根目录
    rootDir: path.resolve(__dirname),
    // 指定需要进行单元测试的文件匹配规则
    testMatch: [
        '<rootDir>/test/**/__test__/*.js'
    ],
    // 需要忽略的文件匹配规则
    testPathIgnorePatterns: [
        '/node/modules'
    ],
    testURL: 'http://localhost/',
    // 是否收集测试覆盖率,以及覆盖率文件路径
    collectCoverage: true,
    coverageDirectory: './coverage'
};

上面是几个比较重要的配置项。其中大部分都是比较好理解的,而testURL这个配置项需要说明一下,这个规则表示当前测试用例所运行的URL,虽然测试的时候我们看不到完整的页面,但是测试用例本身是挂载到一个页面中的,而这个页面的URL就是通过testURL指定的。

在这个Jest配置下,所有的测试用例中,如果执行location.href都会拿到http://localhost/这个URL的,这个配置项在进行需要网络请求的case中是很关键的。

在执行的时候,可以指定Jest的配置文件路径:

~ jest --config ./scripts/jest.config.js

如果没有指定文件路径的话,默认则是取当前文件路径的配置文件。

enzyme

enzyme本身是不需要配置的,作为一个即插即用的React测试库,也算是让我们前端脱离了配置工程师的苦海。

但是基于React进行开发,则需要安装对应的React Adapter,比如如果你需要使用static getDerivedStateFromProps方法,那么就需要引入enzyme-adapter-react-16的库来保证enzyme渲染的版本和你使用的版本是一致的。

Jest在进行UT的过程中,会首先检查工程是否有配置.babelrc文件,如果配置了,则会自动根据这个文件来进行babel编辑,然后执行测试用例。

一个随手搭建的演示环境的依赖:

  "dependencies": {
    "react": "^16.7.0",
    "react-dom": "^16.7.0"
  },
  "devDependencies": {
    "babel-plugin-transform-async-to-generator": "^6.24.1",
    "babel-plugin-transform-class-properties": "^6.24.1",
    "babel-preset-env": "^1.7.0",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "babel-preset-stage-0": "^6.24.1",
    "babel-preset-stage-3": "^6.24.1",
    "enzyme-adapter-react-16": "^1.7.1",
    "enzyme": "^3.8.0",
    "jest": "^23.6.0"
  },
  "scripts": {
    "test": "jest --config ./jest.config.js"
  }
// ./__test__/index.js
import Test from '../src';
import Enzyme, { shallow, render, mount } from 'enzyme';
import React from 'react';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
    adapter: new Adapter()
});

而enzyme的adapter是需要进行初始化的,通过Enzyme.configure指定需要引入的adapter实例。

这样就完成了一个Enzyme + React + Jest的环境。

撰写一个简单的测试用例

断言

目前,各种测试框架的断言已经开始收敛,Jest采用的断言语法和我们之前使用的mocha语法类似。

一个test suite可以用describe来描述,一个test suite可以包含多个case,来测试各种场景下的组件渲染结果。

我们先给出一个非常简单的React组件:

import React from 'react';

export default class Text extends React.Component {
    render() {
        return (<div className="test-container" />)
    };
}

对于这个组件,我们需要判断是否成功渲染出来了div元素,并且元素的类名是test-container

这是一个极简版本的case:

describe('test suite: Test component', () => {
    it('case: expect Test render a div with className: test-container', () => {
        const wrapper = shallow(<Test />);

        expect(wrapper.find('.test-container').length).toEqual(1);
    });
});

执行npm run test,可以得到下面的结果:

测试1

可以看到suites和cases的通过情况,以及各种覆盖率结果。其实前端单元测试也可以这么简单的。

关于enzyme的三个核心渲染方法,mount、render以及shallow,网上有很多文章介绍三者之间的区别,这里就不班门弄斧了。mount应该是我写测试用例最常用的方法吧,毕竟大部分组件的逻辑都需要真实挂载出来,才能够进行用例测试。

测试用例也可以很复杂

最近有一个比较复杂的组件,需要接入单元测试,当时在开发的时候太天真,现在想起来真的是追悔莫及。组件内部包含:fetch请求、时间获取、history操作,并且含有非常多的人机交互逻辑

这样的组件现在想起来是非常不规范的,但是为了保证以后修改的时候,业务逻辑的鲁棒,也不得不强行为其添加单元测试。

下面有很多case,大部分case都是在实际coding过程中遇到的,希望能够帮助到有同样需求的人。

history和Date.now()

在业务代码中,很多时候我们都需要进行页面的跳转,或者hash的修改。所有对于location的操作都会落在window.location的对象上。

enzyme实际上为我们构建了一个虚拟的DOM环境,我们可以拿到对应的DOM元素以及windowdocument对象来进行DOM操作。

Date也是类似的,也是一个全局的对象,以前我们通过集成js-dom来进行模拟,而现在enzyme和Jest为我们做好了这些工作。

看下面这个组件:

class Time extends React.Component {
    static propTypes = {
        time: PropTypes.number
    };

    constructor(props) {
        super(props);
        this.state = {
            before: Date.now() < props.time
        }
    }
    
    render() {
        const { before } = this.state;
        const { time } = this.props;
        
        if (before) {
            return (
                <div className="before">
                    {`now is before time: ${time}`}
                </div>
            );
        } else {
            return (
                <div className="after">
                    {`now is after time: ${time}`}
                </div>
            );
        }
    }
}

在撰写单元测试的时候,我们会发现,由于当前时间的不一致,所以作为props传入的时间在和Date.now()进行比较,得到的结果是不一致的,这样会导致测试用例的结果不可控。

为了保证Date.now()得到的值是一致的,我们需要改写DOM上的Date对象。

describe('test suite: Time component', () => {
    const NOW_TO_CACHE = global.Date.now;
    const NOW_TO_USE = jest.fn(() => 1547717952668);

    beforeEach(() => {
        global.Date.now = NOW_TO_USE;
    });
    afterEach(() => {
        global.Date.now = NOW_TO_CACHE;
    });
    it('case: now is less than props\' time', () => {
        const wrapper = shallow(<Time time={1547717952669} />);

        console.log(Date.now())

        expect(wrapper.find('.before').length).toEqual(1);
    });

    it('case: now is greater than props\' time', () => {
        const wrapper = shallow(<Time time={1547717952667} />);

        console.log(Date.now())

        expect(wrapper.find('.after').length).toEqual(1);
    })
});

beforeEachafterEach两个hook在每一个case执行之前或者之后,会分别执行,在每个case之前,进行global.Date.now的改写,然后在case结束之后,将global.Date.now恢复为原本的方法。

jest.fn会生成一个Mock函数,这个函数和其他函数不一样的地方在于,这个函数会记录到其被执行的一些信息,比如:

  • 函数被执行的次数
  • 函数每次被执行时的参数
  • 甚至是函数每次被调用时的this指向

Date.now

可以看到,对于所有的Date.now()方法,得到的当前时间都被复写成了一个确定的数字,这样就可以保证你的测试用例的时间无关性。

对于historyDate.now这类挂载到window或者document上面的实例对象,我们都可以通过jest.fn来复写其方法,保证这些方法被调用的顺序以及调用结果的正确性,我们也可以在jest.fn内部进行断言,从而判断每次执行的过程中是否发生错误。

fetch请求

前端作为View,部分场景下比较依赖后端提供的Model来进行渲染,API的正确性很多时候会直接影响到整个页面的渲染结果是否正确。

并且部分场景中,某些代码也许是在Promiseresolve了之后才会被调用。

所以我们需要模拟fetch请求,来保证在请求回调中的代码被单元测试覆盖到。

这里就需要用到:

Nock:HTTP server mocking and expectations library for Node.js

Async-wait-until:Wait while predicate completes and resolve a Promise

这两个库了。

首先,看下面这个组件:

import React from 'react';
import fetch from 'isomorphic-fetch';

export default class AsyncComponent extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            user: {}
        }
    }

    componentDidMount() {
        this.fetchUser()
            .then(res => {
                this.setState({user: res});
            });
    }

    fetchUser = () => {
        return fetch(`${location.origin}/api/user/get`, {
            method: 'GET'
        }).then(ret => {
            return ret.json();
        }).catch(err => {
            console.error(err);
        });
    }

    render() {
        const { user } = this.state;
        return (
            <div className="user-profile">
                <p className="name">{user.name}</p>
                <p className="age">{user.age}</p>
            </div>
        );
    }
}

组件内部在componentDidMount阶段进行了一次fetch请求,来在客户端渲染的时候获取数据,填充到页面中。

同步的测试工作非常简单,根据前面的几个例子,相信你可以对于渲染进行很好地测试了。

Q & A:

Q:其一:如何测试网络请求的回调呢?

我们不可能直接将UT的请求直接打到后台的接口里,这样在没有网络的环境下,UT是通过不了的。所以必须要在本地模拟到近似于真实的网络请求。

A:Nock

Q: 其二:网络请求时异步的,如果撰写异步的测试用例呢?

组件View的更新是在异步的请求resolve之后进行的,而测试用例的执行是同步的,这样就会出现时序问题,所以我们需要将断言和组件的fetch同步执行。

A: async-wait-until

这就是我们引入这两个库的原因了。具体如何结合这两个库来进行异步渲染的单元测试,看下面这个test suite。

import Async from '../src/async';
import Enzyme, { shallow, render, mount } from 'enzyme';
import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import nock from 'nock';
import waitUntil from 'async-wait-until';

Enzyme.configure({
    adapter: new Adapter()
});

describe('test suite: Async component', () => {
    beforeAll(() => {
        nock('http://localhost/api/user')
            .get('/get')
            .reply(200, {
                "name": "lucas",
                "age": 20
            });
    });

    afterAll(() => {
        nock.cleanAll();
    });

    it('case: expect component did mount will trigger re-render', async () => {
        const wrapper = mount(<Async />);

        await waitUntil(() => wrapper.state('user').name === 'lucas');

        expect(wrapper.find('.name').text()).toBe('lucas');
        expect(wrapper.find('.age').text()).toBe('20');
    });
});

上面的这个测试用例的核心在于模拟fetch请求,并且等在请求结束再执行对应的断言。

首先,我们为这个test suite增加了两个hook,beforeAll会在这个suite的所有case执行之前执行一次,而afterAll则会在所有的case全部执行完之后,执行一次。

beforeAll中,我们通过nock模拟了组件中fetch请求的请求结果,给到了一个resolve的响应。

当React执行到componentDidMount的时候,会进行fetch请求,这个请求会被打到nock中。这里注意到,我们fetch的URL是http://localhost/api/user/get,这就是之前提到的,Jest配置项中设置testURL的作用。testURL指定的URL会作为测试页面的location.origin

由于fetch是一个异步的过程,我们需要等待fetch被resolve之后,才能够进行断言。

所以,这里用到了waitUntil,这个函数接受一个函数作为参数,这个函数会返回一个bool值,当bool值为true的时候,表示异步调用结束,可以开始执行后面的逻辑了,当然,我们也可以封装一个自己的waitUntil,其本质就是封装一个Promise。

结束了这一个suite之后,代码逻辑会走到afterAll的hook中。这里面调用了nock.cleanAll(),用于对之前mock的接口进行清理,也就是规范这个mock的作用域仅仅位于当前的suite中。

这时,我们再跑一次npm run test,可以得到下面的测试结果:

test async

结合上面的test suite,在单元测试中成功进行了fetch,并且渲染出了正确的结果。

但是细心的小伙伴可能会发现,coverage报告中有一行代码没有被这个test suite覆盖到,这行代码可以定位到fetch的reject中,因为我们仅仅测试了fetch resolve的情况。

为了测试reject的情况,我们需要一个新的suite,在这个suite中,我们mock一个reject响应的接口:

describe('test suite: Async component', () => {
    let resolve = false;
    beforeAll(() => {
        nock('http://localhost/api/user')
            .get('/get')
            .reply(400, () => {
                resolve = true;
            });
    });

    afterAll(() => {
        nock.cleanAll();
    });

    it('case: expect component fetch error will not block rendering', async () => {
        const wrapper = mount(<Async />);

        await waitUntil(() => resolve);

        expect(wrapper.find('.name').text()).toBe('');
        expect(wrapper.find('.age').text()).toBe('');
    });
});

由于请求是异步的,并且与resolve的情况不同,我们不知道何时请求会被reject,所以我们需要给nock传入一个回调,来标识fetch结束,请求被reject。

这样就可以测试到reject情况下页面是否成功渲染了,保证了各种condition下,页面或者组件的稳定。

async error test

交互模拟

作为链路中toC的部分,前端代码中有许多地方是需要进行人机交互的。在交互过程中,javascript主要以注册事件的方式进行交互响应。

人机交互不仅仅是异步的,并且还包含事件的触发以及回调。这部分测试,enzyme提供了很多有意思的API,来帮助我们完成人机交互过程的单元测试。

考虑下面的这个组件:

import React from 'react';
import fetch from 'isomorphic-fetch';

export default class Text extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            value: ''
        };
    }

    onInputChanged = (e) => {
        this.setState({
            value: e.target.value
        });
    }

    onClicked = () => {
        const { value } = this.state;
        this.postValue(value)
            .then(res => {
                this.setState({
                    value: ''
                });
            });
    }

    postValue = (value) => {
        return fetch(`${location.origin}/api/value`, {
            method: 'POST',
            body: JSON.stringify({value}),
        }).then(ret => {
            return ret.json();
        });
    }

    render() {
        const { value } = this.state;
        return (
            <div className="form">
                <input value={value} onChange={this.onInputChanged} />
                <button className="submit" onClick={this.onClicked}>提交</button>
            </div>
        )
    }
}

这是一个常见的React输入框,我们将输入框的value绑定到state上面。期望能够通过用户输入来改变组件状态,在用户点击提交的时候,可以从页面中取到这个值,并且POST到服务端,在得到了正确的回调之后,清空掉输入框中的内容。

这种需求比较普遍,现在需要为这样一个需求添加一组单元测试,保证这个组件能够稳定运行。

考虑到几个重点:

  1. 触发输入框onchange事件
  2. 等待输入框输入事件结束
  3. 触发按钮点击事件
  4. 进行fetch
  5. 等待fetch结束
  6. 回调中清理input内容

enzyme提供了一些触发事件的方法。当我们使用mount将一个组件挂载到虚拟DOM上的时候,可以通过wrapper.simulate()方法来触发各种DOM事件。

首先,先测试组件是否正确完成渲染:

it('case: expect input & click operation correct', async () => {
    const wrapper = mount(<Interaction />);

    const input = wrapper.find('input').at(0);
    const button = wrapper.find('button').at(0);

    expect(input.exists());
    expect(button.exists());
});

然后需要触发input的onchange事件,来改变当前的state:

input.simulate('change', {
    target: {
        value: 'lucas'
    }
});

expect(wrapper.state('value')).toBe('lucas');

接着,触发按钮的点击事件,进行fetch请求,然后在响应返回之后,清理掉state中的内容。

button.simulate('click');

这样就完成了整个组件的操作流程的UT了,执行这个单元测试,可以发现我们的测试已经完全覆盖了所有代码的所有分支了。

下面是完成的test suite:

import Interaction from '../src/interaction';
import Enzyme, { shallow, render, mount } from 'enzyme';
import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import nock from 'nock';
import waitUntil from 'async-wait-until';

Enzyme.configure({
    adapter: new Adapter()
});

describe('test suite: Async component', () => {
    let resolve = false;
    beforeAll(() => {
        nock('http://localhost/api')
            .post('/value')
            .reply(200, () => {
                resolve = true;
                return {};
            });
    });

    afterAll(() => {
        nock.cleanAll();
    });

    it('case: expect input & click operation correct', async () => {
        const wrapper = mount(<Interaction />);

        const input = wrapper.find('input').at(0);
        const button = wrapper.find('button').at(0);

        expect(input.exists());
        expect(button.exists());

        input.simulate('change', {
            target: {
                value: 'lucas'
            }
        });

        expect(wrapper.state('value')).toBe('lucas');

        button.simulate('click');

        await waitUntil(() => resolve);

        expect(wrapper.state('value')).toBe('')
    });
});

整个测试用例完全pass,并且coverage为100%

interaction test

最后

洋洋洒洒又是一个大长篇,有很多博主会将enzyme、nock、jest这类库分开来讲,但是在实际使用过程中,这几个库却是密不可分的。

单元测试是前端工程化的一个不可避免的阶段性工作,无论是开源工作还是业务工作,保证在每次迭代过程中代码的安全性于人于己都有很大的好处。

最后还是要说,撰写测试用例的时候,一定要切记,单元测试并不是堆砌覆盖率,而是保证每一个功能细节都被覆盖到,不要舍本逐末了。