> 文章列表 > react单元测试

react单元测试

react单元测试

单元测试

  • 针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。
    最小单位: main / userPart
    正确性检验: 验证 预期结果 与 输出结果 是否一致

测试作用

  • 保证代码质量 提高效率
  • 更早的发现bug, 降低bug出现与复现
  • 增强开发者信心

测试思想

  • TDD:
    Test-Driven Development(测试驱动开发)
    编写某个功能的代码之前先编写测试代码,仅编写使测试通过的功能代码,通过测试来推动整个开发的进行

  • BDD:
    Behavior-Driven Development(行为驱动开发)使用自然语言来描述系统功能和业务逻辑,根据描述步骤进行功能开发,然后编写的测试代码

测试类型

  • 单元测试(Unit Test)
    作用: 保证最小单位的代码与预期结果一致性
    应用: 公共函数,单个组件
  • 集成测试(Integration Test)
    作用: 测试经过单元测试后的各个模块组合在一起是否能正常工作
    应用: 耦合度较高的函数/组件、二次封装的函数/组件、多个函数/组件组合而成的代码
  • 界面测试(UI Test)
    作用: 脱离真实后端环境,程序中数据来源通过Mock模拟
    应用: 开发过程中的自测
  • 端到端测试(E2E test)
    作用: 整个应用程序在真实环境中运行,数据来源后端
    应用: 测试工程师手工测试与自动测试

React 测试库搭配

  • Airbnb
    Enzyme+chai+sinon+jest
    enzyme: 模拟react组件运行及输出, 操作、遍历
    chai: BDD / TDD 断言库,适用于节点和浏览器,可以与任何 js 测试框架搭配
    sinon: 具有可以模拟 spies, stub, mock 功能的库
  • Testing-library
    testing-library/react + testing-library/jest-dom + testing-library/user-event + jest
    testing-library/react: 将 React 组件渲染为DOM
    testing-library/jest-dom: 增加额外的 DOM Matchers
    testing-library/user-event: 浏览器交互模拟(事件模拟库)

测试文件定义

在这里插入图片描述

单元测试思路

  • 准备数据
  • 渲染组件
  • 断言结果

jest学习 – 匹配器使用

test('精准匹配', () => {expect(2 + 2).toBe(4)
})test('对象匹配', () => {const data = { one: 1 }data['two'] = 2expect(data).toEqual({ one: 1, two: 2 })
})test('相反匹配', () => {const a = 10const b = 20expect(a + b).not.toBe(50)
})test('布尔匹配', () => {const B = nullexpect(B).toBeFalsy()expect(B).toBeNull()expect(B).not.toBeUndefined()expect(B).not.toBeTruthy()
})test('等价匹配', () => {const A = 2,B = 2expect(A + B).toBeGreaterThan(3)expect(A + B).toBeLessThan(5)const F = 0.1 + 0.2expect(F).toBeCloseTo(0.3)
})test('字符串匹配', () => {const str = 'abcd'expect(str).toMatch(/ab/)
})test('数组匹配', () => {const List = ['hello', 'world', 'one', 'two', 'three', 'four']expect(List).toContain('one')
})function Err() {throw new Error('抛出错误')
}
test('错误匹配', () => {expect(() => Err()).toThrow(/错误/)
})

testing-library 学习 – 节点查询

  • 官方文档
    https://testing-library.com/docs/queries/about/#types-of-queries
  • 单节点查询
    getByText : 查询匹配节点,没有或者找到多个会报错
    queryByText: 查询匹配节点,没有匹配到返回null(主要用于断言不存在的元素),找到多个抛出错误
    getAllByText: 返回一个promise, 找到匹配的元素时解析成一个元素,未找到或超时(1秒),找到多个都会报错
  • 多节点查询
    queryAllByText: 返回查询的所有匹配节点的数组,如果没有元素匹配则抛出错误
    findByText: 返回查询的所有匹配节点的数组,如果没有元素匹配,则返回空数组
    findAllByText: 返回一个promise,当找到与给定查询匹配的任何元素时,它会解析为一个元素数组。如果在默认超时 1000 毫秒后没有找到任何元素,则该承诺将被拒绝
    在这里插入图片描述

差异对比

import { fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

上面 fireEventuserEvent 有很多相似的地方, 实际上,userEvent是对 fireEvent补充, userEvent 从用户角度模拟交互行为

测试场景学习

debug 方法

import {screen, render} from '@testing-library/react'
screen.debug()
const { debug } = render(<Demo click={fn} />)
debug()

点击测试

点击模拟 - 被测文件

import React, { useState } from 'react'export type ButtonProps = {onClick?: () => void
}const Button = (props: ButtonProps) => {const [btnState, setBtnState] = useState<boolean>(false)const inSideClick = () => setBtnState((state) => !state)return (<div><button onClick={props.onClick('112')}>Click</button><button onClick={inSideClick} data-testid="toggle">{btnState ? '点击了' : '未点击'}</button></div>)
}export default Button

点击模拟 - 测试用例

import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import Button, { ButtonProps } from './index'const BTNProps: ButtonProps = {onClick: jest.fn(),
}describe('onClick 测试', () => {test('传入点击', () => {// 渲染 React DOMrender(<Button {...BTNProps}></Button>)// 在 screen 找到需要断言的元素const element = screen.getByText('Click') as HTMLButtonElement// 断言 结果与预期一致性expect(element.tagName).toEqual('BUTTON')// 模拟点击fireEvent.click(element)// 断言 是否已经被点击expect(BTNProps.onClick).toHaveBeenCalled()// 被点击次数expect(BTNProps.onClick).toBeCalledTimes(1)// 点击传参测试expect(BTNProps.onClick).toBeCalledWith('112') // 参数})// 内部点击事件是否触发,可以通过DOM变化,间接测试test('内部点击', () => {// 渲染 React domrender(<Button></Button>)// 通过 test id 获取渲染树元素const element = screen.getByTestId('toggle') as HTMLButtonElement// 断言 '未点击' 文本内容是否存在expect(element).toHaveTextContent('未点击')// 模拟点击fireEvent.click(element)// 断言 是否点击expect(element).toHaveTextContent('点击了')})
})

快照测试

被测文件: 这里使用上面模拟点击的 被测文件

// 测试用例
import React from 'react'
import { render, screen } from '@testing-library/react'
import Button from '../onClick/index'
test('快照测试', () => {render(<Button></Button>)const element = screen.getByTestId('toggle') as HTMLButtonElementexpect(element).toMatchSnapshot()
})

测试结果: 生成一个__snapshots__文件夹
react单元测试

input 测试

import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'// 被测试组件
function Demo(props: any) {return (<><button onClick={() => props?.click('112')}>click</button><input type="text" data-testid="input" /><input type="text" data-testid="blur" /></>)
}// input 测试用例
describe('input 测试', () => {test('input', async () => {// 渲染组件render(<Demo />)// 获取节点const input = screen.getByTestId('input') as HTMLInputElement// 模拟输入fireEvent.change(input, { target: { value: '1223' } })// 判断值expect(input.value).toBe('1223')})test('blur', async () => {// 渲染组件render(<Demo />)// 获取节点const input = screen.getByTestId('blur') as HTMLInputElement// 模拟输入激活焦点input.blur()})test('userEvent input', () => {const fn = jest.fn()const { container, debug } = render(<Demo click={fn} />)// 断点使用debug()// 查找 DOM const btn = container.querySelector('button') as HTMLButtonElement// 模拟事件点击userEvent.click(btn)// 调用 次数expect(fn).toBeCalledTimes(1)})
})

select option 测试

import React, { useCallback, useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react-dom/test-utils'
test('selectOptions', async () => {render(<select multiple><option value="1">A</option><option value="2">B</option><option value="3">C</option></select>,)await userEvent.selectOptions(screen.getByRole('listbox'), ['1', 'C'])expect(screen.getByRole('option', {name: 'A'}).selected).toBe(true)expect(screen.getByRole('option', {name: 'B'}).selected).toBe(false)expect(screen.getByRole('option', {name: 'C'}).selected).toBe(true)
})
import React, { useCallback, useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react-dom/test-utils'test('deselectOptions', async () => {render(<select multiple><option value="1">A</option><option value="2" selected>B</option><option value="3">C</option></select>,)await userEvent.deselectOptions(screen.getByRole('listbox'), '2')expect(screen.getByText('B').selected).toBe(false)
})

定时器模拟

import React, { useCallback, useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react-dom/test-utils'// 被测试组件
function Demo() {const [flag, setFlag] = useState(false)const clickHandle = useCallback(() => {setFlag(true)setTimeout(() => {setFlag(false)}, 2000)}, [setFlag])return (<><button className={`${flag ? 'disabled' : ''}`} onClick={clickHandle}>click</button></>)
}// 模拟定时器测试
describe('mock time', () => {test('setTimeout', async () => {// 模拟定时器jest.useFakeTimers()// 渲染组件render(<Demo />)// 查找元素const btn = screen.getByRole('button')// 断言expect(btn).not.toHaveClass('disabled')// 模拟点击userEvent.click(btn)// 断言 dom 中是否包含 classexpect(btn).toHaveClass('disabled')// act 是 test-utils 的一个异步方法act(() => {// 模拟 准确时间// jest.advanceTimersByTime(2000)// 模拟所有的定时器jest.runAllTimers()})expect(btn).not.toHaveClass('disabled')// debug 方法})
})

re render 测试

import { render } from '@testing-library/react'
import React from 'react'function Demo(props) {return <div>{props.num}</div>
}test('re render', () => {const { container, debug, rerender } = render(<Demo num={2} />)expect(container.querySelector('div').textContent).toBe('2')// 通过 rerender 字段实现重新渲染rerender(<Demo num={5} />)expect(container.querySelector('div').textContent).toBe('5')
})

自定义 hooks 测试

通过 @testing-library/react-hooks 这个库实现自定义hooks测试

import { renderHook, act } from '@testing-library/react-hooks'
import React, { useEffect, useState } from 'react'function useSum(init) {const [count, setCount] = useState(init)const [resNum, setResNum] = useState()useEffect(() => {setResNum(count + 10)}, [count])return { resNum, setCount }
}test('hooks', () => {const { result } = renderHook(() => useSum(0))expect(result.current.resNum).toBe(10)act(() => {result.current.setCount(100)})expect(result.current.resNum).toBe(110)
})

复用逻辑测试

被测试组件

import React from 'react'enum Types {red = 'red',green = 'green',blue = 'rgb(34, 35, 35)',
}function Demo(props) {return (<><button style={{ background: props.types }}>btn</button></>)
}

正常测试


import { render, screen, waitFor } from '@testing-library/react'
import { act } from 'react-dom/test-utils'enum Types {red = 'red',green = 'green',blue = 'rgb(34, 35, 35)',
}test('btn background', async () => {const { rerender } = render(<Demo types={'red'} />)expect(screen.getByRole('button').style.background).toBe(Types.red)act(() => {rerender(<Demo types={'green'} />)})expect(screen.getByRole('button').style.background).toBe(Types.green)rerender(<Demo types={'#222323'} />)expect(screen.getByRole('button').style.background).toBe(Types.blue)
})

使用 test.each 简化测试

enum Types {red = 'red',green = 'green',blue = 'rgb(34, 35, 35)',
}
test.each([['red', Types.red],['green', Types.green],['#222323', Types.blue],
])('test each', (type, expected) => {render(<Demo types={type} />)expect(screen.getByRole('button').style.background).toBe(expected)
})

测试 redux

测试 redux - 被测文件

import React from 'react'
import { useSelector } from 'react-redux'
import { useHistory } from 'react-router-dom'export type StoreType = {userInfo: {age: numbername: stringid: string}
}
const UserInfoPart = () => {const userInfo = useSelector((store: StoreType) => store.userInfo)const jump = useHistory()const jumpHandle = () => jump.push('a/b/c')return (<div><h3 onClick={jumpHandle}>ID: {userInfo.id}</h3><p>姓名: {userInfo.name}</p><p>年龄:{userInfo.age}</p></div>)
}export default UserInfoPart

测试 redux - 测试文件
通过 redux-mock-store 库 实现 redux 模拟测试

import React from 'react'
import { render, screen } from '@testing-library/react'
import UserInfoPart, { StoreType } from './index'
import configureStore from 'redux-mock-store'
import { Provider } from 'react-redux'// 定义初始化数据
const initState: StoreType = {userInfo: {age: 18,name: 'xiaoming',id: 'xm-110-2',},
}// store 数据模拟
const mockStore = configureStore([])
const store = mockStore(initState)describe('模拟redux', () => {test('验证姓名,年龄', () => {render(<Provider store={store}><UserInfoPart /></Provider>)const element = screen.getByText('姓名: xiaoming')const el = screen.getByText(`ID: xm-110-2`)expect(el.tagName).toBe('H3')expect(element.tagName).toEqual('P')})
})

请求测试

被测组件

import React, { useCallback, useEffect, useState } from 'react'
function Demo() {const [data, setData] = useState(['11'])const [err, setErr] = useState('')const clickHandle = useCallback(async () => {fetch('/user/submit', {method: 'POST',body: JSON.stringify({useranme: '123',}),}).then((res) => {if (res.status === 400) {console.log(res.status)setErr('提交错误')}})}, [setErr])const fetchData = () => {fetch('/list', {method: 'POST',}).then((res: any) => {return res.json()}).then((res: any) => {setData(res)})}useEffect(() => {fetchData()}, [fetchData])return (<><span data-testid="err">{err}</span><button onClick={clickHandle}>click</button><div><ul data-testid="list">{data.length &&data.map((i: string, index: number) => {return <li key={index}>{i}</li>})}</ul></div></>)
}export default Demo

测试用例
模拟请求 需要通过 msw 库实现

import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { setupServer } from 'msw/node'
import { rest } from 'msw'
import Demo from './list'
import { act } from 'react-dom/test-utils'
import { runServer } from './mocks'runServer(beforeAll, afterAll, afterEach)// const server = setupServer()
// const server = setupServer(
// 	rest.post('/list', (req, res, ctx) => {
// 		return res(ctx.json(['1', '2', '3']))
// 	})
// )// beforeAll(() => server.listen())
// afterAll(() => server.close())
// afterEach(() => server.resetHandlers())
test('data', async () => {render(<Demo />)await waitFor(() => {const list = screen.getByTestId('list')expect(list.children).toHaveLength(3)})
})// test('submit', async () => {
// 	render(<Demo />)
// 	// await waitFor(() => {
// 	// 	const list = screen.getByTestId('list')
// 	// 	expect(list.children).toHaveLength(3)
// 	// })
// 	await waitFor(() => {
// 		const btn = screen.getByRole('button')
// 		userEvent.click(btn)
// 	})
// 	await waitFor(() => {
// 		const err = screen.getByTestId('err')
// 		expect(err.textContent).toBe('提交错误')
// 	})
// })

mock 使用

父组件 : Index

import React from "react";
// 引入子组件
import Child from "child";const Index = () => {function callBack(message: string = "") {console.log(`来自子组件的消息是:${message}`);}return (<div className="jest-demo"><Child callBack={callBack} /></div>);
};export default Index;

子组件 child

import React, { useEffect } from "react";type iPropsType = {callBack: Function;
};const Child= (props: iPropsType) => {useEffect(() => {props.callBack("我是正经的子组件");}, []);return <div>子组件</div>;
};export default Child;

测试用例

import React from "react";import { render } from "@testing-library/react";import JestDemo from "../index";// 注意这里 child 组件是需要被模拟的, 使用 mock_component 组件代替 child 组件
jest.mock("./child", () => require("./mock_component").default);describe("组件mock单测", () => {test("mock组件", async () => {const { container } = render(<JestDemo />);expect() // 断言逻辑});
});

mock的组件 : mock_component

import React, { useEffect } from "react";type iPropsType = {callBack: Function;
};const Index = (props: iPropsType) => {useEffect(() => {props.callBack("我是MOCK的子组件");}, []);return <div>页面</div>;
};export default Index;