入门:使用 Vitest 测试 React 组件

4,084 阅读9分钟

由于实在是抵挡不住 vite 构建项目巨快的速度之诱惑,Wood-UI 使用了 vite 来构建,其中组件全部采用 Function Component 形式编写。

要成为一个完整的 UI 组件库,单元测试是必不可少的,不然,不要说别人不敢用了,连我自己都不敢在项目中使用!为此,之前从来没有接触过前端单元测试的我,花了 38 首歌的时间,学习了下 vite 官方指定的测试套件——Vitest,不得不说这个名字起的妙啊!

很平静的从头到尾过了一遍 vitest 文档,然后直接上手。官方提供了好多好多例子,真的很多: image.png

由于我是 React 项目,所以直接点进去看 react-mui 这个例子,因为里面有 ui 两个字,想必看一遍就能搞懂了🧐

结果一看.test文件怎么才这么几行?! 就这么点儿信息量还不够我塞牙缝儿的,, image.png

OK ,那我继续看例子,我一直认为学会使用某种工具的最佳方法就是先模仿官方示例。

于是我把下面官网这些关于 React 的例子全看了个遍。。。完了啥也没有搞明白🐷,信息量还是太少,最终我在 Medium 上面找到了一遍文章,详细介绍了如何使用 vitest 测试 React,看完这篇文章,我终于大致搞明白了 vitest 是怎么使用的。

项目中引入

看了看官网中寥寥数步,我有点怀疑,感觉按照官网这么搞,可能还是配置不好,因为根本没提到在typescript中如何引入,所以,我又在 Medium 上面找了一篇文章,按照下面的步骤,将 vitest 引入了项目之中,并且配置好 npm run test命令,步骤如下:

  1. 运行npm i -D vitest 下载到最新版本的 vitest 包
  2. 运行 npm i -D jsdom @testing-library/react 安装 vitest 所依赖的基础库
  3. 找到 vite.config.ts文件,在其中加几行配置,这样才能让 rollup处理.test文件
/// <reference types="vitest" />
/// <reference types="vite/client" />

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vitejs.dev/config/
export default defineConfig( {
  plugins: [ react() ],
  test: {
    globals: true,
    environment: 'jsdom',
    coverage: {
      reporter: [ 'text', 'json', 'html' ]
    }
  }
} );

  1. package.json中配置npm命令
“script”:{
  "test": "vitest",
  "test:ui": "vitest --ui",
  "coverage": "vitest run --coverage"
}

一顿操作之后,只要为组件编写好了.test文件,然后执行npm run test就可以在控制台看到测试结果啦。

不过,这仅仅是第一步。

一些名词解释

jsdom

我们安装完 vitest 之后,又马上安装了一个包jsdom,我马上冒出一个疑问:jsdom 是啥?从来没听过呀,用 js 模拟测试时的 dom 的吗?🤔

搜索一下,发现还真是,jsdom 是一个库,用于在单元测试时,模拟 dom 环境,从而将被测试的元素渲染出来,方便对 UI 进行测试 image.png

再回头查查 vitest 文档,发现 image.png

所以,vitest 支持使用jsdomhappy-dom这两个库来模拟 dom 环境。

@testing-libaray/react

同样的,也来查一查这个包是干啥用的,虽然我写不出来这么牛逼的工具,但是多了解一下总是好的嘛。 image.png

Testing-library 翻译过来就是“测试库”的意思,再看左侧的Core APIFrameworks就明白,这是一个提供了单元测试 API 的库,应有尽有,比如要测试一个按钮的单击事件,就可以参考Core API -> User Action —> Firing Event;要测试 React 组件,可以参考Frameworks -> React Testing library

所以,到现在为止,我们已经搞清楚这些陌生的概念了,总结下来就是:Vitest 基于 Testing-Library 提供了测试、断言等 API 、jsdom 提供了模拟 dom 环境的能力

测试框架 API

本次编写的测试用例非常简单,但是为了跑通这个用例而不报错,我真的是查了半天的文档,我在 vitest 官方文档并没有查到“如何测试一个事件回调是否被调用”这类的说明,对于从来没接触过单元测试的我来说,还真是难啊,难就难在不知道从何下手。

但是呢,也不用把事情想得太复杂了,单元测试的本质就是一条条断言和每条断言结果组成的集合,通过调用测试框架提供的 API 来实现所有测试需求;所以,不熟悉单元测试的话,首先就要来查一波 API 。

test

这是 vitest API 文档中的第一个,先来看下官方描述

test defines a set of related expectations. It receives the test name and a function that holds the expectations to test

test用来定义一系列expectation (期望),可以接受一段文字,用来描述这个用例的意义

(name: string, fn: TestFunction, timeout?: number | TestOptions) => void

name就是文字描述,fn是一个函数包含了测试用例的主要逻辑,timeout是一个可选参数,表示执行这个测试用例的时间限制,超时则用例不通过。

test还有一个别称it,在 vitest 中使用it等同于使用test,我估计大概是因为jest中,创建测试用例的方法叫做it,这里为了降低上手成本也兼容了jest的语法。

describe

When you use test or bench in the top level of file, they are collected as part of the implicit suite for it. Using describe you can define a new suite in the current context, as a set of related tests or benchmarks and other nested suites. A suite lets you organize your tests and benchmarks so reports are more clear.

describe用来定义一个suite (套件),其中包含了所有具有相关性的单条测试用例,这样做,可以更合理地将松散的、杂乱的测试用例组织起来。

import { describe, expect, test } from 'vitest'

const person = {
  isActive: true,
  age: 32,
}

describe('person', () => {
  test('person is defined', () => {
    expect(person).toBeDefined()
  })

  test('is active', () => {
    expect(person.isActive).toBeTruthy()
  })

  test('age limit', () => {
    expect(person.age).toBeLessThanOrEqual(32)
  })
})

expect

这个 API 超级重要啊!用法也是最多的🐳

expect is used to create assertions. In this context assertions are functions that can be called to assert a statement. Vitest provides chai assertions by default and also Jest compatible assertions build on top of chai.

For example, this code asserts that an input value is equal to 2. If it's not, assertion will throw an error, and the test will fail.

expect用来声明assertion (断言),断言是啥呢?断言就是一个函数,调用之后就可以得到对一个 statement (声明)的测试结果,说白了就是验证测试用例执行后是否达到期望。

常见的用法

import { expect } from 'vitest'

const input = Math.sqrt(4);

expect(input).toBe(2);

vitest 文档中列出很多用于测试某个expectation的方法,不需要死记硬背,即用即查,熟悉就好。 image.png

vi

vi也是一个非常重要的 API ,主要用于模拟各种变量或其他环境

When writing tests it's only a matter of time before you need to create a "fake" version of an internal — or external — service. This is commonly referred to as mocking.

Vitest provides utility functions to help you out through its vi helper.

image.png vi对象上提供了很多的方法,在后面编写测试用例的时候,会有一个expectation用来测试 Button 的单击事件回调函数是否在点击按钮之后被正确调用了,这里就要用到vi.fn()来模拟一个回调函数。

编写测试用例

首先,在Button.tsx组件的同级目录下,创建一个测试文件Button.test.tsx,用来存放对于 Button 的所有测试用例。 image.png

然后,先来明确要测试Button组件的内容有哪些,说白了,就是改变对于 Button 组件的外部输入参数以及内部维护的状态,检查 Button 是否按照预期正确的渲染并执行相应的逻辑。

对于 React 组件来说,外部输入就是props,内部状态就是state,还有组件的用户事件以及其他的逻辑,参考 Button 组件的props,我列出了如下的一些需要测试的情况: image.png

  • 初始化一个 Button 组件,是否能正确渲染到页面上
  • 传入不同的type,Button 的样式是否发生相应改变
  • 传入不同的disabled,Button 是否按照预期被禁用或启用
  • 传入不同size,Button 的尺寸是否发生相应改变
  • 传入不同的loading,Button 是否处于加载状态
  • 单击 Button 后,onClick所绑定的事件是否正确触发

现在,“测什么”的问题已经解决了,接下来就是“怎么测”了,我们来编写Button.test.tsx

// 引入测试库函数,用来 mocking
import { render, fireEvent } from '@testing-library/react';
// 引入测试 api ,用来编写用例的逻辑
import { describe, it, expect, vi } from 'vitest';
// 引入被测试组件
import Button from "./Button";

describe( 'Button', () => {
    // 模拟一个函数
    const handleCallback = vi.fn();

    // 通过 render 来渲染 Button 组件到 jsdom 中
    const button = render( <Button onClick={ handleCallback }></Button> );

    // it 用来定义单条用例
    it( 'button click event executed correctly', () => {
        // 组件被渲染之后,通过 getByRole 查询到组件的 dom 节点
        const element = button.getByRole( 'button' );

        // fireEvent 用来模拟触发 click 点击
        fireEvent.click( element );

        // expect 就是期望,toHaveBeenCalled 是一个断言,表示函数被执行
        expect( handleCallback ).toHaveBeenCalled();
    } );
} );

接下来,运行npm run test,执行我们的测试用例 image.png 可以看到,我们的第一个测试用例通过了,这说明 Button 组件的单击事件是没有问题的。

我们继续来完善其余的测试用例

import { render, fireEvent } from '@testing-library/react';
import { type } from 'os';
import { describe, test, expect, vi } from 'vitest';

import Button from "./Button";
import { propsButton } from './type';

// 单击事件、禁用、加载
describe( 'test Button', () => {
    test( 'button click event', () => {
        const handleCallback = vi.fn(); 
        const button = render( <Button onClick={ handleCallback }></Button> );
        const element = button.getByRole( 'button' );
        fireEvent.click( element );
        expect( handleCallback ).toHaveBeenCalled();
    } );

    test( 'disable the button', () => {
        const handleClick = vi.fn();
        const button = render( <Button onClick={ handleClick } disabled></Button> );
        const element = button.getByRole( 'button' );
        fireEvent.click( element );
        expect( handleClick ).not.toHaveBeenCalled();
    } );

    test( 'the loading button', () => {
        const button = render( <Button loading></Button> );
        const buttonHTML = button.container.firstElementChild;
        expect( buttonHTML?.firstElementChild?.outerHTML ).toBe( '<i class="wdu-icon-loading"></i>' );
    } );
} );

// Button 的类型,通过 describe.each() 来简化代码
const defineTypes = [
    { type: 'plain', expected: 'wdu-button-plain' },
    { type: 'important', expected: 'wdu-button-important' },
    { type: 'danger', expected: 'wdu-button-danger' },
    { type: 'success', expected: 'wdu-button-success' },
    { type: 'border', expected: 'wdu-button-border' },
    { type: 'warn', expected: 'wdu-button-warn' },
    { type: 'line', expected: 'wdu-button-line' }
];
describe.each( defineTypes )( 'test the type of button', ( { type, expected } ) => {
    test( `returns ${ expected }`, () => {
        const button = render( <Button type={ ( type as propsButton[ 'type' ] ) }></Button> );
        const element = button.getByRole( 'button' );
        expect( element.classList.contains( expected ) ).toBeTruthy();
    } );
} );

// Button 的尺寸
const defineSize = [
    { size: 'small', expected: 'wdu-button-small' },
    { size: 'normal', expected: 'wdu-button-normal' },
    { size: 'large', expected: 'wdu-button-large' },
];
describe.each( defineSize )( 'test the size of button', ( { size, expected } ) => {
    test( `${ size } size button`, () => {
        const button = render( <Button size={ ( size as propsButton[ 'size' ] ) }></Button> );
        const element = button.getByRole( 'button' );
        expect( element.classList.contains( expected ) ).toBeTruthy();
    } );
} );

至此,一个完整的测试文件就写完啦,运行一下测试 image.png

还可以运行npm run coverage查看测试覆盖率情况 image.png

不过,我能肯定的测试代码还有很多需要改进的地方,比如是否还有更优雅的 API 写法,以及用例的意义是否合理,不过这些都可以在后面对 Vitest 的深入使用中慢慢发现和探索。

总结

本篇文章记录了我初次上手前端单元测试,并使用 Vitest 完成了一个组件的测试用例编写的全过程,从查文档、查名词再到实际编写,也是确确实实费了一番功夫。后期,我会为每个组件都编写一套单元测试,这样就能发现未知的 bug 已经修复现有的 bug ,最终提高整个组件库的代码质量和稳定性。

参考资料:

Wood-UI 测试代码地址