Jest+Enzyme前端单元测试入门

1,985 阅读10分钟

Jest简介

Jest 是 Facebook 开源的一款 JS 单元测试框架,它也是 React 目前使用的单元测试框架。 目前除了 Facebook 外,Twitter、Nytimes、Airbnb 也在使用 Jest。Jest 除了基本的断言和 Mock 功能外,还有快照测试、实时监控模式、覆盖度报告等实用功能。 同时 Jest 几乎不需要做任何配置便可使用。

在使用create-react-app开发的前端项目中,脚手架已经为我们集成了Jest的相关配置,我们可以在react的项目中直接使用。

先让我们在一个react工程目录中src下新建一个__tests__文件夹,在里面写我们的各种测试案例。

首先,创建一个 sum.js文件:

function sum(a, b){
    return a + b;
}
export { sum };

然后在__tests__中,新建一个sum.test.js的文件:

import { sum } from './sum.js';
test('测试求和函数', () => {
    expect(sum(2, 2)).toBe(4);
});

最后运行yarn testnpm run test,将打印出下面的消息,表示测试通过了。

 PASS  __test__/sum.test.js
  √ 测试求和函数 (7ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.8s

匹配器

Jest测试通过expect来实现,expect函数返回一个期望值对象,该对象提供了大量的工具方法来做结果判定。详细可见Jest官方文档,查看expect相关API。

判断相等:

注意:toBe使用 Object.is 来测试精确相等。 如果您想要检查对象的值,请使用 toEqual 代替,它会递归判断对象的每一个字段。对数值来说,toBetoEqual都可以使用。

test('2加2等于4', () => {
    expect(2+2).toBe(4);
});
// 测试对象相等
test('测试对象的值', () => {
    const data = {a: 1};
    expect(data).toEqual({a: 1});
});

还可以测试相反的匹配:

test('2加2不等于1', () => {
    expect(2 + 2).not.toBe(1);
});

判断真假:

在测试中,你有时需要区分 undefinednull,和 false,但有时你又不需要区分。

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

例如:

test('null', () => {
  const n = null;
  expect(n).toBeNull();
  expect(n).toBeDefined();
  expect(n).not.toBeUndefined();
  expect(n).not.toBeTruthy();
  expect(n).toBeFalsy();
});

test('zero', () => {
  const z = 0;
  expect(z).not.toBeNull();
  expect(z).toBeDefined();
  expect(z).not.toBeUndefined();
  expect(z).not.toBeTruthy();
  expect(z).toBeFalsy();
});

判断数字:

test('2加2', () => {
  const value = 2 + 2;
  expect(value).toBeGreaterThan(3); // 大于3
  expect(value).toBeGreaterThanOrEqual(4); // 大于或等于4
  expect(value).toBeLessThan(5); // 小于5
  expect(value).toBeLessThanOrEqual(4.5); // 小于或等于4.5
});

判断符点数:

可使用 toBeCloseTo 来解决 JS 浮点精度带来的问题

如下示例:

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 shoppingList = [
  'diapers',
  'kleenex',
  'trash bags',
  'paper towels',
  'beer',
];

test('the shopping list has beer on it', () => {
  expect(shoppingList).toContain('beer');
});

判断异常:

function compileAndroidCode() {
  throw new Error('这是一个错误消息');
}

test('compiling android goes as expected', () => {
  expect(compileAndroidCode).toThrow();

  // 可以匹配异常消息的内容,也可以用正则来匹配内容
  expect(compileAndroidCode).toThrow('这是一个错误消息');
  expect(compileAndroidCode).toThrow(/消息/);
});

异步测试

Jest 提供了三种方式来支持异步测试:回调函数,Promise 以及 async 函数。

回调函数:

默认情况下,Jest测试一旦执行到末尾,那这个测试就完成了。

// fetchData.js
import axios from 'axios';

export const fetchData = (fn) => {
    axios.get('http://www.dell-lee.com/react/api/demo.json').then(res => {
        fn(res.data);
    })
}

// fetchData.test.js 无效测试案例
import { fetchData } from '../fetchData.js';

test('fetchData返回的数据是 {success: true}', () => {
  fetchData(data => {
      expect(data).toEqual({success: true});
  });
});

此时,该测试案例会成功通过,但这里有个问题。如果我将接口地址改成错误的,该测试依然会通过。因为一旦fetchData函数执行完毕,此测试就在没有调用回调函数前结束了,并不会执行到断言部分。

解决办法,使用单个done参数并调用done,jest会等done回调函数执行结束后,结束测试。如果done没有被调用,那Jest会认为该测试没有结束,最后超时失败。

// 有效测试
import { fetchData } from '../fetchData.js';

test('the data is peanut butter', done => {
  fetchData(data => {
      expect(data).toEqual({success: true});
      done();
  });
});

超时测试失败打印出的信息:

Promise:

此时让fetchData不去调用回调函数,而是返回一个Promise。

// fetchData.js
import axios from 'axios';

export const fetchData = () => {
    return axios.get('http://www.dell-lee.com/react/api/demo.json');
};

一定不要忘记把 promise 作为返回值,如果你忘了 return 语句的话,在 fetchData 返回的这个 promise 被 resolve、then() 有机会执行之前,测试就已经被视为已经完成了。

请确保添加 expect.assertions 来验证一定数量的断言被调用。 否则一个rejected态的 Promise 不会让测试失败︰

test('fetchData返回的数据是 {success: true}', () => {
  expect.assertions(1);
  return fetchData().then(res => {
      expect(res.data).toEqual({ success: true });
  });
});

如果你想要 Promise 被拒绝,使用 .catch 方法。

请确保添加 expect.assertions 来验证一定数量的断言被调用。 否则一个fulfilled态的 Promise 不会让测试失败︰

test('fetchData返回错误 404', () => {
  expect.assertions(1);
  return fetchData().catch(e => {
      expect(e.toString()).toMatch('404');
  });
});

.resolves / .rejects 匹配器也可以测试以上返回Promise的2种情况:

// 成功时
test('fetchData返回的数据是 {success: true}', () => {
  return expect(fetchData()).resolves.toMacthObject({
      data: { success: true },
  });
});

// 失败时
test('fetchData抛出错误', () => {
  return expect(fetchData()).rejects.toThrow();
});

Async/Await:可以替代return

// 成功时
test('fetchData返回的数据是 {success: true}', async () => {
  await expect(fetchData()).resolves.toMacthObject({
      data: { success: true },
  });
});

// 失败时
test('fetchData抛出错误', async () => {
  await expect(fetchData()).rejects.toThrow();
});

或者采用另一种方式:

// 成功时
test('fetchData返回的数据是 {success: true}', async () => {
  const res = await fetchData();
  expect(res.data).toEqual({ success: true });
});

// 失败时,需要try catch来捕获异常
test('fetchData返回错误 404', () => {
  expect.assertions(1);
  try{
      await fetchData();
  }catch(e){
      expect(e.toString()).toMatch('404');
  }
});

Jest钩子函数

钩子函数是指在某一时刻,jest会自动调用的函数。如下:

  • beforeAll:所有测试执行前的函数
  • afterAll:所有测试执行完成后的函数
  • beforeEach:每一个测试执行前的函数
  • afterEach:每一个测试执行完成后的函数
beforeAll(() => {
    // 所有测试前需要执行的代码
    ...
})

钩子函数的作用域

默认情况下,before 和 after 的块可以应用到文件中的每个测试。

此外可以通过 describe 块来将测试分组。 当 before 和 after 的块在 describe 块内部时,则其只适用于该 describe 块内的测试。

注意,顶级的 beforeEach 在 describe 块级的 beforeEach 之前被执行。

describe('测试第一层', () => {
    beforeAll(() => console.log('第一层beforeAll'));
    beforeEach(() => console.log('第一层beforeEach'));
    afterAll(() => console.log('第一层afterAll'));
    afterEach(() => console.log('第一层afterEach'));

    describe('测试第二层', () => {
        beforeAll(() => console.log('第二层beforeAll'));
        beforeEach(() => console.log('第二层beforeEach'));
        afterAll(() => console.log('第二层afterAll'));
        afterEach(() => console.log('第二层afterEach'));

        test('第二层测试1', () => console.log('第二层测试1'));
        test('第二层测试2', () => console.log('第二层测试2'));
    });
});

打印出来的顺序:

如果在一个测试文件中,测试案例比较多,有时只想测试单个测试案例时,只需要在test后添加.only即可或者单独测试一个describe块,只需要在后面添加.only即可。这样的话,此时的测试文件中就只会执行这一个测试案例,其它的案例都是被跳过skipped。 相反,想要跳过测试案例的话,添加.skip即可。 如:test.only('xxx', () => {...})或者describe.skip('xxx', ()=>{...})

describe和test块的执行顺序

Jest 会在所有真正的测试开始之前执行测试文件里所有的 describe 处理程序(handlers)。 这是在 before* 和 after* 处理程序里面 (而不是在 describe 块中)进行准备工作和整理工作的另一个原因。 当 describe 块运行完后,默认情况下,Jest 会按照 test 出现的顺序依次运行所有测试,等待每一个测试完成并整理好,然后才继续往下走。

describe('outer', () => {
  console.log('describe outer-a');

  describe('describe inner 1', () => {
    console.log('describe inner 1');
    test('test 1', () => {
      console.log('test for describe inner 1');
      expect(true).toEqual(true);
    });
  });

  console.log('describe outer-b');

  test('test 1', () => {
    console.log('test for describe outer');
    expect(true).toEqual(true);
  });

  describe('describe inner 2', () => {
    console.log('describe inner 2');
    test('test for describe inner 2', () => {
      console.log('test for describe inner 2');
      expect(false).toEqual(false);
    });
  });

  console.log('describe outer-c');
});

// 打印出的顺序:
// describe outer-a
// describe inner 1
// describe outer-b
// describe inner 2
// describe outer-c
// test for describe inner 1
// test for describe outer
// test for describe inner 2

Mock函数

在项目中,一个模块的方法内常常会去调用另外一个模块的方法。在单元测试中,我们可能并不需要关心内部调用的方法的执行过程和结果,只想知道它是否被正确调用即可,甚至会指定该函数的返回值。此时,使用Mock函数是十分有必要。

Mock函数提供的以下三种特性,在我们写测试代码时十分有用:

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

jest.fn()

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

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

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

.mock属性:

所有的 mock 函数都有这个特殊的 .mock属性,它保存了关于此函数如何被调用、调用时的返回值的信息。

function forEach(items, callback) {
  for (let index = 0; index < items.length; index++) {
    callback(items[index]);
  };
};

// 测试文件
test('测试forEach函数', () => {
    const mockCallback = jest.fn(x => 42 + x);
    forEach([0, 1], mockCallback);
    // 此 mock 函数被调用了两次
    expect(mockCallback.mock.calls.length).toBe(2);
    // 第一次调用函数时的第一个参数是 0
    expect(mockCallback.mock.calls[0][0]).toBe(0);    
    // 第一次函数调用的返回值是 42
    expect(mockCallback.mock.results[0].value).toBe(42);
})

这些 mock 成员变量在测试中非常有用,用于说明这些mock function 是如何被调用、实例化或返回的情况。打印mock函数的mock属性得到:

Mock的返回值:

Mock 函数也可以用于在测试期间将测试值注入代码。如果没有定义函数内部的实现,jest.fn()会返回undefined作为返回值。

const myMock = jest.fn();
console.log(myMock());
// undefined

myMock
  .mockReturnValueOnce(10) // 第一个调用返回的值为10
  .mockReturnValueOnce('x') // 第一个调用返回的值为'x'
  .mockReturnValue(true); // 返回的值为true

console.log(myMock(), myMock(), myMock(), myMock());
// 10, 'x', true, true

jest.mock()

通常情况下,我们需要调用api,发送ajax请求,从后台获取数据。但是我们在做前端测试的时候,并不需要去调用真实的接口,所以此时我们需要模拟axios模块,让它不必调用api也能测试我们的接口调用是否正确。

一旦模拟模块,我们可为 .get 提供一个 mockResolvedValue ,它会返回假数据用于测试。 实际上,我们想让 axios.get('/demo.json')有个假的 response。

// api
export const fetchData = () => {
    return axios.get('http://www.dell-lee.com/react/api/demo.json');
}

// 测试
import axios from 'axios';
jest.mock('axios');

test('测试模拟axios模块', () => {
    axios.get.mockResolvedValue({
        data: 'haha',
    });
    return fetchData().then(res => {
        expect(res.data).toBe('haha');
    });
});

Mock的内部实现

方式一:

test('', () => {
    const myMockFn = jest.fn(() => 22);
    myMockFn();
    expect(myMockFn.mock.result[0].value).toBe(22);
});

方式二:使用mockImplementation

test('', () => {
    const myMockFn = jest.fn();
    myMockFn.mockImplementation(() => 44);
    myMockFn();
    expect(myMockFn.mock.result[0].value).toBe(44);
})
// true

推荐VS CODE中扩展插件:Jest

在vs code扩展商店中搜索 jest,安装该插件。

安装好后,在通过的测试案例前会出现绿色的√,未通过的前面出现红色的×,并且在下方面板中看到未通过的测试案例的错误信息。

安装该插件后,可以不必在终端中输入yarn test 命令进行测试了。