前端单元测试之Jest

3,636 阅读7分钟

概述

关于前端单元测试的好处自不必说,基础的介绍和知识可以参考之前的博客链接:React Native单元测试。在软件的测试领域,测试主要分为:单元测试、集成测试和功能测试。

  • 单元测试:在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
  • 集成测试,也叫组装测试或联合测试。在单元测试的基础上,将所有模块按照设计要求(如根据结构图)组装成为子系统或系统,进行集成测试。
  • 功能测试,就是对产品的各功能进行验证,根据功能测试用例,逐项测试,检查产品是否达到用户要求的功能。

前端的测试框架有很多:mocha, jasmine, ava, testcafe, jest,他们都有各自擅长的领域和特点,而我们采用的jest框架具有如下的一些特点:

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

安装

# yarn
yarn add --dev jest

# npm
npm install --save-dev jest

我们编写一个被测试文件的sum.js,代码如下:

function sum(a, b) {
  return a + b;
}
module.exports = sum;

然后,我们添加一个名为sum.test.js的测试文件,注意命名时遵循xxx.test.js的命名规则。

const sum = require(‘./sum');
test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

内建断言库

“断言”通常是给程序开发人员自己使用,并且在开发测试期间使用,用于判断在某些逻辑条件下会执行某种预期的结果。Jest框架内置了丰富的断言语句,详细的可以参考Jest 的Expect。此处,列举一些常用的:

.toBe(value)
.toHaveBeenCalled()
.toBeFalsy()
.toEqual(value)
.toBeGreaterThan(number)
.toBeGreaterThanOrEqual(number)

举个例子,下面是一个被测试的文件Hook.js。

export default class Hook {

    constructor() {
        this.init();
    }

    init() {
        this.a = 1;
        this.b = 1;
    }

    sum() {
        return this.a  + this.b;
    }
}

Hook.js主要实现两个数字相加的功能,然后我们编写一个测试文件Hook.test.js。

import Hook from '../src/hook';

describe('hook', () => {
    const hook = new Hook;
    // 每个测试用例执行前都会还原数据,所以下面两个测试可以通过。
    beforeEach( () => {
        hook.init();
    })
    test('test hook 1', () => {
        hook.a = 2;
        hook.b = 2;
        expect(hook.sum()).toBe(4);
    })
    test('test hook 2', () => {
        expect(hook.sum()).toBe(2);// 测试通过
    })
})

然后,在控制台执行yarn jest命令,即可运行单元测试,执行完成后会给出相应的结果。例如:

在这里插入图片描述

生命周期勾子

jest 测试提供了一些测试的生命周期 API,可以辅助我们在每个 case 的开始和结束做一些处理。 这样,在进行一些和数据相关的测试时,可以在测试前准备一些数据,在测试完成后清理测试数据。这部分的知识可以参考官方的全局API

这里列举4个主要的生命周期勾子:

  • afterAll(fn, timeout): 当前文件中的所有测试执行完成后执行 fn, 如果 fn 是 promise,jest 会等待timeout 毫秒,默认 5000;
  • afterEach(fn, timeout): 每个 test 执行完后执行 fn,timeout 含义同上;
  • beforeAll(fn, timeout): 同 afterAll,不同之处在于在所有测试开始前执行;
  • beforeEach(fn, timeout): 同 afterEach,不同之处在于在每个测试开始前执行;
BeforeAll(() => {
  console.log('before all tests to excute !')
})

BeforeEach(() => {
  console.log('before each test !')
})

AfterAll(() => {
  console.log('after all tests to excute !')
})

AfterEach(() => {
  console.log('after each test !')
})

Test('test lifecycle 01', () => {
  expect(1 + 2).toBe(3)
})

Test('test lifecycle 03', () => {
  expect(2 + 2).toBe(4)
})

mock

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

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

jest.fn()

jest.fn()是创建Mock函数最简单的方式,如果没有定义函数内部的实现,jest.fn()会返回undefined作为返回值。例如:有两个被测试代码every.js和foreach.js。代码如下: every.js

function every(array, predicate) {
  let index = -1
  const length = array == null ? 0 : array.length
  while (++index < length) {
    if (!predicate(array[index], index, array)) {
      return false
    }
  }
  return true
}
export default every

foreach.js

function foreach(arr, fn) {
    for(let i = 0, len = arr.length;  i < len; i++) {
        fn(arr[i]);
    }
}

module.exports = foreach;

下面是测试用例mock.test.js文件的代码:

import foreach from '../foreach';
import every from '../every';

describe('mock test', () => {
    it('test foreach use mock', () => {
        // 通过jest.fn()生成一个mock函数
        const fn = jest.fn();
        foreach([1, 2, 3], fn);
        //测试mock函数被调用了3次
        expect(fn.mock.calls.length).toBe(3);
        // 测试第二次调用的函数第一个参数是3
        expect(fn.mock.calls[2][0]).toBe(3);
    })

    it('test every use mock return value', () => {
        const fn = jest.fn();
        fn
            .mockReturnValueOnce(true)
            .mockReturnValueOnce(false);

        const res = every([1, 2, 3, 4], fn);
        expect(fn.mock.calls.length).toBe(2);
        expect(fn.mock.calls[1][1]).toBe(1);
    })

    it('test every use mock mockImplementationOnce', () => {
        const fn = jest.fn((val, index) => {
            if (index == 2) {
                return false;
            }
            return true;
        });

        const res = every([1, 2, 3, 4], fn);
        expect(fn.mock.calls.length).toBe(3);
        expect(fn.mock.calls[1][1]).toBe(1);
    })
    
})

手动mock

测试代码时可以忽略模块的依存关系,进行手动mock。例如,有一个测试文件sum2.js。

function sum2(a, b) {
    if (a > 10) return a * b;
    return a + b;
}

export default sum2;

如果要mock 一个sum2.js 文件的话,需要在sum2.js 同级目录下新建文件夹__mock__,然后在此文件下新建文件同名 sum2.js,然后mock返回100。

export default function sum2(a, b) {
    return 100;
}

然后,新建一个mock_file.test.js测试文件。

jest.mock('../sum2');
import sum2 from '../__mock__/sum2';

it('test mock sum2', () => {
    //因为此时访问的是__mock__文件夹下的sum2.js所以测试通过
    expect(sum2(1, 11111)).toBe(100);
})

异步测试

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

  • done函数
  • return promise
  • async/await

done的例子如下:

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

因为superagent库支持 promise和async/await方式,所以用superagent举例,实际项目开发可能会涉及到promise(es6以前的写法)和async/await(最新的写法),大家可以根据实际情况编写测试代码。

import superagent from 'superagent';


const target = 'http://www.baidu.com';

describe('test promise async', () => {

    it('test done', done => {
        superagent.get(target).end((err, res) => {
            expect(res).toBeTruthy();
            done();
        });
    });

    it('test promise', () => {
        return superagent.get(target).then((res) => {
            expect(res).toBeTruthy();
        });
    });

    it('test async/await', async () => {
        const res = await superagent.get(target);
        expect(res).toBeTruthy();
    });
});

注意,使用superagent框架进行异步测试时,请确保你的项目安装了superagent依赖。

Snapshot

快照测试第一次运行的时候会将被测试ui组件在不同情况下的渲染结果保存一份快照文件,后面每次再运行快照测试时,都会和第一次的比较,除非执行“yarn test – -u”命令删除快照文件。例如,有一个文件reactComp.js.

import React from 'react';

export default class reactComp extends React.Component {
    render() {
        return (
            <div>我是react组件 </div>
        )
    }
}

然后,编写一个测试用例文件reactComp.test.js。

import React from 'react';
import renderer from 'react-test-renderer';

import RC from '../reactComp';

test('react-comp snapshot test', () => {
    const component = renderer.create(<RC />);
    let tree = component.toJSON();
    expect(tree).toMatchSnapshot();
})

test('react-comp snapshot test2', () => {
    const component = renderer.create(<RC />);

    let tree = component.toJSON();
    expect(tree).toMatchSnapshot();
})

执行测试命令,会在test目录下生成一个__snapshots__目录,在此目录下会与一个快照文件,格式如下:

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

exports[`react-comp snapshot test 1`] = `
<div>
  我是react组件
</div>
`;

exports[`react-comp snapshot test2 1`] = `
<div>
  我是react组件
</div>
`;

如果被测试代码有正常更新,可以使用“jest --updateSnapshot ”命令重新更新缓存文件。

附:React Native单元测试 Jest测试官方文档