jest单元测试——项目实战

温故而知新:单元测试工具——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 我就不写例子了

还有好用的我再补充,散会~ 👏

最近更新

  1. TCP协议是安全的吗?

    2024-04-12 14:16:01       18 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-04-12 14:16:01       19 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-04-12 14:16:01       18 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-04-12 14:16:01       20 阅读

热门阅读

  1. ffmpeg编解码opus注意事项

    2024-04-12 14:16:01       18 阅读
  2. 计算机网络——MAC地址和IP地址

    2024-04-12 14:16:01       17 阅读
  3. 汽车传动轴原理?

    2024-04-12 14:16:01       15 阅读
  4. RabbitMQ介绍

    2024-04-12 14:16:01       23 阅读
  5. 技术提升实战:打造个人博客系统

    2024-04-12 14:16:01       17 阅读
  6. vue中nextTick使用以及原理

    2024-04-12 14:16:01       17 阅读
  7. 2024认证杯数学建模B题思路模型代码

    2024-04-12 14:16:01       14 阅读
  8. 【SwiftUI】SwiftUI工程中如何引入DoKit

    2024-04-12 14:16:01       17 阅读
  9. Flink命令行启动Job任务

    2024-04-12 14:16:01       16 阅读
  10. 李白打酒加强版 -- 题解 c++

    2024-04-12 14:16:01       14 阅读
  11. ABAP CALL TRANSACTION 跳转

    2024-04-12 14:16:01       16 阅读