引入单元测试工具Jest

单元测试用于检查程序中的最小单元是否会按照预期设置工作。在过程式编程中,最小单元就是一个函数;而在面向对象编程中,最小单元是对象的方法。

Jest 是目前较流行的 JavaScript 单元测试框架,它提供了测试驱动、断言库、模拟底层函数、代码覆盖率等,而且使用起来相当简单。

image 2024 02 20 11 50 18 543
Figure 1. 图20-3 Jest测试结果

Jest的安装与配置

要在 TypeScript 中使用 Jest,首先,执行以下命令,安装 Jest 及用于 TypeScriptJest 扩展。

$ npm install jest ts-jest @types/jest -D

上述命令将分别以发布到开发环境(-D 参数,其作用等同于 -save-dev 参数)的形式安装以下 3 个包。

  • jest:Jest 的核心包。

  • ts-jest:Jest 针对 TypeScript 的预处理器。

  • @types/jest:JestTypeScript 声明文件(关于此类声明文件,详见21.2节)。

然后,使用以下命令创建 Jest 配置文件。

$ npx ts-jest config:init

之后会在命令运行目录中生成 jest.config.js 文件。然后,添加如下代码。

/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  transform: { "^.+\\.ts?$": "ts-jest" },
  moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"]
};

以上代码中加粗部分的含义如下。

  • transform:自定义转换设置。此处配置为由 ts-jest 预处理器来处理 .ts 文件。

  • moduleFileExtensions:指定要测试的文件类型的扩展名。

最后在 package.jsonscripts 中加入

{
    test: "jest"
    // 如果要测试覆盖率,后面加上--coverage
    // 如果要监听所有测试文件 --watchAll
}

下面就编写单元测试代码并执行。

编写和执行单元测试

首先,创建程序文件 a.ts,代码如下。

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

其中,创建并导出了一个名为 sum() 的函数,用于计算两数之和。

默认情况下,Jest 会把项目中所有扩展名为 .test.js 的文件当作单元测试文件。创建的单元测试文件必须符合该规则。

接着,创建测试文件 a.test.ts,代码如下。

import { sum } from './a';

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

其中导入了 a.ts 文件中的 sum() 函数,然后使用 test() 函数测试,断言 sum(1,2) 的返回值为 3

a.ts 文件和 a.test.ts 文件都编译成 JavaScript 文件(a.js 文件和 a.test.js 文件)之后,执行以下命令以运行测试。

$ npx jest

Jest 测试结果如图20-3所示。

假设现在开发人员在维护代码时出现编写错误,误将 a.ts 文件修改为如下,那么将无法正确返回两数之和。

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

编译 a.ts 文件,输出更新后的 a.js 文件,执行 npx jest 命令以运行测试,单元测试结果如图20-4所示。

image 2024 02 20 11 56 58 549
Figure 2. 图20-4 代码修改后的单元测试结果

可以看到单元测试成功检测到程序错误,sum(1,2) 的预期返回结果为 3,但实际为 2,不是两数之和。

匹配器

匹配器(Matchers)是 Jest 中非常重要的一个概念,它可以提供很多种方式来让你去验证你所测试的返回值。举个例子就明白什么是匹配器了。

相等匹配,这是我们最常用的匹配规则

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

在这段代码中 expact(2 + 2) 将返回我们期望的结果,通常情况下我们只需要调用 expect 就可以,括号中的 可以是一个具有返回值的函数,也可以是表达式。后面的 toBe 就是一匹配器。

下面列举一些常用的匹配器:

普通匹配器

  • toBeobject.is 相当于 ===

    test('测试加法 3 + 7', () => {
        // toBe 匹配器 matchers object.is 相当于 ===
        expect(10).toBe(10)
    })
  • toEqual:内容相等,匹配内容,不匹配引用

    test('toEqual 匹配器', () => {
        // toEqual 匹配器 只会匹配内容,不会匹配引用
        const a = { one: 1 }
        expect(a).toEqual({ one: 1 })
    })

与真假有关的匹配器

  • toBeNull:只匹配 Null

    test('toBeNull 匹配器', () => {
        // toBeNull
        const n = null;
        expect(n).toBeNull();
        expect(n).toBeDefined();
        expect(n).not.toBeUndefined();
        expect(n).not.toBeTruthy();
        expect(n).toBeFalsy();
    });
  • toBeUndefined:只匹配 undefined

    test('toBeUndefined 匹配器', () => {
        const a = undefined
        expect(a).toBeUndefined()
    })
  • toBeDefined: 与 toBeUndefined 相反,这里匹配 null 是通过的

    test('toBeDefined 匹配器', () => {
        const a = null
        expect(a).toBeDefined()
    })
  • toBeTruthy:匹配任何 if 语句为 true

    test('toBeTruthy 匹配器', () => {
        const a = 1
        expect(a).toBeTruthy()
    })
  • toBeFalsy:匹配任何 if 语句为 false

    test('toBeFalsy 匹配器', () => {
        const a = 0
        expect(a).toBeFalsy()
    })
  • not:取反

    test('not 匹配器', () => {
        const a = 1
        // 以下两个匹配器是一样的
        expect(a).not.toBeFalsy()
        expect(a).toBeTruthy()
    })

数字

  • toBeGreaterThan:大于

  • toBeLessThan:小于

  • toBeGreaterThanOrEqual:大于等于

  • toBeLessThanOrEqual:小于等于

  • toBeCloseTo:计算浮点数

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

字符串

toMatch: 匹配某个特定项字符串,支持正则

test('toMatch', () => {
    const str = 'http://www.zsh.com'
    expect(str).toMatch('zsh')
    expect(str).toMatch(/zsh/)
})

数组

toContain:匹配是否包含某个特定项

test('toContain', () => {
    const arr = ['z', 's', 'h']
    const data = new Set(arr)
    expect(data).toContain('z')
})

异常

toThrow

const throwNewErrorFunc = () => {
    throw new Error('this is a new error')
}
test('toThrow', () => {
    // 抛出的异常也要一样才可以通过,也可以写正则表达式
    expect(throwNewErrorFunc).toThrow('this is a new 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);

    // You can also use a string that must be contained in the error message or a regexp
    expect(() => compileAndroidCode()).toThrow('you are using the wrong JDK');
    expect(() => compileAndroidCode()).toThrow(/JDK/);

    // Or you can match an exact error message using a regexp like below
    expect(() => compileAndroidCode()).toThrow(/^you are using the wrong JDK$/); // Test fails
    expect(() => compileAndroidCode()).toThrow(/^you are using the wrong JDK!$/); // Test pass
});

测试异步代码

假设请求函数如下:

const fethUserInfo = fetch('http://xxxx')

测试异步代码有好几种方式,我就推荐一种我认为比较常用的方式:

// fetchData.test.js

// 测试promise成功需要加.resolves方法
test('the data is peanut butter', async () => {
    await expect(fethUserInfo()).resolves.toBe('peanut butter');
});

// 测试promise成功需要加.rejects方法
test('the fetch fails with an error', async () => {
    await expect(fethUserInfo()).rejects.toMatch('error');
});

作用域

jest 提供一个 describle 函数来分离各个 test 测试用例,就是把相关的代码放到一类分组中,这么简单,看个例子就懂了。

// 分组一
describe('Test xxFunction', () => {
  test('Test default return zero', () => {
      expect(xxFunction()).toBe(0)
  })

  // ...其它test
})

// 分组二
describe('Test xxFunction2', () => {
  test('Pass 3 can return 9', () => {
      expect(xxFunction2(3)).toBe(9)
  })

  // ...其它test
})

钩子函数

jest 中有 4 个钩子函数:

  • beforeAll:所有测试之前执行

  • afterAll:所有测试执行完之后

  • beforeEach:每个测试实例之前执行

  • afterEach:每个测试实例完成之后执行

我们举例来说明为什么需要他们。在 index.js 中写入一些待测试方法

export default class compute {
    constructor() {
        this.number = 0
    }
    addOne() {
        this.number += 1
    }
    addTwo() {
        this.number += 2
    }
    minusOne() {
        this.number -= 1
    }
    minusTwo() {
        this.number -= 2
    }
}

假如我们要在 index.test.js 中写测试实例:

import compute from './index'

const Compute = new compute()

test('测试 addOne', () => {
    Compute.addOne()
    expect(Compute.number).toBe(1)
})

test('测试 minusOne', () => {
    Compute.minusOne()
    expect(Compute.number).toBe(0)
})
  • 这里两个测试实例相互之间影响了,共用了一个 compute 实例,我们可以将 const Compute = new compute() 放在 beforeEach 里面就可以解决了,每次测试实例之前先重新 new compute

  • 同理,你想在每个 test 测试完毕后单独运行什么可以放入到 afterEach 中。

我们接着看一下什么情况下使用 beforeAll,假如我们测试数据库数据是否保存正确:

  • 我们在测试最开始,也就是 beforeAll 生命周期里,新增 1 条数据到数据库里

  • 测试完后,也就是 afterAll 周期里,删除之前添加的数据

  • 最后利用全局作用域 afterAll 确认数据库是否还原成初始状态

// 模拟数据库
const userDB = [
    { id: 1, name: '小明' },
    { id: 2, name: '小花' },
]

// 新增数据
const insertTestData = data => {
    // userDB,push数据
}

// 删除数据
const deleteTestData = id => {
    // userDB,delete数据
}

// 全部测试完
afterAll(() => {
    console.log(userDB)
})

describe('Test about user data', () => {

  beforeAll(() => {
      insertTestData({ id: 99, name: 'CS' })
  })
  afterAll(() => {
      deleteTestData(99)
  })

})

jest 里的 Mock

为什么要使用 Mock 函数?

在项目中,经常会碰见 A 模块调 B 模块的方法。并且,在单元测试中,我们可能并不需要关心内部调用的方法的执行过程和结果,只想知道它是否被正确调用即可,甚至会指定该函数的返回值。此时,就需要 mock 函数了。

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

  • 捕获函数调用情况

  • 设置函数返回值

  • 改变函数的内部实现

jest.fn()

jest.fn() 是创建 Mock 函数最常用的方式。

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

    // 断言mockFn被调用
    expect(mockFn).toBeCalled();
    // 断言mockFn被调用了一次
    expect(mockFn).toBeCalledTimes(1);
    // 断言mockFn传入的参数为1
    expect(mockFn).toHaveBeenCalledWith(1);
})

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

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.mock()

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

下面我们在 src/fetch.js 的同级目录下创建一个 src/events.js

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

export default {
    async getPostList() {
        return fetch.fetchPostsList(data => {
            console.log('fetchPostsList be called!');
            // do something
        });
    }
}
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 被正常的执行了。

快照

快照就是对你对比的数据会存一份副本,啥意思呢,我们举个例子:

index.js
export const data2 = () => {
    return {
        name: 'zhangsan',
        age: 26,
        time: new Date()
    }
}

index.test.js 中写入一些测试实例

import { data2 } from "./index"

it('测试快照 data2', () => {
    expect(data2()).toMatchSnapshot({
        name: 'zhangsan',
        age: 26,
        time: expect.any(Date) //用于声明是个时间类型,否则时间会一直改变,快照不通过
    })
})
  • toMatchSnapshot 会将参数将快照进行匹配

  • expect.any(Date) 用于匹配一个时间类型

执行 npm run test 会生成一个 __snapshots__ 文件夹,里面是生成的快照,当你修改一下测试代码时,会提示你,快照不匹配。如果你确定你需要修改,按 u 键,即可更新快照。这用于 UI 组件的测试非常有用。

React的BDD单测

接下来我们看下 react 代码如何进行测试,用一个很小的例子来说明。

案例中引入了 enzyme。Enzyme 来自 airbnb 公司,是一个用于 React 的 JavaScript 测试工具,方便你判断、操纵和历遍 React Components 输出。

我们达成的目的是检测:

  • 用户进入首页,看到两个按钮,分别是 counter1 和 counter2

  • 点击 counter1,就能看到两个按钮的文字部分分别是 "counter1" 和 "counter2"

react 代码如下:

import React from 'react';
function Counter(){
    return (
        <ul>
            <li>
                <button id='counter1' className='button1'>counter1</button>
            </li>
            <li>
                <button id='counter2' className='button2'>counter2</button>
            </li>
        </ul>
    )
}

单测的文件:

import Counter from 'xx';
import { mount } from 'enzyme';

describle('测试APP',() => {
    test('用户进入首页,看到两个按钮,分别是counter1和counter2,并且按钮文字也是counter1和counter2',()=>{
        const wrapper = mount(<Counter />);
        const button = wrapper.find('button');
        except(button).toHaveLength(2);
        except(button.at(0).text()).toBe('counter1');
        except(button.at(1).text()).toBe('counter2');
    })
})