3

概述

所谓单元测试,就是对每个单元进行的测试,一般针对的是函数、类或单个组件,不涉及系统和集成,单元测试是软件测试的基础测试,一个完备的软件系统都会涉及到单元测试。

目前,Javascript的测试工具很多,但是针对React的测试主要使用的是Facebook推出的Jest框架,Jest是基于Jasmine的JavaScript测试框架,具有上手容易、快速、可靠的特点,是React.js默认的单元测试框架。相比其他的测试框架,Jest具有如下的一些特点:

  • 适应性:Jest是模块化、可扩展和可配置的;
  • 沙箱和快速:Jest虚拟化了JavaScript的环境,能模拟浏览器,并且并行执行;
  • 快照测试:Jest能够对React 树进行快照或别的序列化数值快速编写测试,提供快速更新的用户体验;
  • 支持异步代码测试:支持promises和async/await;
  • 自动生成静态分析结果:不仅显示测试用例执行结果,也显示语句、分支、函数等覆盖率。

环境搭建

安装Jest

首先,在项目目录下使用下面的命令安装Jest。

npm install --save-dev jest
//或者
yarn add --dev jest

如果你使用的是react-native init命令行方式来创建的RN项目,且RN版本在0.38以上,则无需手动安装,系统在生成项目的时候会自动添加依赖。

"scripts": {
 "test": "jest"
},
"jest": {
 "preset": "react-native"
}

配置Babel

现在很多的项目都使用es6及以上版本编写,为了兼容老版本,我们可以使用Babel来将es5的语法转换为es6。使用Babel前,我们需要使用如下的命令来安装Babel。

yarn add --dev babel-jest babel-core regenerator-runtime

说明:如果使用的是Babel 的version 7则需要安装babel-jest, babel-core@^7.0.0-bridge.0 和 @babel/core,安全命令如下:

yarn add --dev babel-jest babel-core@^7.0.0-bridge.0 @babel/core regenerator-runtime

然后在项目的根目录里添加 .babelrc 文件,在文件中配置如下react-native脚本内容。

{
  "presets": ["react-native"],
  "sourceMaps":true  // 用于对齐堆栈,精准的定位单元测试中的问题
}

如果是自动生成的, .babelrc 文件的配置脚本如下:

{
  "presets": ["module:metro-react-native-babel-preset"]
}

此时,需要将上面的presets配置修改为 "presets": ["react-native"]。

完整配置

为了方便查看, 下面是package.json文件的完整配置:

{
  "name": "jestTest",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start",
    "test": "jest"
  },
  "dependencies": {
    "react-native": "0.55.4",
    "react": "^16.6.0",
    "react-dom": "^16.6.0"
  },
  "devDependencies": {
    "babel-core": "^6.26.3",
    "babel-jest": "^23.6.0",
    "jest": "23.6.0",
    "metro-react-native-babel-preset": "0.48.3",
    "react-test-renderer": "16.5.0",
    "regenerator-runtime": "^0.12.1"
  },
  "jest": {
    "preset": "react-native",
    "transform": {
      "^.+\\.js$": "babel-jest"
    },
    "transformIgnorePatterns": [
      "node_modules/(?!(react-native)/)"
    ]
  }
}

说明:如果报AccessibilityInfo错误,请注意react-naitve的版本号,因为react-naitve的版本和其他库存在一些兼容问题,请使用0.55.4及以下稳定版本。

Cannot find module 'AccessibilityInfo' (While processing preset: "/Users/xiangzhihong029/Documents/rn/jestTest/node_modules/react-native/Libraries/react-native/react-native-implementation.js")

Enzyme

Enzyme 是 Airbnb 公司开源的测试工具库,是react-addons-test-utils的封装的产品,它模拟了 jQuery 的 API,非常直观并且易于使用和学习,提供了一些与众不同的接口和几个方法来减少测试的样板代码,方便你判断、操纵和遍历 React Components 的输出,并且减少了测试代码和实现代码之间的耦合。相比react-addons-test-utils,enzyme的API 就一目了然,下表是两个框架常用的函数的对比。
在这里插入图片描述
Enzyme提供了三种渲染方法:

shallow

shallow 方法就是对官方的 Shallow Rendering 的封装,浅渲染在将一个组件作为一个单元进行测试的时候非常有用,可以确保你的测试不会去间接断言子组件的行为。shallow 方法只会渲染出组件的第一层 DOM 结构,其嵌套的子组件不会被渲染出来,从而使得渲染的效率更高,单元测试的速度也会更快。例如:

import { shallow } from 'enzyme'

describe('Enzyme Shallow', () => {
  it('App should have three <Todo /> components', () => {
    const app = shallow(<App />)
    expect(app.find('Todo')).to.have.length(3)
  })
}

mount

mount 方法则会将 React 组件渲染为真实的 DOM 节点,特别是在依赖真实的 DOM 结构必须存在的情况下,比如说按钮的点击事件。
完全的 DOM 渲染需要在全局范围内提供完整的 DOM API, 这也就意味着它必须在至少“看起来像”浏览器环境的环境中运行,如果不想在浏览器中运行测试,推荐使用 mount 的方法是依赖于一个名为 jsdom 的库,它本质上是一个完全在 JavaScript 中实现的 headless 浏览器。
mount渲染方式的示例如下:

import { mount } from 'enzyme'

describe('Enzyme Mount', () => {
  it('should delete Todo when click button', () => {
    const app = mount(<App />)
    const todoLength = app.find('li').length
    app.find('button.delete').at(0).simulate('click')
    expect(app.find('li').length).to.equal(todoLength - 1)
  })
})

render

render 方法则会将 React 组件渲染成静态的 HTML 字符串,返回的是一个 Cheerio 实例对象,采用的是一个第三方的 HTML 解析库 Cheerio。这个 CheerioWrapper 可以用于分析最终结果的 HTML 代码结构,它的 API 跟 shallow 和 mount 方法的 API 都保持基本一致。

import { render } from 'enzyme'

describe('Enzyme Render', () => {
  it('Todo item should not have todo-done class', () => {
    const app = render(<App />)
    expect(app.find('.todo-done').length).to.equal(0)
    expect(app.contains(<div className="todo" />)).to.equal(true)
  })
})

Jest单元测试

简单示例

首先,我们在项目的根目录新建一个名为__test__的文件夹,然后编写一个组件,例如:

import React, {Component} from 'react';
import {
    Text, View,
} from 'react-native';

export default class JestTest extends Component{
    render() {
        return(<View />)
    }
}

然后,我们在__test__文件夹下编写一个名为jest.test.js的文件,代码如下:

import React from 'react';
import JestTest from '../src/JestTest';

import renderer from 'react-test-renderer';

test('renders correctly', () => {
    const tree = renderer.create(<JestTest/>).toJSON();
    expect(tree).toMatchSnapshot();
});

使用命令 “yarn jest” ,系统就会开始执行单元测试,如果没有任何错误,将会显示PASS相关的信息。
在这里插入图片描述
当然,上面的例子并没有涉及到任何的业务逻辑,只是介绍了下在React Native中如何使用Jest进行单元测试。

生成快照测试

快照测试是第一次运行测试的时候在不同情况下的渲染结果(挂载前)保存的一份快照文件,后面每次再运行快照测试时,都会和第一次的比较,除非使用“npm test -- -u”命令重新生成快照文件。

为了测试快照测试,我们先新建一个带有逻辑的组件。例如:

import React, {Component} from 'react';
import {
    Text, View,
    Button
} from 'react-native';

export default class JestTest extends Component{

    constructor() {
        super();
        this.state = {liked: false};
        this.handleClick = this.handleClick.bind(this);
    }

    handleClick() {
        return this.setState({
            liked: !this.state.liked
        });
    }

    render() {
        const text = this.state.liked ? 'like' : 'not liked';
        return (<Text onClick={this.handleClick}>
            You {text} this.Click to toggle.
        </Text>);
    }
}

上面的组件拥有三种状态,初始状态,点击状态,以及再次被点击的状态,所以在测试文件中,我们分别生成三种状态的快照,快照测试文件的代码如下:

import React from 'react';
import renderer from 'react-test-renderer';
import JestTest from "../src/JestTest";

describe('<JestTest/>', () => {
    it('Snapshot', () => {
        const component = renderer.create(<JestTest/>);

        let snapshot = component.toJSON();
        expect(snapshot).toMatchSnapshot();

        snapshot.props.onClick();
        snapshot = component.toJSON();
        expect(snapshot).toMatchSnapshot();

        snapshot.props.onClick();
        snapshot = component.toJSON();
        expect(snapshot).toMatchSnapshot()
    });
});

然后,在控制台运行yarn jest命令,就会看到在__tests___snapshots_目录下看到快照测试,快照测试文件的代码如下:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<JestTest/> Snapshot 1`] = `
<Text
  accessible={true}
  allowFontScaling={true}
  ellipsizeMode="tail"
  onClick={[Function]}
>
  You 
  not liked
   this.Click to toggle.
</Text>
`;

exports[`<JestTest/> Snapshot 2`] = `
<Text
  accessible={true}
  allowFontScaling={true}
  ellipsizeMode="tail"
  onClick={[Function]}
>
  You 
  like
   this.Click to toggle.
</Text>
`;

exports[`<JestTest/> Snapshot 3`] = `
<Text
  accessible={true}
  allowFontScaling={true}
  ellipsizeMode="tail"
  onClick={[Function]}
>
  You 
  not liked
   this.Click to toggle.
</Text>
`;

如果需要更新快照文件,执行yarn test -- -u命令。

DOM测试

DOM测试主要测试组件生成的 DOM 节点是否符合预期,比如响应事件之后,组件的属性与状态是否符合预期。DOM 测试 依赖于官方的 TestUtil,所以需要安装react-addons-test-utils依赖库,安装的时候注意版本的兼容问题。不过在实战过程中,我发现react-addons-test-utils会报很多错误,并且官方文档也不是很友好。

这里推荐使用airbnb开源的Enzyme 脚手架,Enzyme是由 airbnb 开发的React单测工具,它扩展了React的TestUtils,并通过支持类似jQuery的find语法可以很方便的对render出来的结果做各种断言,开发体检十分友好。

生成测试报告

使用命令yarn test -- --coverage就可以生成测试覆盖报告。如图:
在这里插入图片描述
同时,还会在根目录生成一个名为 coverage 的文件夹,是测试覆盖报告的网页版,包含更多,更详细的信息。

Jest基础语法

匹配器

匹配器用于测试输入输出的值是否符合预期,下面介绍一些常见的匹配器。

普通匹配器

最简单的测试值的方法就是看值是否精确匹配,使用的是toBe(),例如:

test('two plus two is four', () => {
  expect(2 + 2).toBe(4);
});

toBe()使用的是JavaScript中的Object.is(),属于ES6中的特性,所以不能检测对象,如果要检测对象的值的话,需要用到toEqual。

test('object assignment', () => {
  const data = {one: 1};
  data['two'] = 2;
  expect(data).toEqual({one: 1, two: 2});
});

Truthiness

在实际的测试中,有时候我们需要明确区分undefined、null和false等情况,而Jest提供的下面的一些规则可以帮我们完成上面的需求。

  • toBeNull只匹配null
  • toBeUndefined只匹配undefined
  • toBeDefine与toBeUndefined相反
  • toBeTruthy匹配任何if语句为真
  • toBeFalsy匹配任何if语句为假

数字匹配器

toBeGreaterThan():大于
toBeGreaterThanOrEqual():大于或者等于
toBeLessThan():小于
toBeLessThanOrEqual():小于或等于
注:对比两个浮点数是否相等,使用的是toBeCloseTo()而不是toEqual()。

例子:

test('two plus two', () => {
  const value = 2 + 2;
  expect(value).toBeGreaterThan(3);
  expect(value).toBeGreaterThanOrEqual(3.5);
  expect(value).toBeLessThan(5);
  expect(value).toBeLessThanOrEqual(4.5);

  // toBe and toEqual are equivalent for numbers
  expect(value).toBe(4);
  expect(value).toEqual(4);
});
test('两个浮点数字相加', () => {
  const value = 0.1 + 0.2;
  //expect(value).toBe(0.3);           这句会报错,因为浮点数有舍入误差
  expect(value).toBeCloseTo(0.3); // 这句可以运行
});

字符串

使用toMatch()函数测试字符串,传递的参数需要是正则表达式。例如:

test('there is no I in team', () => {
  expect('team').not.toMatch(/I/);
});

test('but there is a "stop" in Christoph', () => {
  expect('Christoph').toMatch(/stop/);
});

数组

如果要检测某个字符串是否包含某个字符串或字符,可以使用toContain()。例如:

const list = [
    'diapers',
    'kleenex',
    'trash bags',
    'paper towels',
    'beer',
];

test('字符串包含', () => {
    expect(list).toContain('beer');
});

toThrow

如果想在测试特定函数的时候抛出错误,则可以在它调用的时候可以使用toThrow()。

异步函数

在实际开发过程中,经常会遇到一些异步的JavaScript代码。当有异步方式运行的代码的时候,Jest需要知道当前它测试的代码是否已经完成,然后它才可以转移动另一个测试。也就是说,测试的用例一定要在测试对象结束之后才能够运行。异步测试有多种手段:

回调

回调函数和异步没有必然的联系,回调只是异步的一种调用方式而已。现在假设一个fetchData(call)函数,获取一些数据并在完成的时候调用call(data),我们想要测试返回的数据是不是包含字符串'peanut butter',那么我们可以这样写:

function fetchData(call) {
  setTimeout(() => {
    call('peanut butter1')
  },1000);
}

test('the data is peanut butter', (done) => {
  function callback(data) {
    expect(data).toBe('peanut butter');
    done()
  }
  fetchData(callback);
});

Promise

Promise表示“承诺将来会执行”的对象,基础内容可以参考廖雪峰的Promise。例如,还是上面的fetchData,我们使用Promise代替回调来实现网络请求。则测试代码写法如下:

test('the data is peanut butter', () => {
  expect.assertions(1);
  return fetchData().then(data => {
    expect(data).toBe('peanut butter');
  });
});

上面,我们使用expect.assertions来验证一定数量的断言是否被调用,如果想要Promise被拒绝,我们可以使用.catch方法。

test('the fetch fails with an error', () => {
  expect.assertions(1);
  return fetchData().catch(e => expect(e).toMatch('error'));
});

Async/Await

Async/Await是一种新的异步请求实现方式,若要编写async测试,只需要在函数前面使用async关键字即可。例如:

test('the data is peanut butter', async () => {
  expect.assertions(1);
  const data = await fetchData();
  expect(data).toBe('peanut butter');
});

test('the fetch fails with an error', async () => {
  expect.assertions(1);
  try {
    await fetchData();
  } catch (e) {
    expect(e).toMatch('error');
  }
});

Jest Object

在写测试的时候,我们经常需要进行测试之前做一些准备工作。例如,多次测试重复设置的工作,可以使用beforeEach和afterEach。

beforeEach(() => {
  jest.resetModules();
});

test('moduleName 1', () => {
  jest.doMock('../moduleName', () => {
    return jest.fn(() => 1);
  });
  const moduleName = require('../moduleName');
  expect(moduleName()).toEqual(1);
});

test('moduleName 2', () => {
  jest.doMock('../moduleName', () => {
    return jest.fn(() => 2);
  });
  const moduleName = require('../moduleName');
  expect(moduleName()).toEqual(2);
});

在某些情况下,如果只需要在文件的开头做一次设置,则可以使用beforeAll和afterAll来处理。

beforeAll(() => {
  return initializeCityDatabase();
});

afterAll(() => {
  return clearCityDatabase();
});

test('city database has Vienna', () => {
  expect(isCity('Vienna')).toBeTruthy();
});

test('city database has San Juan', () => {
  expect(isCity('San Juan')).toBeTruthy();
});

作用域

默认情况下,before和after的块可以应用到文件中的每一个测试。此外可以通过describe块来将将测试中的某一块进行分组,当before和after的块在describe块内部的时候,则只适用于该describe块内的测试。例如:

describe('arrayContaining', () => {
  const expected = ['Alice', 'Bob'];
  it('matches even if received contains additional elements', () => {
    expect(['Alice', 'Bob', 'Eve']).toEqual(expect.arrayContaining(expected));
  });
  it('does not match if received does not contain expected elements', () => {
    expect(['Bob', 'Eve']).not.toEqual(expect.arrayContaining(expected));
  });
});

Jest测试之Mock

mock测试就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便继续进行测试的测试方法。Mock函数通常会提供以下三种特性:

  • 捕获函数调用情况;
  • 设置函数返回值;
  • 改变函数的内部实现

本节,我们主要介绍与 Mock 函数相关的几个API,分别是jest.fn()、jest.spyOn()、jest.mock()。

jest.fn()

jest.fn()是创建Mock函数最简单的方式,如果没有定义函数内部的实现,jest.fn()会返回undefined作为返回值。例如:

// functions.test.js

test('测试jest.fn()调用', () => {
  let mockFn = jest.fn();
  let result = mockFn(1, 2, 3);

  // 断言mockFn的执行后返回undefined
  expect(result).toBeUndefined();
  // 断言mockFn被调用
  expect(mockFn).toBeCalled();
  // 断言mockFn被调用了一次
  expect(mockFn).toBeCalledTimes(1);
  // 断言mockFn传入的参数为1, 2, 3
  expect(mockFn).toHaveBeenCalledWith(1, 2, 3);
})

jest.fn()所创建的Mock函数还可以设置返回值,定义内部实现或返回Promise对象。

// functions.test.js

test('测试jest.fn()返回固定值', () => {
  let mockFn = jest.fn().mockReturnValue('default');
  // 断言mockFn执行后返回值为default
  expect(mockFn()).toBe('default');
})

test('测试jest.fn()内部实现', () => {
  let mockFn = jest.fn((num1, num2) => {
    return num1 * num2;
  })
  // 断言mockFn执行后返回100
  expect(mockFn(10, 10)).toBe(100);
})

test('测试jest.fn()返回Promise', async () => {
  let mockFn = jest.fn().mockResolvedValue('default');
  let result = await mockFn();
  // 断言mockFn通过await关键字执行后返回值为default
  expect(result).toBe('default');
  // 断言mockFn调用后返回的是Promise对象
  expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})

上面的代码是jest.fn()提供的几个常用的API和断言语句,下面我们在src/fetch.js文件中写一些被测试代码,以更加接近业务的方式来理解Mock函数的实际应用。

需要说明的是,被测试代码中依赖了axios这个常用的请求库和JSONPlaceholder这个上篇文章中提到免费的请求接口,请先在shell中执行npm install axios --save安装依赖。

// fetch.js

import axios from 'axios';

export default {
  async fetchPostsList(callback) {
    return axios.get('https://jsonplaceholder.typicode.com/posts').then(res => {
      return callback(res.data);
    })
  }
}

我们在fetch.js中封装了一个fetchPostsList方法,该方法请求了JSONPlaceholder提供的接口,并通过传入的回调函数返回处理过的返回值。如果我们想测试该接口能够被正常请求,只需要捕获到传入的回调函数能够被正常的调用即可。例如:

import fetch from '../src/fetch.js'

test('fetchPostsList中的回调函数应该能够被调用', async () => {
  expect.assertions(1);
  let mockFn = jest.fn();
  await fetch.fetchPostsList(mockFn);

  // 断言mockFn被调用
  expect(mockFn).toBeCalled();
})

jest.mock()

在上一个请求fetch.js文件夹中,我们封装的请求方法可能在其他模块被调用,但有时候我们并不需要进行实际的请求(请求方法已经通过单侧或需要该方法返回非真实数据)。此时,使用jest.mock()去mock整个模块是十分有必要的。

// events.js

import fetch from './fetch';

export default {
  async getPostList() {
    return fetch.fetchPostsList(data => {
      console.log('fetchPostsList be called!');
      // do something
    });
  }
}

然后我们编写一个测试文件,用于测试getPostList请求。

// functions.test.js

import events from '../src/events';
import fetch from '../src/fetch';

jest.mock('../src/fetch.js');

test('mock 整个 fetch.js模块', async () => {
  expect.assertions(2);
  await events.getPostList();
  expect(fetch.fetchPostsList).toHaveBeenCalled();
  expect(fetch.fetchPostsList).toHaveBeenCalledTimes(1);
});

测试代码中,我们使用了jest.mock('../src/fetch.js')去mock整个fetch.js模块,如果注释掉这行代码,执行测试脚本时会出现以下报错信息。
在这里插入图片描述

jest.spyOn()

jest.spyOn()方法同样可以创建一个mock函数,但是该mock函数不仅能够捕获函数的调用情况,还可以正常的执行被spy的函数。实际上,jest.spyOn()是jest.fn()的语法糖,它创建了一个和被spy的函数具有相同内部代码的mock函数。例如:
在这里插入图片描述
上图是之前jest.mock()的示例代码中的正确执行结果的截图,从shell脚本中可以看到console.log('fetchPostsList be called!');这行代码并没有在shell中被打印,这是因为通过jest.mock()后,模块内的方法是不会被jest所实际执行的。这时我们就需要使用jest.spyOn()。

// functions.test.js

import events from '../src/events';
import fetch from '../src/fetch';

test('使用jest.spyOn()监控fetch.fetchPostsList被正常调用', async() => {
  expect.assertions(2);
  const spyFn = jest.spyOn(fetch, 'fetchPostsList');
  await events.getPostList();
  expect(spyFn).toHaveBeenCalled();
  expect(spyFn).toHaveBeenCalledTimes(1);
})

执行npm run test后,可以看到shell中的打印信息,说明通过jest.spyOn(),fetchPostsList被正常的执行了。
在这里插入图片描述

E2E自动化测试


xiangzhihong
5.9k 声望15.3k 粉丝

著有《React Native移动开发实战》1,2,3、《Kotlin入门与实战》《Weex跨平台开发实战》、《Flutter跨平台开发与实战》1,2和《Android应用开发实战》