组件库单元测试

918 阅读8分钟

为什么要进行单元测试

  • 正确性,单元测试能确保组件与预期结果一致,特别是一些边界问题
  • 自动化,迭代组件的时候,只需跑一下单测用例,不用手动从头测过

技术选型

  • jest(Facebook开源的一个前端测试框架)
  • @umijs/test umi 基于jest的封装
  • @testing-library/react(简单而完整的React DOM测试实用程序鼓励良好的测试实践)
  • @testing-library/user-event (以与用户相同的方式触发事件)
  • @testing-library/jest-dom(自定义匹配器来测试DOM的状态)
  • @testing-library/react-hooks 测试 react hooks的工具
  • react-test-renderer 提供了一个实验性的React渲染器,可用于将React组件渲染为纯JavaScript对象

如何开始?

1.安装依赖

npm i @testing-library/user-event@14.4.3 -D

npm i @testing-library/react@12.1.2 -D

npm i @testing-library/jest-dom@5.15.1 -D

npm i @umijs/test@3.5.34 -D

npm i @testing-library/react-hooks@8.0.1 -D

npm i react-test-renderer@17.0.2 -D

2.环境配置

  • 在根目录新建 jest.config.js 文件
module.exports = {
  setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
};
  • 在package.json 中配置测试命令
...
"script":{
  "test":"umi-test",
  "test:coverage":"umi-test --coverage"
}
...

3.简单例子

  • 新建一个button组件 (二次封装 antd button组件,在点击的时候自动加上loading)
import React,{useState} from 'react'
import {Button} from 'antd'
import omit from 'lodash/omit'

import type { ButtonProps as AButtonProps } from 'antd/es/button/button';

export interface ButtonProps extends AButtonProps {
  async?:boolean
}

const Index = (props:ButtonProps)=>{
  const [loading, setLoading] = useState(false);
  const onClick = async (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
    setLoading(true);
    try {
      await props.onClick?.(e);
    } catch (err) {
      console.warn('Button asynchronous operation failure:', err);
    }
    setLoading(false);
  };
  let resetProps = omit(props,['async'])
  if(props.async){
    resetProps = {
      ...resetProps,
      loading,
      onClick
    }
  }

  return <Button {...resetProps} />
}

export default Index
  • 在button组件旁新建tests 文件夹,在下面新建 index.test.tsx 文件 为组件编写测试用例
import React from 'react';
import Button from '..';
import { render, act,screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
import sleep from '../../utils/sleep'

describe('Button', () => {
  it('async correctly', async () => {
    const AsyncButton = () => {
      const getData = async () => {
        await sleep(100);
      };
      return (
        <Button async onClick={getData}>
          content
        </Button>
      );
    };
    render(<AsyncButton />);

    const node = screen.getByRole('button',{name:'content'})

    await userEvent.click(node)
    expect(node).toHaveClass('ant-btn-loading');
    await act(async ()=>{
        await sleep(200);
        expect(node).not.toHaveClass('ant-btn-loading');  
    })
  });
});
  • 运行单测命令 npm run test 就可以看到单测结果

4.如何单个用例测试

上面我们运行 npm run test 命令,会去查找当前项目下所有的单测用例,如果我们只需要对单个用例进行测试,解决呢?

无论是jest 还是 @umijs/test 都是可以进行单个用例测试的

node 'node_
modules/@umijs/test/bin/umi-test.js' '/Users/mryeziqing
/Desktop/核盛/组件平台/hera-ui/packages/hera-ui/src/but
ton/__tests__/index.test.tsx' -t 'Button async correctl
y'

实际开发过程中,不可能一个一个路径的输,可以使用 vscode 插件 Jset Runner。安装该插件之后,在用例上面会有一个 run 按钮,点击就可以进行单个用例测试。Jest Runner 默认调用的是 jest,如果想调用 umi-test的话,可以在 项目下新建 .vscode/settings.json,配置运行命令即可

{
  "jestrunner.jestPath": "node_modules/@umijs/test/bin/umi-test.js"
}

常用测试用例

崩溃测试

组件挂载到更新和卸载而没有错误

在项目根目录,新建tests/componentTest.tsx 编写一个通用的测试方法

import React from 'react';
import { render } from '@testing-library/react';

export default function componentTest(
  Component: React.ComponentType<any>,
  componentName: string,
  componentProps = {},
) {
  describe(`component ${componentName}`, () => {
    it(`renders correctly`, () => {
      const wrapper = render(<Component {...componentProps} />);
      expect(wrapper).toMatchSnapshot();
    });

    it('component could be updated and unmounted without errors', () => {
      const { rerender, unmount } = render(<Component {...componentProps} />);
      expect(() => {
        // re-render the same component with different props
        rerender(<Component {...componentProps} />);
        unmount();
      }).not.toThrow();
    });
  });
};

使用

import Button from '..'
import componentTest from '../../../tests/componentTest'

// 确保组件能够正常渲染
componentTest(Button, 'Button', { children: 'content' });

快照测试

Jest 允许你使用 toMatchSnapshot / toMatchInlineSnapshot 保存数据的“快照”。有了这些,我们可以“保存”渲染的组件输出,并确保对它的更新作为对快照的更新显式提交。

import Button from '..'
import { render } from '@testing-library/react';

describe('Button',()=>{

  it('renders content correctly',()=>{
    const wrapper = render(<Button>content</Button>)
  	expect(wrapper).toMatchSnapshot()
    
    const wrapper1 = render(<Button>内容</Button>)
		expect(wrapper1).toMatchSnapshot()
  })

})

通常,进行具体的断言比使用快照更好。这类测试包括实现细节,因此很容易中断,并且团队可能对快照中断不敏感。选择性地 mock 一些子组件可以帮助减小快照的大小,并使它们在代码评审中保持可读性。

事件测试

使用@testing-library/user-event 在 DOM 元素上触发真正的 DOM 事件,然后对结果进行断言

我们新建一个 Toggle 组件:

import React, { useState } from 'react';

export default function Toggle() {
  const [state, setState] = useState(false);
  return (
    <button
      onClick={() => {
        setState(previousState => !previousState);
      }}
      id='toggle'
    >
      {state === true ? 'Turn off' : 'Turn on'}
    </button>
  );
}

我们可以为他编写测试用例:

import React from 'react'
import Toggle from '..'
import { render,screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

describe('Toggle',()=>{

  it('Update values when clicked', async ()=>{
    render(<Toggle />)
    
    const node = screen.getByRole('button')
    expect(node.textContent).toBe('Turn on')
    
    await userEvent.click(node)

    expect(node.textContent).toBe('Turn off')

  })
  
})

mock测试

有些模块可能在测试环境中不能很好地工作,或者对测试本身不是很重要。使用虚拟数据来 mock 这些模块可以使你为代码编写测试变得更容易

我们现在写一个Text组件,超出字数之后 Tooltip显示

import { Tooltip } from 'antd'
import React,{memo} from 'react'

export interface TextProps {
    maxLength?:number,
    content:string | number
}

const Index:React.FC<TextProps> = (props)=>{
    const {maxLength=30,content} = props
    if(!content){
        return null
    }
    const title = content.toString();
    if(title.length > maxLength){
        const text = title.slice(0, maxLength);
        return (
            <Tooltip title={title}>
                {text}...
            </Tooltip>
        )
    }
    return <span>{title}</span>
}

export default memo(Index)

不想在测试中加载 antd 的 Tooltip 这个组件,我们可以将依赖 mock 到一个虚拟组件,然后运行我们的测试:

import React from 'react';
import Text from '..'
import {render,screen} from '@testing-library/react'

jest.mock('antd',()=>{
    return {
        Tooltip:(props)=>{
            return (
                <div>
                    <span data-testid='title' >{props.title}</span>
                    <span data-testid='children'>{props.children}</span>
                </div>
            )
        }
    }
})

describe('Text',()=>{

    it('text correctly',()=>{
        render(<Text maxLength={6} content='我是text组件' />)

        const titleNode = screen.getByTestId('title')
        const childNode = screen.getByTestId('children')

        expect(titleNode.textContent).toBe('我是text组件')
        expect(childNode.textContent).toBe('我是text...')
    })
})

上面讲到的是 组件的 mock,如果是数据请求呢?

我们可以mock 请求来拿到数据 ,而不是在所有测试中调用真正的 API

import React, { useState,useEffect } from 'react'

export interface UserProps {
    id:string
}

interface UserState {
    name:string,
    age:string,
    address:string
}

const Index:React.FC<UserProps> = (props)=>{
    const [user,setUser] = useState<UserState | null>(null)

    async function fetchUserData(id) {
        const response = await fetch("/" + id);
        setUser(await response.json());
    }

    useEffect(() => {
        fetchUserData(props.id);
      }, [props.id]);
    
    if (!user) {
    return <div>加载中...</div>
    }
    
    return (
        <div>
          <summary>{user.name}</summary>
          <strong>{user.age}</strong><br />
          住址 {user.address}
        </div>
    );
}

export default Index

我们可以为它编写测试

import React from 'react'
import User from '..'
import {render,act} from '@testing-library/react'

describe('User',()=>{

    it('renders user data',async ()=>{
        const fetchUser = {
            name:'叶小秋',
            age:'23',
            address:'杭州'
        }
        jest.spyOn(global,'fetch').mockImplementation(()=>(
            Promise.resolve({
                json:()=> Promise.resolve(fetchUser)
            })
        ))
        let wrapper
        // 使用异步的 act 应用执行成功的 promise
        await act(async ()=>{
            wrapper = render(<User id='123' />)
        })
        expect(wrapper.container.querySelector('summary').textContent).toBe(fetchUser.name)
        expect(wrapper.container.querySelector('strong').textContent).toBe(fetchUser.age)
        expect(wrapper.container.textContent).toContain(fetchUser.name)

        // 清理 mock 以确保测试完全隔离
        global.fetch.mockRestore()
    })
})

hooks测试

对react的自定义编写的 hooks 进行测试

我们先来编写一个自定义 hooks

import { useCallback, useRef } from 'react';
import useUpdate from './useUpdate';

export interface usePropsValueProps {
  value?: any;
  onChange?: any;
}

export default function usePropsValue(options: usePropsValueProps) {
  const { value, onChange } = options;

  const update = useUpdate();

  // 是否受控
  const controlled = options.hasOwnProperty('value');

  const stateRef = useRef(controlled ? value : undefined);
  if (controlled) {
    stateRef.current = value;
  }

  const setState = useCallback(
    (v, ...args) => {
      if (!controlled) {
        stateRef.current = v;
        update();
      }
      onChange?.(v, ...args);
    },
    [value, update, onChange],
  );
  return [stateRef.current, setState];
}

我们来为他编写测试用例:

import {act,renderHook} from '@testing-library/react-hooks'
import usePropsValue from '../usePropsValue'

describe('usePropsValue',()=>{

    it('should be defined',()=>{
        expect(usePropsValue).toBeDefined()
    })

    const setUp = <T extends object>(props:T)=>(
        renderHook(()=>{
            const [value,setValue] = usePropsValue(props)
            return {
                value,
                setValue
            }
        })
    )

    it('should support value',()=>{
        const hook = setUp<any>({
            value:'叶小秋'
        })
        expect(hook.result.current.value).toBe('叶小秋')
    })

    it('should support function update',()=>{
        let name = '叶小秋'
        const hook = setUp<any>({
            value:name,
            onChange:(value)=>{
                name = value
            }
        })
        act(()=>{
            hook.result.current.setValue('一叶之秋')
        })
        expect(name).toBe('一叶之秋')

        const hook1 = setUp<any>({})
        expect(hook1.result.current.value).toBe(undefined)
        act(()=>{
            hook1.result.current.setValue('叶小秋')
        })
        expect(hook1.result.current.value).toBe('叶小秋')
    })
})

测试覆盖率报告

单元测试覆盖率是一种软件测试的度量指标,描述程序中源代码被测试的比例和程度

如何生成报告?

在jest.config.js 配置

module.exports = {
  setupFilesAfterEnv: ["@testing-library/jest-dom/extend-expect"],
  // // 是否显示覆盖率报告
  // collectCoverage: true,
  // 告诉 jest 哪些文件需要经过单元测试测试
  collectCoverageFrom: ["components/**/*.{ts,tsx}", "!components/**/__test__/__snapshots__"],
  // 设置单元测试覆盖率阀值
  coverageThreshold: {
    // 设置单测覆盖率阀值
    global: {
      // 保证每个语句都执行了
      statements: 90,
      // 保证每个函数都调用了
      functions: 90,
      // 保证每个 if 等分支代码都执行了
      branches: 90,
      // 保证 每一行都执行了
      // lines:xxx,
    },
  },
};

生成报告

npm run test:coverage

指标说明
% Stmts语句覆盖率:是不是每个语句都执行了?
% Branch分支覆盖率:是不是每个if代码块都执行了?
% Funcs函数覆盖率:是不是每个函数都调用了?
% Lines行覆盖率:是不是每一行都执行了?

常用API

jest

    • .toBe(value) 相等性,检查规则为 === + Object.is
    • .toEqual(value) 相等性,递归对比对象字段
    • .toBeInstanceOf(Class) 检查是否属于某一个 Class 的 instance
    • .toHaveProperty(keyPath, value) 检查断言中的对象是否包含 keyPath 字段,或可以检查该对象的值是否等于 value
    • .toBeGreaterThan(number) 大于 number
    • .toBeGreaterThanOrEqual(number) 大于等于 number
    • .toBeNaN() 值是否是 NaN
    • .toMatch(regexp or String) 字符串的相等性,可以填入 string 或者一个正则
    • .toContain(item) 截取字符串中的子串
    • .toHaveLength(number) 字符串长度
    • .not 当前测试的反面
    • .toThrow(error?) 检查是否抛出异常
  • mock
    • jest.fn 创建Mock函数
    • jest.mock mock 一些不好控制的依赖,比如第三方包
    • jest.spyOn 创建一个类似于jest的mock函数。fn,但也可以跟踪对对象[methodName]的调用

@testing-library/react

  • render 渲染组件
  • screen 为测试用例提供了一个全局 DOM 环境
  • act 测试异步代码时包裹,确保状态更新
  • screen.debug 输出元素

@testing-library/user-event

  • .click 点击事件
  • .type 在输入框输入
  • .upload 文件上传

参考文献

demo 地址

github.com/MrYeZiqing/…