前言
之前一次想学写单测,但是一直没有动力,因为感觉ui测试写起来比较复杂而且麻烦,再加上实际开发项周期短,没有时间给写单测,但是最近公司比较注重质量,对测试bug数提出了限制,而且还有惩罚措施,为了避免被惩罚,再因为没有写单测,在给的自测时间里,后端同学可以通过梳理代码,补单测来进行测试,而我只能不断的点点点…来测试,很low且没效率,心里也没底(虽然最后测试也没测出bug),总感觉机器比人可靠,因此我后面就开始学习单测,来补全这个项目的测试,在结合gitlab ci ,sonarqube来进行测试报告。
我用的框架是react, 官方推荐jest+testing-library/react ,所以我选择了这两个来写单测。因为用的是antd的框架,平时的业务代码也类似,网上也没太多实际例子,就在这里记录下来,一些常用场景怎么写单测, 以及如何配置。
install
install jest
- 首先安装jest (如果是用react-create 生成的会自带jest 可以跳过这里) `` yarn add --dev jest
- 安装 babel
yarn add --dev babel-jest @babel/core @babel/preset-env
新建babel.config.js文件
module.exports ={presets:[['@babel/preset-env',{targets:{node:'current'}}]],};
- 支持ts
yarn add --dev @babel/preset-typescript
修改babel.config.js 加入@babel/preset-typescript
module.exports ={presets:[['@babel/preset-env',{targets:{node:'current'}}],'@babel/preset-typescript',],};
安装ts-jest, jest 的 TypeScript 预处理器
yarn add --dev ts-jest
- jest 配置文件
jest --init
初始化配置 生成jest.config.ts文件
exportdefault{// 不建议自动mock 可以手动mock 后面会有讲
automock:false,// Automatically clear mock calls, instances, contexts and results before every test
clearMocks:true,// 测试覆盖率
collectCoverage:true,// 测试覆盖率收集来源
collectCoverageFrom:['src/**/*.ts','src/**/*.tsx','!**/node_modules/**'],// 测试覆盖率生成的目录文件
coverageDirectory:'coverage',// Indicates which provider should be used to instrument code for coverage
coverageProvider:'babel',// babel不支持的一些文件 例如图片 css/scss 模块的映射
moduleNameMapper:{'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|ico)$':'<rootDir>/__mocks__/fileMock.js',// 新建__mocks__/fileMock.js'\\.(css|less|scss|sass)$':'identity-obj-proxy',//className查找都会原样返回 要安装identity-obj-proxy},// Use this configuration option to add custom reporters to Jest
reporters:['default',['jest-sonar',{
outputDirectory:'./coverage',// 在这个文件夹下面
outputName:'test-report.xml',// 最后生成文件名字
reportedFilePath:'relative',// 相对路径},],],// A list of paths to modules that run some code to configure or set up the testing framework before each test
setupFilesAfterEnv:['<rootDir>/setupTest.ts'],// The test environment that will be used for testing
testEnvironment:'jsdom',// js测试环境 要安装 jest-environment-jsdom// A map from regular expressions to paths to transformers
transform:{'^.+\\.(js|jsx)$':['babel-jest'],'^.+\\.(ts|tsx)$':['ts-jest'],},// 转换器要忽略的路径
transformIgnorePatterns:['[/\\\\]node_modules/(?!(antd)/)[/\\\\].+\\.(js|jsx|ts|tsx)$'],};
- 安装 jest-environment-jsdom
yarn add --dev jest-environment-jsdom
提供js dom测试环境 - 为了识别 scss/css文件 要安装identity-obj-proxy
yarn add --dev identity-obj-proxy
className查找都会原样返回 - 为了识别图片等文档,需要把他们映射到指定文件 fileMock.js
exportdefault'test-file-stub';
在packgage.json script里面加
test:jest --coverage
运行
yarn test
会自动测试,测试完成生成测试覆盖率,以及测试报告在coverage/icov-report/index.html文件里面
install testing-library/react
yarnadd --dev @testing-library/react @testing-library/jest-dom @testing-library/react-hooks @testing-library/user-event
- @testing-library/react : 以用户为中心的方式测试 UI 组件。
- @testing-library/jest-dom 好用的自定义 jest 匹配器来测试 DOM 的状态
- @testing-library/react-hooks 测试react hook
- @testing-library/user-event 测试交互事件 根目录 新建setupTest.ts文件 默认启动导入 @testing-library/jest-dom,避免在每个文件单独写
import'@testing-library/jest-dom';
install eslint-plugin-testing-library
yarn add --dev eslint-plugin-testing-library
来进行测试代码的语法检查 建议加上,测试写法有多种,容易写烂代码,这里可以约束下。
到这里前置的安装工作就完成了,后面我把一些常用的API进行记录下。
常用API
jest
具体见官网:https://jestjs.io/docs/jest-community
global
- beforeEach afterEach 真正测试之前(之后)要执行的 一般是配置或者mock 每个测试单元需要的都提取出来,不用每次都写
beforeEach(()=>{initializeCityDatabase();});afterEach(()=>{clearCityDatabase();});test('city database has Vienna',()=>{expect(isCity('Vienna')).toBeTruthy();});test('city database has San Juan',()=>{expect(isCity('San Juan')).toBeTruthy();});
- beforeAll afterAll 在文件开头之执行一次
- describe(name, fn) 创建一个将几个相关测试组合在一起的块
- test(name,fn,timeout) 单个测试
Expect 判断是否符合预期
- 判断是不是基本类型的值
expect(..).toBe('a')
- 判断undefined
expect(...).toBeDefined()
不是undefinedexpect(...).toBeUndefined()
是undefined - 判断对象 数组 `expect(…).toStrictEqual()
- 判断throwError
expect(...).toThrow()
- 判断数组是否包括啥值
expect(...).toContain('lime');
- 判断方法是否被调用
expect(...).toHaveBeenCalled()
- 判断方法调用次数
expect(...).toHaveBeenCalledTimes(num)
- 判断方法调用的参数
expect(...).toHaveBeenCalledWith(arg1,arg2...);
- 判断方法返回值
expect(...).toHaveReturned();
Mock 模拟方法
- mock function
jest.fn(()=>{})
下面的例子是测试function 传入function作为回调函数 我们不关心这个函数具体内容,可以模拟这个回调函数 就判断这个回调函数是否被执行
it('方法被触发5次,但是最后只执行一次',()=>{const callBack = jest.fn();fun(callBack)expect(callBack).toHaveBeenCalledTimes(1);});
- mock module
jest.mock('axios')
模拟模块 整个模块模拟 例如模拟auth.ts 模块 在模块的目录内新建 __mocks__文件 auth.tsmocks/auth.ts 文件
exportconst logoutMock = jest.fn();exportconst getTokenMock = jest.fn().mockReturnValue('token');exportconst getUserNameMock = jest.fn().mockReturnValue('a');exportconst auth ={
getToken: getTokenMock,
logout: logoutMock,
getUserName: getUserNameMock,};
原auth.ts文件
classAuth{privatereadonly key ='token';constructor(privatereadonly url ='xxxx'){}logout(){this.setToken('');
localStorage.removeItem(this.key);const{ token,...rest }=getPageQuery();const searchArr = Object.entries(rest).map(([k, v])=>`${k}=${v}`);const searchStr = searchArr.length >0?`?${searchArr.join('&')}`:'';const redirectUrl =encodeURIComponent(`${window.location.origin}${window.location.pathname}${searchStr}`,);
window.location.href =`${this.url}/logout?r=${redirectUrl}`;}getToken():string{const str = localStorage.getItem(this.key);if(str){return str;}this.logout();return'';}setToken(token:string){// 对 token 进行 base64 编码,然后存入 LocalStorage
localStorage.setItem(this.key, token);}...getUserName():string{const decoded =this.jwtTokenVerify();return decoded?.data?.username ||'';}}exportconst auth =newAuth();
使用时 前mock 当再调用 auth.logou 时 会使用mock的函数
jest.mock('../service/auth');it('当status=401时,弹出提示:登录状态已失效,请重新登录,1秒后登出',()=>{....expect(messageErrorMock).toBeCalledWith('登录状态已失效,请重新登录');expect(setTimeout).toHaveBeenCalledTimes(1);expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function),1000);expect(interceptorsResponseResolved).toBeUndefined();
jest.runAllTimers();expect(auth.logout).toBeCalled();});
- mock 模块里的部分方法 在测试前直接jest.mock(‘模块名’,构造器函数)
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom',()=>({...(jest.requireActual('react-router-dom')asany),// 拿原来的模块的方法useNavigate:()=> mockedUsedNavigate,// 重写部门要模拟的函数}));it('创建规则, 发送创建请求 返回列表页面',async()=>{....expect(mockedUsedNavigate).toHaveBeenCalledWith('/dashboard/permission');})
- spy 模块的一个方法 jest.spyOn(auth,‘setToken’)
it('auth.logout 登出',()=>{const setTokenSpy = jest.spyOn(auth,'setToken');// mock auth.setToken 方法
auth.logout();expect(setTokenSpy).toHaveBeenCalledWith('');//测试登出时要清空 token 判断auth.setToken方法有木有被执行....
setTokenSpy.mockRestore();});
- mock 定时器 测试定时器1s后执行回调函数
jest.useFakeTimers();
jest.spyOn(global,'setTimeout');test('1s 后回调函数执行',()=>{const callback = jest.fn();someFn(callback);expect(setTimeout).toHaveBeenCalledTimes(1);expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function),1000);
jest.runAllTimers();expect(callback).toBeCalled();expect(callback).toHaveBeenCalledTimes(1);});
原function
exportfunctionsomeFn(callback){setTimeout(()=>{
callback &&callback();},1000);}
React Testing Library
Queries
getxx queryxx findxx的区别:
getxx是直接一定存在,不存在报错,queryxx一般查询不一定存在,不存在不报错,findxx是查找一般是异步和await一起出现
带all 是找所以
- ByRole 根据role属性获取元素
getByRole, queryByRole, getAllByRole, queryAllByRole, findByRole,
findAllByRole
常见html标签的role
htmlrolealinkarticlearticlebuttonbuttondddefinitiondttermformformhi-h6headinghrseparatorimgimginput type=buttonbuttoninput type=checkboxcheckboxinput type=numberspinbuttoninput type=radioradioinput type=rangesliderinput type=submitbuttonnput type=texttexboxlilistitemmainmainmenulistnavnavigationollistoptionoptionprogressprogressbarselectcombobox or listboxtabletabletbodyrowgrouptextareatexboxtfootrowgrouptheadrowgrouptdcellthcolumheadertrrowullist
*ByRole的参数属性
- aria-hidden
getAllByRole('button', { hidden: true })
- aria-selected
getByRole('tab', { selected: true })
- aria-checked
getByRole('checkbox', { checked: true })
- aria-current=“true”
getByRole('link', { current: true })
- aria-pressed
getByRole('button', { pressed: true })
- aria-expanded
getByRole('link', { expanded: false })
- h1-h6
getByRole('heading', { level: 2 })
- aria-describedby
getByRole('alertdialog', {description: 'Your session is about to expire'})
- ByLabelText 查询label
getByLabelText, queryByLabelText, getAllByLabelText,
queryAllByLabelText, findByLabelText, findAllByLabelText
// 1. lable for + input id
<labelfor="username-input">Username</label><inputid="username-input"/>
// aria-labelledby
<labelid="username-label">Username</label><inputaria-labelledby="username-label"/>
// label include input
<label>Username <input/></label>
// label include input
<label><span>Username</span><input/></label>
// aria-label
<inputaria-label="Username"/>
上面可以通过 ·
screen.getByLabelText('Username')
找到input
可选参数传入 selector
const inputNode = screen.getByLabelText('Username', {selector: 'input'})
- ByPlaceholderText 根据plaveholder属性找
screen.getByPlaceholderText('Username')
getByPlaceholderText, queryByPlaceholderText, getAllByPlaceholderText,
queryAllByPlaceholderText, findByPlaceholderText,
findAllByPlaceholderText
- ByText 根据文本找
screen.getByText(/about/i)
getByText, queryByText, getAllByText, queryAllByText, findByText, findAllByText
可选参数:selector ignore
- ByDisplayValue 跟据input, textarea, or select的值找元素
screen.getByDisplayValue('Norris')
getByDisplayValue, queryByDisplayValue, getAllByDisplayValue, queryAllByDisplayValue, findByDisplayValue, findAllByDisplayValue
- ByAltText 根据img alt属性 找
screen.getByAltText(/incredibles.*? poster/i)
getByAltText, queryByAltText, getAllByAltText, queryAllByAltText, findByAltText, findAllByAltText
- ByTitle 根据title和标签属性找
screen.getByTitle('Delete')
getByTitle, queryByTitle, getAllByTitle, queryAllByTitle, findByTitle, findAllByTitle
- ByTestId 根据data-testid属性找
screen.getByTestId('custom-element')
getByTestId, queryByTestId, getAllByTestId, queryAllByTestId, findByTestId, findAllByTestId
- waitFor 可以查询异步时间 这规定时间内一直找 知道找到expect
awaitwaitFor(()=>expect(mockAPI).toHaveBeenCalledTimes(1))
- waitForElementToBeRemoved 等待从 DOM 中移除元素
waitForElementToBeRemoved(document.querySelector('div.getOuttaHere')).then(()=>console.log('Element no longer in DOM'),)
jest-dom
- toBeDisabled
expecr(..).toBeDisabled()
被禁用 - toBeEnabled
expecr(..).toBeEnabled()
等价于expecr(..).not.toBeDisabled()
- toBeEmptyDOMElement 没有内容
expect(...).toBeEmptyDOMElement()
- toBeInTheDocument 存在元素 ·
expect(...).toBeInTheDocument()
- toBeInvalid() aria-invalid="true"的元素
expect(getByTestId('aria-invalid')).toBeInvalid()
- toBeRequired() required or aria-required="true 的元素
expect(getByTestId('required-input')).toBeRequired()
- toBeValid() aria-invalid=“false” 的元素
expect(getByTestId('no-aria-invalid')).toBeValid()
- toBeVisible() 存在文档中,display不为none,visibility不为hidden or collapse,opacity不为0,父元素可见,没有hidden属性
expect(getByText('Zero Opacity Example')).not.toBeVisible()
- toContainElement() 一个元素是不是另一个元素的后代
expect(ancestor).toContainElement(descendant)
- toContainHTML() 断言表示 HTML 元素的字符串是否包含在另一个元素中
expect(getByTestId('parent')).toContainHTML('<span data-testid="child"></span>')
- toHaveAccessibleDescription() 具有预期的可访问描述title aria-label aria-describedby
expect(getByTestId('link')).toHaveAccessibleDescription('A link to start over')
- toHaveAccessibleName
- toHaveAttribute() 元素是否具有属性
expect(button).toHaveAttribute('disabled') expect(button).toHaveAttribute('type', 'submit')
- toHaveClass
expect(deleteButton).toHaveClass('btn-danger', 'btn')
- toHaveFocus()
expect(input).toHaveFocus()
- toHaveFormValues() form表单的值
expect(getByTestId('login-form')).toHaveFormValues({
username:'jane.doe',
rememberMe:true,})
- toHaveStyle()
expect(button).toHaveStyle('display: none')
- toHaveTextContent()
expect(element).toHaveTextContent('Content')
- toHaveValue()
expect(textInput).toHaveValue('text')
- toHaveDisplayValue显示值
expect(input).toHaveDisplayValue('Luca')
- toBeChecked
expect(inputCheckboxChecked).toBeChecked()
- toBePartiallyChecked()
xpect(ariaCheckboxMixed).toBePartiallyChecked()
- toHaveErrorMessage() aria-errormessage
expect(timeInput).toHaveErrorMessage(/invalid time/i)
@testing-library/user-event
- 输入
await userEvent.type(input,''aa'')
输入aa - 清空
await userEvent.clear(inpur)
- 选择
await userEvent.selectOptions(screen.getByRole('listbox'), ['1', 'C'])
- 取消选择
await userEvent.deselectOptions(screen.getByRole('listbox'), '2')
- 上传
await userEvent.upload(input, file)
- 点击
await userEvent.click(button)
- 双击
await userEvent.dblClick(button)
- hover
await userEvent.hover(a)
基本语法已经完成 下面讲从react+antd常见业务的实践。
版权归原作者 zw_slime 所有, 如有侵权,请联系我们删除。