jest单元测试——项目实战

jest单元测试------项目实战

温故而知新:单元测试工具------JEST

包括:什么是单元测试、jest的基本配置、快照测试、mock函数、常用断言、前端单测策略等等。。

一、纯函数测试

关于纯函数的测试,之前的文章讲的蛮多了,这次重点就不在这里了,感兴趣的同学请移步 温故而知新~🎉

typescript 复制代码
// 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 包:

typescript 复制代码
npm install @testing-library/jest-dom --save-dev

同时,要在 tsconfig.json 里引入这个库的类型声明:

typescript 复制代码
{
  "compilerOptions": {
    "types": ["node", "jest", "@testing-library/jest-dom"]
  }
}

为了防止引入 css 文件报错:

typescript 复制代码
npm install --dev identity-obj-proxy

在项目根目录下创建jest.config.js文件:

typescript 复制代码
module.exports = {
  collectCoverage: true, // 是否显示覆盖率报告
  testEnvironment: 'jsdom', // 添加 jsdom 测试环境
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|scss)$': 'identity-obj-proxy',
  },
}

2. 开始测试------写用例 📝

先用小小的 button 试试水~

typescript 复制代码
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()
  })
})

接下来是业务组件:

typescript 复制代码
// 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掉,不真正地发送请求到后端,自定义接口返回的值。

typescript 复制代码
// 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 的返回:

typescript 复制代码
it('mock axios', async () => {
  jest.spyOn(axios, 'post').mockResolvedValueOnce({
    data: { userType: 'user' },
  })
  const { userType } = await getUserType()
  expect(userType).toBe('user')
})

2. Mock API

另一种方法是 Mock测试文件中的接口函数:

typescript 复制代码
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 工具。

typescript 复制代码
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 就可以灵活模拟测试场景了。

typescript 复制代码
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,提供了增加、减少、设置和重置功能:

typescript 复制代码
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,并把增加、减少、设置和重置功能绑定到按钮:

typescript 复制代码
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 的文本结果来完成测试:

typescript 复制代码
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 结果, 用完就把别人抛弃了。

typescript 复制代码
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:

typescript 复制代码
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 做交互:

typescript 复制代码
// 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:

typescript 复制代码
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的测试用例会被执行,其他测试用例则会被跳过。

举个例子🌰:(只有第二个测试用例会运行,第一个会被跳过,其他文件中的测试用例不会被跳过

typescript 复制代码
describe('Example', () => {
  test('随便不知道是啥', () => {
    // 测试用例
  })
  test.only('我就举个例子', () => {
    // 测试用例
  })
})

2. test.skip

使用场景:想跳过某个测试用例进行调试时

在同一测试文件中,使用test.skip的测试用例会被跳过,其他测试用例正常执行。

用法同 test.only 我就不写例子了

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

相关推荐
surfirst1 小时前
举例说明 .Net Core 单元测试中 xUnit 的 [Theory] 属性的用法
单元测试·.netcore·xunit
回眸&啤酒鸭13 小时前
【回眸】Tessy 单元测试软件使用指南(四)常见报错及解决方案与批量初始化的经验
单元测试·tessy
Iam傅红雪1 天前
mock数据,不使用springboot的单元测试
spring boot·后端·单元测试
月光code2 天前
SLF4J报错log4j又报错
单元测试·log4j
编程经验分享2 天前
Spring Boot 基于 Mockito 单元测试
spring boot·后端·单元测试
神即道 道法自然 如来3 天前
测试面试题:请你分别介绍一下单元测试、集成测试、系统测试、验收测试、回归测试
单元测试·集成测试
友恒3 天前
C#单元测试(一):用 NUnit 和 .NET Core 进行单元测试
单元测试·c#·.netcore
进击的横打3 天前
【车载开发系列】ParaSoft单元测试环境配置(四)
c语言·单元测试
wangyue44 天前
C# MSTest 进行单元测试
单元测试
互联网杂货铺6 天前
软件测试之单元测试/系统测试/集成测试详解
自动化测试·软件测试·python·测试工具·单元测试·测试用例·集成测试