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'] = 2
	expect(data).toEqual({ one: 1, two: 2 })
})

test('相反匹配', () => {
	const a = 10
	const b = 20
	expect(a + b).not.toBe(50)
})

test('布尔匹配', () => {
	const B = null
	expect(B).toBeFalsy()
	expect(B).toBeNull()
	expect(B).not.toBeUndefined()
	expect(B).not.toBeTruthy()
})

test('等价匹配', () => {
	const A = 2,
		B = 2
	expect(A + B).toBeGreaterThan(3)
	expect(A + B).toBeLessThan(5)
	const F = 0.1 + 0.2
	expect(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 DOM
		render(<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 dom
		render(<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 HTMLButtonElement
	expect(element).toMatchSnapshot()
})

测试结果: 生成一个__snapshots__文件夹
在这里插入图片描述

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 中是否包含 class
		expect(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: number
		name: string
		id: 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;
  • 3
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Gleason.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值