jest单元测试——项目实战
- 一、纯函数测试
- 二、组件测试
- 三、接口测试
- 四、React Hook测试
- 💥 其他的疑难杂症
- 另:好用的方法 🌟
温故而知新:单元测试工具——JEST
包括:什么是单元测试、jest的基本配置、快照测试、mock函数、常用断言、前端单测策略等等。。
一、纯函数测试
关于纯函数的测试,之前的文章讲的蛮多了,这次重点就不在这里了,感兴趣的同学请移步 温故而知新~🎉
// demo.ts
/**
* 比较两个数组内容是否相同
* @param {Array} arr1 - 第一个数组
* @param {Array} arr2 - 第二个数组
* @returns {Boolean} - 如果两个数组内容相同,返回 true,否则返回 false
*/
export const compareArrays = (arr1: ReactText[], arr2: ReactText[]) => {
if (arr1.length !== arr2.length) {
return false
} else {
const result = arr1.every((item) => arr2.includes(item))
return result
}
}
//demo.test.ts
describe('compareArrays', () => {
test('should return true if two arrays are identical', () => {
const arr1 = [1, 2, 3]
const arr2 = [1, 2, 3]
expect(compareArrays(arr1, arr2)).toBe(true)
})
test('should return false if two arrays have different lengths', () => {
const arr1 = [1, 2, 3]
const arr2 = [1, 2, 3, 4]
expect(compareArrays(arr1, arr2)).toBe(false)
})
// 好多好多用例,我就不每个都展示出来了
})
二、组件测试
虽然 Jest 可以对 React 组件进行测试,但不建议在组件上编写太多的测试,任何你想测试的内容,例如业务逻辑,还是建议从组件中独立出来放在单独的函数中进行函数测试,但测试一些 React 交互是很有必要的,例如要确保用户在单击某个按钮时是否正确地调用特定函数。
1. 准备工作——配置 🔧
下载 @testing-library/jest-dom
包:
npm install @testing-library/jest-dom --save-dev
同时,要在 tsconfig.json
里引入这个库的类型声明:
{
"compilerOptions": {
"types": ["node", "jest", "@testing-library/jest-dom"]
}
}
为了防止引入 css 文件报错:
npm install --dev identity-obj-proxy
在项目根目录下创建jest.config.js文件:
module.exports = {
collectCoverage: true, // 是否显示覆盖率报告
testEnvironment: 'jsdom', // 添加 jsdom 测试环境
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|scss)$': 'identity-obj-proxy',
},
}
2. 开始测试——写用例 📝
先用小小的 button 试试水~
describe('Button component', () => {
// 测试按钮文案
test('should have correct text content', () => {
const { getByText } = render(<button>Click me</button>)
expect(getByText('Click me')).toBeInTheDocument()
})
// 使用自定义的匹配器断言 DOM 状态
test('should be disabled when prop is set', () => {
const { getByTestId } = render(
<button disabled data-testid="button">
Click me
</button>
)
expect(getByTestId('button')).toBeDisabled()
})
// 模拟点击事件
test('should call onClick when clicked', () => {
const handleClick = jest.fn()
const { getByText } = render(<button onClick={handleClick}>Click me</button>)
fireEvent.click(getByText('Click me'))
expect(handleClick).toHaveBeenCalled()
})
})
接下来是业务组件:
// demo.tsx
import React from 'react'
import './index.scss'
interface Props {
title: string
showStar?: boolean
}
const Prefix = 'card-title'
export const CardTitle = (props: Props) => {
const { title, showStar = true } = props
return (
<div className={`${Prefix}-title`}>
{showStar && <span className={`${Prefix}-title-star`}>*</span>}
<div>{title}</div>
</div>
)
}
// demo.test.tsx
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
describe('CardTitle', () => {
it('should have correct text content', () => {
const { getByText } = render(<CardTitle title="测试标题" />)
expect(getByText('测试标题')).toBeInTheDocument()
})
it('should render a span if showStar is true', () => {
const { getByText } = render(<CardTitle title="test" showStar={true} />)
expect(getByText('*')).toBeInTheDocument()
})
it('should not render a span if showStar is false', () => {
render(<CardTitle title="测试标题" showStar={false} />)
const span = screen.queryByText('*')
expect(span).not.toBeInTheDocument()
})
})
三、接口测试
在测试的时候我们常常希望: 把接口mock掉,不真正地发送请求到后端,自定义接口返回的值。
// api.ts(接口)
export const getUserRole = async () => {
const result = await axios.post('XXX', { data: 'abc' })
return result.data
}
// index.ts(调用函数)
export const getUserType = async () => {
const result = await getUserRole()
return result
}
1. Mock axios
这种方法可以在不同的测试用例中,根据我们的需要,来控制接口 data 的返回:
it('mock axios', async () => {
jest.spyOn(axios, 'post').mockResolvedValueOnce({
data: { userType: 'user' },
})
const { userType } = await getUserType()
expect(userType).toBe('user')
})
2. Mock API
另一种方法是 Mock测试文件中的接口函数:
import * as userUtils from './api'
it('mock api', async () => {
jest.spyOn(userUtils, 'getUserRole').mockResolvedValueOnce({ userType: 'user' })
const { userType } = await getUserType()
expect(userType).toBe('user')
})
3. Mock Http请求
我们可以不 Mock 任何函数实现,只对 Http 请求进行 Mock!先安装 msw:
🔧 msw 可以拦截指定的 Http 请求,有点类似 Mock.js,是做测试时一个非常强大好用的 Http Mock 工具。
npm install msw@latest --save-dev
需要说明一点,2.0.0以上的版本都是需要node>18的,由于不方便升级,我这里使用的是1.3.3版本(2024-03-15更新的,还是蛮新的哈)
如果你想在某个测试文件中想单独指定某个接口的 Mock 返回, 可以使用 server.use(mockHandler) 。
这里声明了一个 setup 函数,用于在每个用例前初始化 Http 请求的 Mock 返回。通过传不同值给 setup 就可以灵活模拟测试场景了。
import { rest } from 'msw'
import { setupServer } from 'msw/node'
describe('getUserType', () => {
// 需要mock的接口地址
const url = 'http://xxxx'
const server = setupServer()
const setup = (data: { userType: string }) => {
server.use(
rest.post(url, async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(data))
})
)
}
beforeAll(() => {
server.listen()
})
afterEach(() => {
server.resetHandlers()
})
afterAll(() => {
server.close()
})
it('mock http', async () => {
setup({ userType: 'user' })
const { userType } = await getUserType()
expect(userType).toBe('user')
})
})
四、React Hook测试
如果我们需求中需要实现一个 Hook,那么我们要对 Hook 进行测试该怎么办呢?
🌰 举个例子:这里有一个useCounter,提供了增加、减少、设置和重置功能:
import { useState } from 'react'
export interface Options {
min?: number
max?: number
}
export type ValueParam = number | ((c: number) => number)
function useCounter(initialValue = 0) {
const [current, setCurrent] = useState(initialValue)
const setValue = (value: ValueParam) => {
setCurrent((preValue) => (typeof value === 'number' ? value : value(preValue)))
}
// 增加
const increase = (delta = 1) => {
setValue((preValue) => preValue + delta)
}
// 减少
const decrease = (delta = 1) => {
setValue((preValue) => preValue - delta)
}
// 设置指定值
const specifyValue = (value: ValueParam) => {
setValue(value)
}
// 重置值
const resetValue = () => {
setValue(initialValue)
}
return [
current,
{
increase,
decrease,
specifyValue,
resetValue,
},
] as const
}
export default useCounter
🙋有些同学会觉得 Hook 不就是纯函数么?为什么不能直接像纯函数那样去测呢?
❌ NoNoNo,React 规定 只有在组件中才能使用这些 Hooks,所以这样测试的结果就会得到下面的报错:
🙋那又有同学问了,我直接 Mock 掉这些 Hook 不就解决了?
❌ NoNoNo,假如除了 useState,还有 useEffect 这样的呢? 难道每个 React API 都要 Mock 一遍吗?
👉 这里循序渐进列举了三种方法,更推荐第三种哦~
1. 写组件进行整体测试
首先写一个组件,然后在组件内使用 useCounter,并把增加、减少、设置和重置功能绑定到按钮:
import React from 'react'
import useCounter from './useCounter'
export const UseCounterTest = () => {
const [counter, { increase, decrease, specifyValue, resetValue }] = useCounter(0)
return (
<section>
<div>Counter: {counter}</div>
<button onClick={() => increase(1)}>点一下加一</button>
<button onClick={() => decrease(1)}>点一下减一</button>
<button onClick={() => specifyValue(10)}>点一下变成十</button>
<button onClick={resetValue}>重置</button>
</section>
)
}
在每个用例中,我们通过点击按钮来模拟函数的调用,最后 expect 一下 Counter:n 的文本结果来完成测试:
import React from 'react'
import { describe, expect } from '@jest/globals'
import { render, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import { UseCounterTest } from '.'
describe('useCounter', () => {
it('可以做加法', async () => {
const { getByText } = render(<UseCounterTest />)
fireEvent.click(getByText('点一下加一'))
expect(getByText('Counter: 1')).toBeInTheDocument()
})
it('可以做减法', async () => {
const { getByText } = render(<UseCounterTest />)
fireEvent.click(getByText('点一下减一'))
expect(getByText('Counter: -1')).toBeInTheDocument()
})
it('可以设置值', async () => {
const { getByText } = render(<UseCounterTest />)
fireEvent.click(getByText('点一下变成十'))
expect(getByText('Counter: 10')).toBeInTheDocument()
})
it('可以重置值', async () => {
const { getByText } = render(<UseCounterTest />)
fireEvent.click(getByText('点一下变成十'))
fireEvent.click(getByText('重置'))
expect(getByText('Counter: 0')).toBeInTheDocument()
})
})
这个方法并不好,因为要用按钮来绑定一些操作并触发,可不可以直接操作函数呢?
2. 创建 setup 函数进行测试
我们不想一直和组件进行交互做测试,那么这个方法则只是借了 组件环境来生成一下 useCounter 结果, 用完就把别人抛弃了。
import React from 'react'
import { act, render } from '@testing-library/react'
import useCounter, { ValueParam } from '../useCounter'
interface UseCounterData {
counter: number
utils: {
increase: (delta?: number) => void
decrease: (delta?: number) => void
specifyValue: (value: ValueParam) => void
resetValue: () => void
}
}
const setup = (initialNumber: number) => {
const returnVal = {} as UseCounterData
const UseCounterTest = () => {
const [counter, utils] = useCounter(initialNumber)
Object.assign(returnVal, {
counter,
utils,
})
return null
}
render(<UseCounterTest />)
return returnVal
}
describe('useCounter', () => {
it('可以做加法', async () => {
const useCounterData: UseCounterData = setup(0)
act(() => {
useCounterData.utils.increase(1)
})
expect(useCounterData.counter).toEqual(1)
})
it('可以做减法', async () => {
const useCounterData: UseCounterData = setup(0)
act(() => {
useCounterData.utils.decrease(1)
})
expect(useCounterData.counter).toEqual(-1)
})
it('可以设置值', async () => {
const useCounterData: UseCounterData = setup(0)
act(() => {
useCounterData.utils.specifyValue(10)
})
expect(useCounterData.counter).toEqual(10)
})
it('可以重置值', async () => {
const useCounterData: UseCounterData = setup(0)
act(() => {
useCounterData.utils.specifyValue(10)
useCounterData.utils.resetValue()
})
expect(useCounterData.counter).toEqual(0)
})
})
注意:由于setState 是一个异步逻辑,因此我们可以使用 @testing-library/react 提供的 act 里调用它。
act 可以确保回调里的异步逻辑走完再执行后续代码,详情可见官网这里
3. 使用 renderHook 测试
基于这样的想法,@testing-library/react-hooks 把上面的步骤封装成了一个公共函数 renderHook
注意:在 @testing-library/react@13.1.0 以上的版本已经把 renderHook 内置到里面了,这个版本需要和
react@18 一起使用。如果是旧版本,需要单独下载 @testing-library/react-hooks 包。
这里我使用新的版本,也就是内置的 renderHook:
import { act, renderHook } from '@testing-library/react'
import useCounter from '../useCounter'
describe('useCounter', () => {
it('可以做加法', () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current[1].increase(1)
})
expect(result.current[0]).toEqual(1)
})
it('可以做减法', () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current[1].decrease(1)
})
expect(result.current[0]).toEqual(-1)
})
it('可以设置值', () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current[1].specifyValue(10)
})
expect(result.current[0]).toEqual(10)
})
it('可以重置值', () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current[1].specifyValue(10)
result.current[1].resetValue()
})
expect(result.current[0]).toEqual(0)
})
})
实际上 renderHook 只是 setup 方法里 setupTestComponent 的高度封装而已。
💥 其他的疑难杂症
如果测试组件和 React Router 做交互:
// useQuery.ts
import React from 'react'
import { useLocation } from 'react-router-dom'
// 获取查询参数
export const useQuery = () => {
const { search } = useLocation()
return React.useMemo(() => new URLSearchParams(search), [search])
}
// index.tsx
import React from 'react'
import { useQuery } from '../useQuery'
export const MyComponent = () => {
const query = useQuery()
return <div>{query.get('id')}</div>
}
使用 useLocation 时报错:
要创建 React Router 环境,我们可以使用 createMemoryHistory 这个 API:
import React from 'react'
import { useQuery } from '../useQuery'
import { createMemoryHistory, InitialEntry } from 'history'
import { render } from '@testing-library/react'
import { Router } from 'react-router-dom'
const setup = (initialEntries: InitialEntry[]) => {
const history = createMemoryHistory({
initialEntries,
})
const returnVal = {
query: new URLSearchParams(),
}
const TestComponent = () => {
const query = useQuery()
Object.assign(returnVal, { query })
return null
}
// 此处为 react router v6 的写法
render(
<Router location={history.location} navigator={history}>
<TestComponent />
</Router>
)
// 此处为 react router v5 的写法
// render(
// <Router history={history}>
// <TestComponent />
// </Router>
// );
return returnVal
}
describe('userQuery', () => {
it('可以获取参数', () => {
const result = setup([
{
pathname: '/home',
search: '?id=123',
},
])
expect(result.query.get('id')).toEqual('123')
})
it('查询参数为空时返回 Null', () => {
const result = setup([
{
pathname: '/home',
},
])
expect(result.query.get('id')).toBeNull()
})
})
另:好用的方法 🌟
1. test.only
使用场景:只想对单个测试用例进行调试时
在同一测试文件中,只有使用test.only的测试用例会被执行,其他测试用例则会被跳过。
举个例子🌰:(只有第二个测试用例会运行,第一个会被跳过,其他文件中的测试用例不会被跳过
)
describe('Example', () => {
test('随便不知道是啥', () => {
// 测试用例
})
test.only('我就举个例子', () => {
// 测试用例
})
})
2. test.skip
使用场景:想跳过某个测试用例进行调试时
在同一测试文件中,使用test.skip的测试用例会被跳过,其他测试用例正常执行。
用法同 test.only 我就不写例子了
还有好用的我再补充,散会~ 👏