单元测试
- 针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。
最小单位: 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'
上面 fireEvent 与 userEvent 有很多相似的地方, 实际上,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('传入点击', () => {
render(<Button {...BTNProps}></Button>)
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')
})
test('内部点击', () => {
render(<Button></Button>)
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" />
</>
)
}
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()
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)
expect(btn).toHaveClass('disabled')
act(() => {
jest.runAllTimers()
})
expect(btn).not.toHaveClass('disabled')
})
})
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(<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',
},
}
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)
test('data', async () => {
render(<Demo />)
await waitFor(() => {
const list = screen.getByTestId('list')
expect(list.children).toHaveLength(3)
})
})
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";
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;
|