配置Jest进行项目测试

507 阅读7分钟

上一篇中,我们用 TypeScript 实现了发布订阅模式。但是,我们这个《每日一写》的项目,总不能就写几行代码让它在哪里裸奔,连个测试都不支持吧。于是,这篇就来配置Jest进行测试。

一、Jest 配置步骤

1、安装 jest 和 @types/jest

pnpm add jest @types/jest -D

2、初始化jest.config.js

# 生成jest.config.js文件
npx jest --init

3、安装 babel-jest、@babel/core、@babel/preset-env、@babel/preset-typescript

因为我们要用 TypeScript 写代码文件和测试文件,而 Jest 是不能直接执行 Typescript 的,所以需要通过 babel 来处理。

pnpm add -D babel-jest @babel/core @babel/preset-env @babel/preset-typescript

4、配置 babel.config.js

内容如下:

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          node: 'current'
        }
      }
    ],
    '@babel/preset-typescript',
  ],
};

5、在 package.json 的 scripts 下添加 "test": "jest"

"scripts": {
  "start": "tsc --watch --project tsconfig.json",
  "test": "jest"
},

这样,我们就可以通过 pnpm test 来执行测试了。

因为项目中暂时还没有用到UI组件,所以,关于UI组件的测试暂且留个坑。后面用到的时候再来填坑。

二、使用 Jest 进行测试

开始动手写测试代码之前,我们先要弄清楚 Jest 中的两个概念 —— describe 和 test:

  • describe(name, fn) describe块称为测试套件(test suite),表示一组相关的测试。它是一个函数,第一个参数是测试套件的名称,第二个参数是一个实际执行的函数。

  • test(name, fn, timeout) test块称为"测试用例"(test case),表示一个单独的测试,是测试的最小单位。

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

1、为发布订阅模式的代码编写测试代码

关于发布订阅模式的实现代码见上篇文章

因为很简单,我就上代码了(src/1、实现发布订阅模式/index.test.ts文件):

image.png

import { Event } from './';

describe('Event', () => {
  const eventBus = new Event();
  const b = (params: unknown) => {
    console.log('B recieved', params);
  };
  const eventType = 'publish';
  let mockB: typeof b;

  // 执行每个test之前都会做一遍的事情
  beforeEach(() => {
    mockB = jest.fn(b);
  });

  test('add and emit a event, listener is called', () => {
    eventBus.on(eventType, mockB);
    eventBus.emit(eventType, 'message 1');
    expect(mockB).toBeCalledTimes(1);
    // 注意这里是个数组
    expect(mockB).toBeCalledWith(['message 1']);
    eventBus.off(eventType, mockB);
  });

  test('given an not exist event, throw error', () => {
    eventBus.on(eventType, mockB);
    const event = eventType + '123';
    // 注意这里要把会跑出错误的代码放在函数中,否则,如果写成expect(eventBus.off(event, mockB)).toThrow('未绑定该事件')的话,执行测试的时候是会报错的,导致永远也无法测试通过。
    expect(() => eventBus.off(event, mockB)).toThrow('未绑定该事件');
  });

  test('passing no handler to off, all handlers of this type removed', () => {
    // @ts-ignore-next-line
    eventBus.off(eventType);
    expect(eventBus.handlers[eventType]).toBeUndefined();
  });

  test('passing error handler to off, throw error', () => {
    eventBus.on(eventType, mockB);
    expect(() => eventBus.off(eventType, () => {})).toThrow();
    eventBus.off(eventType, mockB);
  });

  test('remove event, emit will not trigger callback', () => {
    eventBus.on(eventType, mockB);
    eventBus.off(eventType, mockB);
    eventBus.emit(eventType, 'message 2');
    expect(mockB).not.toBeCalled();
  });
});

这里特别提一下这句代码:

expect(() => eventBus.off(event, mockB)).toThrow('未绑定该事件');

注意这里要把会跑出错误的代码放在函数中,否则,如果写成expect(eventBus.off(event, mockB)).toThrow('未绑定该事件')的话,执行测试的时候是会报错的,导致永远也无法测试通过。

完成上面的测试代码后,执行 pnpm test ,就可以看到如下测试结果了。

image.png

在编写测试用例的过程中,对于没有测试到的代码行号,会列在上图的 Uncovered Line 这一列中。你可以根据这个提示来逐步去完善测试用例,让测试覆盖率达到比较理想的水平。

2、为观察者模式的代码编写测试代码

关于观察者模式的实现代码见上篇文章

同样直接上代码(src/2、实现观察者模式/index.test.ts):

import { Subject, Observer } from './';

describe('Subject', () => {
  let subject: Subject;

  beforeEach(() => {
    subject = new Subject();
  });

  test('add observer', () => {
    const observer = new Observer('b');
    subject.add(observer);
    expect(subject.observers).toContain(observer);
    subject.remove(observer);
  });

  test('remove observer', () => {
    const observer = new Observer('b');
    subject.add(observer);
    subject.remove(observer);
    expect(subject.observers).not.toContain(observer);
  });

  test('remove not exist observer, throw error', () => {
    const observer = new Observer('b');
    expect(() => subject.remove(observer)).toThrow('没有该observer');
  });

  test('notify', () => {
    const observerB = new Observer('b');
    const observerC = new Observer('c');
    // 注意这里jest.spyOn的使用,这里不能用jest.fn
    const mockUpdateB = jest.spyOn(observerB, 'update');
    const mockUpdateC = jest.spyOn(observerC, 'update');

    subject.add(observerB);
    subject.add(observerC);
    subject.notify();
    expect(mockUpdateB).toBeCalledTimes(1);
    expect(mockUpdateC).toBeCalledTimes(1);
    subject.remove(observerB);
    subject.remove(observerC);
  });
});

值得注意的是,下面这里 jest.spyOn 的使用,这里不能用 jest.fn

const mockUpdateB = jest.spyOn(observerB, 'update');

相关的代码见这里

三、Jest 的基本使用

这部分主要是做个整理,方便查阅,你也可以直接阅读 Jest官方文档

1、钩子函数

  • beforeEach(fn, timeout) // 每个测试用例执行前执行

  • afterEach(fn, timeout) // 每个测试用例执行后执行

  • beforeAll(fn, timeout) // 所有测试用例测试之前执行

  • afterAll(fn, timeout) // 所有测试用例测试之后执行

2、常用的匹配器(Matcher)

(1)是与内容相等

  • .toBe(value)

  • .toEqual(value)

注意这两者的区别:toBe使用 Object.is来进行精准匹配的测试;如果您想要检查对象的值,请使用 toEqual 代替。例如:

test('对象赋值', () => {
    const data = {one: 1};
    data['two'] = 2;
    expect(data).toEqual({one: 1, two: 2});
});

(2)取反

  • .not // 取反

(3)真值、空值

  • .toBeTruthy() // 匹配真

  • .toBeFalsy() // 匹配假

  • .toBeNull() // 匹配null

  • .toBeUndefined() // 匹配undefined

  • .toBeDefined() // 与.toBeUndefined()相反

(4)数字

  • .toBeGreaterThan(number | bigint) // 匹配大于

  • .toBeGreaterThanOrEqual(number | bigint) // 匹配大于等于

  • .toBeLessThan(number | bigint) // 匹配小于

  • .toBeLessThanOrEqual(number | bigint) // 匹配小于等于

  • .toHaveLength(number) // 匹配长度

(5)字符串匹配

  • .toMatch(/xxx/) // 例如,expect('Christoph').toMatch(/stop/);

(6)数组和可迭代对象

  • .toContain(xxx) // 检查一个数组或可迭代对象是否包含某个特定项
const shoppingList = ['diapers', 'milk'];
expect(shoppingList).toContain('milk');
expect(new Set(shoppingList)).toContain('milk');

(7)抛出异常

  • .toThrow(error?) // 匹配跑出异常

例如:

function compileAndroidCode() {
  throw new Error('you are using the wrong JDK');
}

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

  // 你可以自己定义确切的错误消息内容或者使用正则表达式
  expect(() => compileAndroidCode()).toThrow('you are using the wrong JDK');
  expect(() => compileAndroidCode()).toThrow(/JDK/);
});

(8)函数调用

  • .toBeCalled() // 匹配函数被调用

  • .toHaveBeenCalledTimes(number) // 匹配函数调用次数

  • .toHaveBeenCalledWith(arg1, arg2, ...) // 匹配函数调用的参数

  • .toHaveBeenLastCalledWith(arg1, arg2, ...) // 匹配最后一次调用的返回值

(9)快照测试

每当你想要确保你的UI不会有意外的改变,快照测试是非常有用的工具。

典型的做法是在渲染了UI组件之后,保存一个快照文件, 检测他是否与保存在单元测试旁的快照文件相匹配。 若两个快照不匹配,测试将失败:有可能做了意外的更改,或者UI组件已经更新到了新版本。

import React from 'react';
import renderer from 'react-test-renderer';
import Link from '../Link';

it('renders correctly', () => {
  const tree = renderer
    .create(<Link page="http://www.facebook.com">Facebook</Link>)
    .toJSON();
  expect(tree).toMatchSnapshot();
});

测试第一次运行的时候,jest会生成一个快照文件,内容形如:

exports[`renders correctly 1`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;

你可以运行 Jest 加一个标识符来重新生成快照文件。

jest --updateSnapshot
  • .toMatchSnapshot(propertyMatchers?, hint?) // 匹配生成的快照文件

  • .toMatchInlineSnapshot(propertyMatchers?, inlineSnapshot) // 匹配内联快照

内联快照和普通快照(.snap 文件)表现一致,只是会将快照值自动写会源代码中。这意味着你可以从自动生成的快照中受益,并且不用切换到额外生成的快照文件中保证值的正确性。

首先,你写一个测试,并调用 .toMatchInlineSnapshot() ,不需要传递参数。

it('renders correctly', () => {
  const tree = renderer
    .create(<Link page="https://example.com">Example Site</Link>)
    .toJSON();
  expect(tree).toMatchInlineSnapshot();
});

下次运行Jest时,tree 会被重新评估,此次的快照会被当作一个参数传递到 toMatchInlineSnapshot:

3、expect函数

关于它们的具体使用,详见官方文档。这里有非常详细的说明,而且都有示例。

  • expect(value) // 后边可以跟匹配器

  • expect.anything() // 匹配除了null和undefined的所有,可以检查是否使用非空参数调用模拟函数

  • expect.any(constructor) // 匹配任何构造器

  • expect.objectContaining(object) // 匹配一个对象包含

  • expect.not.objectContaining(object)

  • expect.arrayContaining(array) // 匹配一个数组包含

  • expect.not.arrayContaining(array)

  • expect.stringContaining(string) // 匹配字符串包含

  • expect.not.stringContaining(string)

  • expect.extend(matchers) // 自定义匹配器

  • expect.assertions(number) // 验证断言次数

  • expect.hasAssertions() // 验证至少有一个断言

例子:

expect.extend({
  toBeWithinRange(received, floor, ceiling) {
    const pass = received >= floor && received <= ceiling;
    if (pass) {
      return {
        message: () =>
          `expected ${received} not to be within range ${floor} - ${ceiling}`,
        pass: true,
      };
    } else {
      return {
        message: () =>
          `expected ${received} to be within range ${floor} - ${ceiling}`,
        pass: false,
      };
    }
  },
});

test('numeric ranges', () => {
  expect(100).toBeWithinRange(90, 110);
  expect(101).not.toBeWithinRange(0, 100);
  expect({apples: 6, bananas: 3}).toEqual({
    apples: expect.toBeWithinRange(1, 10),
    bananas: expect.not.toBeWithinRange(11, 20),
  });
});