0


jest+testing-library/react 单元测试 react+antd 的实践(一)

前言

之前一次想学写单测,但是一直没有动力,因为感觉ui测试写起来比较复杂而且麻烦,再加上实际开发项周期短,没有时间给写单测,但是最近公司比较注重质量,对测试bug数提出了限制,而且还有惩罚措施,为了避免被惩罚,再因为没有写单测,在给的自测时间里,后端同学可以通过梳理代码,补单测来进行测试,而我只能不断的点点点…来测试,很low且没效率,心里也没底(虽然最后测试也没测出bug),总感觉机器比人可靠,因此我后面就开始学习单测,来补全这个项目的测试,在结合gitlab ci ,sonarqube来进行测试报告。
我用的框架是react, 官方推荐jest+testing-library/react ,所以我选择了这两个来写单测。因为用的是antd的框架,平时的业务代码也类似,网上也没太多实际例子,就在这里记录下来,一些常用场景怎么写单测, 以及如何配置。

install

install jest

  1. 首先安装jest (如果是用react-create 生成的会自带jest 可以跳过这里) `` yarn add --dev jest
  2. 安装 babel yarn add --dev babel-jest @babel/core @babel/preset-env 新建babel.config.js文件
  1. module.exports ={presets:[['@babel/preset-env',{targets:{node:'current'}}]],};
  1. 支持ts yarn add --dev @babel/preset-typescript 修改babel.config.js 加入@babel/preset-typescript
  1. module.exports ={presets:[['@babel/preset-env',{targets:{node:'current'}}],'@babel/preset-typescript',],};

安装ts-jest, jest 的 TypeScript 预处理器

  1. yarn add --dev ts-jest
  1. jest 配置文件 jest --init 初始化配置 生成jest.config.ts文件
  1. exportdefault{// 不建议自动mock 可以手动mock 后面会有讲
  2. automock:false,// Automatically clear mock calls, instances, contexts and results before every test
  3. clearMocks:true,// 测试覆盖率
  4. collectCoverage:true,// 测试覆盖率收集来源
  5. collectCoverageFrom:['src/**/*.ts','src/**/*.tsx','!**/node_modules/**'],// 测试覆盖率生成的目录文件
  6. coverageDirectory:'coverage',// Indicates which provider should be used to instrument code for coverage
  7. coverageProvider:'babel',// babel不支持的一些文件 例如图片 css/scss 模块的映射
  8. 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
  9. reporters:['default',['jest-sonar',{
  10. outputDirectory:'./coverage',// 在这个文件夹下面
  11. outputName:'test-report.xml',// 最后生成文件名字
  12. reportedFilePath:'relative',// 相对路径},],],// A list of paths to modules that run some code to configure or set up the testing framework before each test
  13. setupFilesAfterEnv:['<rootDir>/setupTest.ts'],// The test environment that will be used for testing
  14. testEnvironment:'jsdom',// js测试环境 要安装 jest-environment-jsdom// A map from regular expressions to paths to transformers
  15. transform:{'^.+\\.(js|jsx)$':['babel-jest'],'^.+\\.(ts|tsx)$':['ts-jest'],},// 转换器要忽略的路径
  16. 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
  1. exportdefault'test-file-stub';

在packgage.json script里面加

  1. test:jest --coverage

运行

  1. yarn test

会自动测试,测试完成生成测试覆盖率,以及测试报告在coverage/icov-report/index.html文件里面

install testing-library/react

  1. 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,避免在每个文件单独写
  1. import'@testing-library/jest-dom';

install eslint-plugin-testing-library

  1. yarn add --dev eslint-plugin-testing-library

来进行测试代码的语法检查 建议加上,测试写法有多种,容易写烂代码,这里可以约束下。


到这里前置的安装工作就完成了,后面我把一些常用的API进行记录下。

常用API

jest

具体见官网:https://jestjs.io/docs/jest-community

global

  1. beforeEach afterEach 真正测试之前(之后)要执行的 一般是配置或者mock 每个测试单元需要的都提取出来,不用每次都写
  1. beforeEach(()=>{initializeCityDatabase();});afterEach(()=>{clearCityDatabase();});test('city database has Vienna',()=>{expect(isCity('Vienna')).toBeTruthy();});test('city database has San Juan',()=>{expect(isCity('San Juan')).toBeTruthy();});
  1. beforeAll afterAll 在文件开头之执行一次
  2. describe(name, fn) 创建一个将几个相关测试组合在一起的块
  3. test(name,fn,timeout) 单个测试

Expect 判断是否符合预期

  1. 判断是不是基本类型的值 expect(..).toBe('a')
  2. 判断undefined expect(...).toBeDefined() 不是undefined expect(...).toBeUndefined()是undefined
  3. 判断对象 数组 `expect(…).toStrictEqual()
  4. 判断throwError expect(...).toThrow()
  5. 判断数组是否包括啥值 expect(...).toContain('lime');
  6. 判断方法是否被调用 expect(...).toHaveBeenCalled()
  7. 判断方法调用次数 expect(...).toHaveBeenCalledTimes(num)
  8. 判断方法调用的参数expect(...).toHaveBeenCalledWith(arg1,arg2...);
  9. 判断方法返回值 expect(...).toHaveReturned();

Mock 模拟方法

  1. mock function jest.fn(()=>{}) 下面的例子是测试function 传入function作为回调函数 我们不关心这个函数具体内容,可以模拟这个回调函数 就判断这个回调函数是否被执行
  1. it('方法被触发5次,但是最后只执行一次',()=>{const callBack = jest.fn();fun(callBack)expect(callBack).toHaveBeenCalledTimes(1);});
  1. mock module jest.mock('axios') 模拟模块 整个模块模拟 例如模拟auth.ts 模块 在模块的目录内新建 __mocks__文件 auth.tsmocks/auth.ts 文件
  1. exportconst logoutMock = jest.fn();exportconst getTokenMock = jest.fn().mockReturnValue('token');exportconst getUserNameMock = jest.fn().mockReturnValue('a');exportconst auth ={
  2. getToken: getTokenMock,
  3. logout: logoutMock,
  4. getUserName: getUserNameMock,};

原auth.ts文件

  1. classAuth{privatereadonly key ='token';constructor(privatereadonly url ='xxxx'){}logout(){this.setToken('');
  2. 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}`,);
  3. 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
  4. localStorage.setItem(this.key, token);}...getUserName():string{const decoded =this.jwtTokenVerify();return decoded?.data?.username ||'';}}exportconst auth =newAuth();

使用时 前mock 当再调用 auth.logou 时 会使用mock的函数

  1. 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();
  2. jest.runAllTimers();expect(auth.logout).toBeCalled();});
  1. mock 模块里的部分方法 在测试前直接jest.mock(‘模块名’,构造器函数)
  1. const mockedUsedNavigate = jest.fn();
  2. jest.mock('react-router-dom',()=>({...(jest.requireActual('react-router-dom')asany),// 拿原来的模块的方法useNavigate:()=> mockedUsedNavigate,// 重写部门要模拟的函数}));it('创建规则, 发送创建请求 返回列表页面',async()=>{....expect(mockedUsedNavigate).toHaveBeenCalledWith('/dashboard/permission');})
  1. spy 模块的一个方法 jest.spyOn(auth,‘setToken’)
  1. it('auth.logout 登出',()=>{const setTokenSpy = jest.spyOn(auth,'setToken');// mock auth.setToken 方法
  2. auth.logout();expect(setTokenSpy).toHaveBeenCalledWith('');//测试登出时要清空 token 判断auth.setToken方法有木有被执行....
  3. setTokenSpy.mockRestore();});
  1. mock 定时器 测试定时器1s后执行回调函数
  1. jest.useFakeTimers();
  2. jest.spyOn(global,'setTimeout');test('1s 后回调函数执行',()=>{const callback = jest.fn();someFn(callback);expect(setTimeout).toHaveBeenCalledTimes(1);expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function),1000);
  3. jest.runAllTimers();expect(callback).toBeCalled();expect(callback).toHaveBeenCalledTimes(1);});

原function

  1. exportfunctionsomeFn(callback){setTimeout(()=>{
  2. callback &&callback();},1000);}

React Testing Library

Queries

getxx queryxx findxx的区别:
getxx是直接一定存在,不存在报错,queryxx一般查询不一定存在,不存在不报错,findxx是查找一般是异步和await一起出现
带all 是找所以

  1. 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'})
  1. ByLabelText 查询label

getByLabelText, queryByLabelText, getAllByLabelText,
queryAllByLabelText, findByLabelText, findAllByLabelText

  1. // 1. lable for + input id
  2. <labelfor="username-input">Username</label><inputid="username-input"/>
  3. // aria-labelledby
  4. <labelid="username-label">Username</label><inputaria-labelledby="username-label"/>
  5. // label include input
  6. <label>Username <input/></label>
  7. // label include input
  8. <label><span>Username</span><input/></label>
  9. // aria-label
  10. <inputaria-label="Username"/>

上面可以通过 ·

  1. screen.getByLabelText('Username')

找到input
可选参数传入 selector

  1. const inputNode = screen.getByLabelText('Username', {selector: 'input'})
  1. ByPlaceholderText 根据plaveholder属性找 screen.getByPlaceholderText('Username')

getByPlaceholderText, queryByPlaceholderText, getAllByPlaceholderText,
queryAllByPlaceholderText, findByPlaceholderText,
findAllByPlaceholderText

  1. ByText 根据文本找 screen.getByText(/about/i)

getByText, queryByText, getAllByText, queryAllByText, findByText, findAllByText

可选参数:selector ignore

  1. ByDisplayValue 跟据input, textarea, or select的值找元素 screen.getByDisplayValue('Norris')

getByDisplayValue, queryByDisplayValue, getAllByDisplayValue, queryAllByDisplayValue, findByDisplayValue, findAllByDisplayValue

  1. ByAltText 根据img alt属性 找 screen.getByAltText(/incredibles.*? poster/i)

getByAltText, queryByAltText, getAllByAltText, queryAllByAltText, findByAltText, findAllByAltText

  1. ByTitle 根据title和标签属性找 screen.getByTitle('Delete')

getByTitle, queryByTitle, getAllByTitle, queryAllByTitle, findByTitle, findAllByTitle

  1. ByTestId 根据data-testid属性找 screen.getByTestId('custom-element')

getByTestId, queryByTestId, getAllByTestId, queryAllByTestId, findByTestId, findAllByTestId

  1. waitFor 可以查询异步时间 这规定时间内一直找 知道找到expect
  1. awaitwaitFor(()=>expect(mockAPI).toHaveBeenCalledTimes(1))
  1. waitForElementToBeRemoved 等待从 DOM 中移除元素
  1. waitForElementToBeRemoved(document.querySelector('div.getOuttaHere')).then(()=>console.log('Element no longer in DOM'),)

jest-dom

  1. toBeDisabled expecr(..).toBeDisabled() 被禁用
  2. toBeEnabled expecr(..).toBeEnabled() 等价于 expecr(..).not.toBeDisabled()
  3. toBeEmptyDOMElement 没有内容expect(...).toBeEmptyDOMElement()
  4. toBeInTheDocument 存在元素 ·expect(...).toBeInTheDocument()
  5. toBeInvalid() aria-invalid="true"的元素 expect(getByTestId('aria-invalid')).toBeInvalid()
  6. toBeRequired() required or aria-required="true 的元素 expect(getByTestId('required-input')).toBeRequired()
  7. toBeValid() aria-invalid=“false” 的元素 expect(getByTestId('no-aria-invalid')).toBeValid()
  8. toBeVisible() 存在文档中,display不为none,visibility不为hidden or collapse,opacity不为0,父元素可见,没有hidden属性 expect(getByText('Zero Opacity Example')).not.toBeVisible()
  9. toContainElement() 一个元素是不是另一个元素的后代 expect(ancestor).toContainElement(descendant)
  10. toContainHTML() 断言表示 HTML 元素的字符串是否包含在另一个元素中 expect(getByTestId('parent')).toContainHTML('<span data-testid="child"></span>')
  11. toHaveAccessibleDescription() 具有预期的可访问描述title aria-label aria-describedby expect(getByTestId('link')).toHaveAccessibleDescription('A link to start over')
  12. toHaveAccessibleName
  13. toHaveAttribute() 元素是否具有属性 expect(button).toHaveAttribute('disabled') expect(button).toHaveAttribute('type', 'submit')
  14. toHaveClass expect(deleteButton).toHaveClass('btn-danger', 'btn')
  15. toHaveFocus() expect(input).toHaveFocus()
  16. toHaveFormValues() form表单的值
  1. expect(getByTestId('login-form')).toHaveFormValues({
  2. username:'jane.doe',
  3. rememberMe:true,})
  1. toHaveStyle() expect(button).toHaveStyle('display: none')
  2. toHaveTextContent() expect(element).toHaveTextContent('Content')
  3. toHaveValue() expect(textInput).toHaveValue('text')
  4. toHaveDisplayValue显示值 expect(input).toHaveDisplayValue('Luca')
  5. toBeChecked expect(inputCheckboxChecked).toBeChecked()
  6. toBePartiallyChecked() xpect(ariaCheckboxMixed).toBePartiallyChecked()
  7. toHaveErrorMessage() aria-errormessage expect(timeInput).toHaveErrorMessage(/invalid time/i)

@testing-library/user-event

  1. 输入 await userEvent.type(input,''aa'') 输入aa
  2. 清空 await userEvent.clear(inpur)
  3. 选择 await userEvent.selectOptions(screen.getByRole('listbox'), ['1', 'C'])
  4. 取消选择 await userEvent.deselectOptions(screen.getByRole('listbox'), '2')
  5. 上传 await userEvent.upload(input, file)
  6. 点击 await userEvent.click(button)
  7. 双击 await userEvent.dblClick(button)
  8. hover await userEvent.hover(a)

基本语法已经完成 下面讲从react+antd常见业务的实践。


本文转载自: https://blog.csdn.net/zw52yany/article/details/125745529
版权归原作者 zw_slime 所有, 如有侵权,请联系我们删除。

“jest+testing-library/react 单元测试 react+antd 的实践(一)”的评论:

还没有评论