共计 12937 个字符,预计需要花费 33 分钟才能阅读完成。
前端要不要写单测
- 不要问,问就是写
思考
-
测试的颗粒度?
- 找外围逻辑,不要试图一个 test 测试一个大而简单的模块
- 正当利用 mock,擦除一些不必要的逻辑(不要局限于三方依赖)
-
哪些类型的测试?
- utils
- hooks
- ui
- 组件状态(表单组件)本质上是组件交互的后状态(表单值)的扭转是否合乎预期
测试 – 可用组合插件
- 外围 Jest
- @testing-library/react
- @testing-library/react-hooks
- React-test-renderer
Jest 几个罕用配置
// 配置 webpack alias | |
moduleNameMapper: {'@/(.*)$': '<rootDir>/$1', | |
}, | |
// 用于测试的测试环境。testEnvironment: 'jsdom', |
Mock:
- 原理文档:
https://segmentfault.com/a/11…
https://medium.com/@KumaLi198…
全局模块 mock
// 在配置中 rootDir 下创立 __mocks__ 文件 | |
rootDir: path.join(__dirname, 'src'), |
- 例子 Mock umi 的多语言模块
export const useIntl = () => { | |
return {formatMessage: (params: { id: string}) => params.id, | |
}; | |
}; | |
export const setLocale = (str: string) => str; | |
export const FormattedMessage = ({id}: {id: string}) => {return id;}; |
在以后 mock 某模块
jest.mock('src/utils/ad-plan/creative', () => ({getValidatedFormResult: () => ({ | |
errorFormIdxs: 0, | |
errorFormsInfo: [], | |
successFormsValuesArr: [],}), | |
})); |
在以后 mock 某模块 jest.spyOn,动静设置返回
let validateRes = {errorFormIdxs: [] as number[], | |
errorFormsInfo: [] as ValidateErrorEntity[], | |
successFormsValuesArr: [] as ICreativeFormValues[], | |
}; | |
jest | |
.spyOn(utils, 'getValidatedFormResult') | |
.mockImplementation(() => Promise.resolve(validateRes)); |
Mock callback
模仿函数 · Jest | |
const mockFn = jest.fn(); | |
// 断言 mockFn 被调用 | |
expect(mockFn).toBeCalled(); | |
// 断言 mockFn 被调用了一次 | |
expect(mockFn).toBeCalledTimes(1); | |
// 断言 mockFn 调用次数中某一次含有传入的参数为 1, 2, 3 | |
expect(mockFn).toHaveBeenCalledWith(1, 2, 3); |
测试样例
纯函数测试
- 纯函数是指不依赖于 且 不扭转 它作用域之外的变量状态的函数。
纯函数测试,做好入参和断言即可 | |
import {getObjectFromArray, getParamsString, replaceTime} from './index'; | |
describe('utils', () => {test('getObjectFromArray', () => { | |
expect( | |
getObjectFromArray([ | |
{ | |
label: '1', | |
value: '1', | |
}, | |
{ | |
label: '2', | |
value: '2', | |
}, | |
]), | |
).toEqual({ | |
'1': { | |
label: '1', | |
value: '1', | |
}, | |
'2': { | |
label: '2', | |
value: '2', | |
}, | |
}); | |
expect( | |
getObjectFromArray( | |
[ | |
{ | |
label: 'l1', | |
value: 'v1', | |
}, | |
{ | |
label: 'l2', | |
value: 'v2', | |
}, | |
], | |
'label', | |
), | |
).toEqual({ | |
l1: { | |
label: 'l1', | |
value: 'v1', | |
}, | |
l2: { | |
label: 'l2', | |
value: 'v2', | |
}, | |
}); | |
}); | |
test('getParamsString', () => { | |
const data = { | |
test1: '1', | |
test2: 2, | |
test3: { | |
a: 1, | |
b: 2, | |
}, | |
test4: [1, 2, 3, 4, 5], | |
}; | |
expect(getParamsString(data)).toEqual({ | |
test1: '1', | |
test2: 2, | |
test3: JSON.stringify(data.test3), | |
test4: JSON.stringify(data.test4), | |
}); | |
}); | |
test('replaceTime', () => {expect(replaceTime('2021-15-00 Etc/GMT+1')).toBe('2021-15-00 UTC-1'); | |
expect(replaceTime('2021-15-00 Etc/GMT-1')).toBe('2021-15-00 UTC+1'); | |
expect(replaceTime('')).toBe('-'); | |
}); | |
}); |
hooks 测试
- 外围 Api renderHook、act
- Demo – 多语言的切换
import {useState, useEffect, useCallback} from 'react'; | |
import Cookies from 'js-cookie'; | |
import {setLocale} from 'umi'; | |
import { | |
LANG_MAP, | |
UMI_LANG_MAP, | |
II18nLang, | |
defaultLocale, | |
defaultUmiLang, | |
IUmiI18nLang, | |
} from '@/utils/i18n'; | |
const initLang = | |
LANG_MAP[Cookies.get('lang_type') as II18nLang] || defaultUmiLang; | |
setLocale(initLang, false); // 先更新下多语言,防止 localStorage 存储了一个不合乎预期的兜底 | |
export function useLanguage() {const [curLanguage, setCurLanguage] = useState<IUmiI18nLang>(initLang); | |
const setLanguage = useCallback((lang: II18nLang) => {const nextCurLang = LANG_MAP[lang] || defaultUmiLang; | |
setCurLanguage(nextCurLang); | |
setLocale(nextCurLang, false); | |
Cookies.set('lang_type', UMI_LANG_MAP[nextCurLang] || defaultLocale); | |
}, []); | |
useEffect(() => {window.setCurLanguage = setLanguage;}, []); | |
return { | |
curLanguage, | |
setCurLanguage: setLanguage, | |
}; | |
} |
- 测试用例
import {renderHook, act} from '@testing-library/react-hooks'; | |
import {LANG_MAP} from '@/utils/i18n'; | |
import {useLanguage} from './useLanguage'; | |
describe('useLanguage test', () => {test('useLanguage set language', () => {const { result} = renderHook(() => useLanguage()); | |
expect(result.current.curLanguage).toBe(LANG_MAP.en); | |
act(() => result.current.setCurLanguage('xx' as any)); | |
expect(result.current.curLanguage).toBe(LANG_MAP.en); | |
act(() => result.current.setCurLanguage('ja')); | |
expect(result.current.curLanguage).toBe(LANG_MAP.ja); | |
}); | |
}); |
组件测试
- 查找节点
// About Queries | Testing Library | |
<div data-testid="jest-cascade-panel">......</div> | |
screen.getByTestId('jest-cascade-panel') |
-
fireEvent 事件触发
Firing Events | Testing Library fireEvent[eventName](node: HTMLElement, eventProperties: Object) fireEvent.click(screen.getByTestId('jest-cascade-panel')); -
异步渲染期待
Async Methods | Testing Library function waitFor<T>(callback: () => T | Promise<T>, options?: { container?: HTMLElement timeout?: number interval?: number onTimeout?: (error: Error) => Error mutationObserverOptions?: MutationObserverInit } ): Promise<T> waitFor(() => screen.getByTestId('jest-cascade-panel'));
context(官网 demo copy)
import React from 'react' | |
import {render, screen} from '@testing-library/react' | |
import '@testing-library/jest-dom/extend-expect' | |
import {NameContext, NameProvider, NameConsumer} from '../react-context' | |
/** | |
* Test default values by rendering a context consumer without a | |
* matching provider | |
*/ | |
test('NameConsumer shows default value', () => {render(<NameConsumer />) | |
expect(screen.getByText(/^My Name Is:/)).toHaveTextContent('My Name Is: Unknown') | |
}) | |
/** | |
* A custom render to setup providers. Extends regular | |
* render options with `providerProps` to allow injecting | |
* different scenarios to test with. | |
* | |
* @see https://testing-library.com/docs/react-testing-library/setup#custom-render | |
*/ | |
const customRender = (ui, { providerProps, ...renderOptions}) => { | |
return render(<NameContext.Provider {...providerProps}>{ui}</NameContext.Provider>, | |
renderOptions | |
) | |
} | |
test('NameConsumer shows value from provider', () => { | |
const providerProps = {value: 'C3PO',} | |
customRender(<NameConsumer />, { providerProps}) | |
expect(screen.getByText(/^My Name Is:/)).toHaveTextContent('My Name Is: C3P0') | |
}) | |
/** | |
* To test a component that provides a context value, render a matching | |
* consumer as the child | |
*/ | |
test('NameProvider composes full name from first, last', () => { | |
const providerProps = { | |
first: 'Boba', | |
last: 'Fett', | |
} | |
customRender( | |
<NameContext.Consumer> | |
{(value) => <span>Received: {value}</span>} | |
</NameContext.Consumer>, | |
{providerProps} | |
) | |
expect(screen.getByText(/^Received:/).textContent).toBe('Received: Boba Fett') | |
}) | |
/** | |
* A tree containing both a providers and consumer can be rendered normally | |
*/ | |
test('NameProvider/Consumer shows name of character', () => {const wrapper = ({ children}) => ( | |
<NameProvider first="Leia" last="Organa"> | |
{children} | |
</NameProvider> | |
) | |
render(<NameConsumer />, { wrapper}) | |
expect(screen.getByText(/^My Name Is:/).textContent).toBe('My Name Is: Leia Organa') | |
}) |
快照测试
- 当你想要确保你的 UI 不会有意外的扭转,快照测试是十分有用的工具。
- 对固定配置项 - 避免意外批改
测试原理
- 快照测试第一次运行的时候会将 React 组件在不同状况下的渲染后果(挂载前)保留一份快照文件。前面每次再运行快照测试时,都会和第一次的比拟,应用 jest – u 命令从新生成快照文件。
jest.config 配置
- snapshotResolver
- 配置选项容许您自定义 Jest 在磁盘上存储快照文件的地位。
- 标准:resolveTestPath(resolveSnapshotPath(testPathForConsistencyCheck)) = testPathForConsistencyCheck
// jest.config.js 配置 | |
snapshotResolver: path.join(__dirname, 'snapshotResolver.js'), | |
// snapshotResolver.js | |
module.exports = { | |
// resolves from test to snapshot path | |
resolveSnapshotPath: (testPath, snapshotExtension) => {console.log(`resolveSnapshotPath => ${testPath}、${snapshotExtension}`); | |
// const path = testPath.replace(// /\.test\.([tj]sx?)/, | |
// `$1.${snapshotExtension}`, | |
// ); | |
const path = `${testPath}${snapshotExtension}`; | |
console.log(path); | |
return path; | |
}, | |
// resolves from snapshot to test path | |
resolveTestPath: (snapshotFilePath, snapshotExtension) => {console.log(`resolveTestPath => ${snapshotFilePath}、${snapshotExtension}`); | |
return snapshotFilePath.replace(snapshotExtension, ''); | |
}, | |
// Example test path, used for preflight consistency check of the implementation above | |
testPathForConsistencyCheck: 'some/example.test.js', | |
}; |
- snapshotSerializers(序列化快照后果 )
- A list of paths to snapshot serializer modules Jest should use for snapshot testing.
-
Jest has default serializers for built-in JavaScript types, HTML elements (Jest 20.0.0+), ImmutableJS (Jest 20.0.0+) and for React elements. See snapshot test tutorial for more information.
- 集体感觉对组件化测试的意义不大
- 能够用做配置等测试
// jest.config.js 配置 | |
snapshotSerializers: [path.join(__dirname, 'snapshotSerializers.js')], | |
// snapshotSerializers.js | |
module.exports = {serialize(val, config, indentation, depth, refs, printer) {console.log(val); | |
return 'Pretty test:' + printer(val); | |
}, | |
test(val) {console.log(val); | |
return val; | |
}, | |
}; |
-
快照测试 API
-
toMatchSnapshot 生成快照文件
// Jest Snapshot v1, https://goo.gl/fbAQLP exports[` 1`] = ` <body> <div> <button type="button" > test </button> </div> </body> `; -
toMatchInlineSnapshot 在测试文件行内生成快照
expect(dom.baseElement).toMatchInlineSnapshot(`<body> <div> <button type="button" > test </button> </div> </body>`);
-
-
快照测试 DEMO
-
LoadingButton 组件,点击后按钮展现一个小 loading
import React, {useCallback} from 'react'; import {Button, ButtonType} from '@byte-design/ui'; import {useStateIfMounted} from '@/hooks'; interface IProps { children: React.ReactNode; onClick?: () => Promise<void>; type?: ButtonType; loadingColor?: string; className?: string; disabled?: boolean; disabledHideContent?: boolean; } export const LoadingButton = (props: IProps) => { const { children, onClick, type = 'primary', className = '', disabled = false, disabledHideContent = false, } = props; const {state: loading, setState: setLoading} = useStateIfMounted(false); const handleClick = useCallback(() => {if (loading) return; if (onClick) {setLoading(true); onClick().finally(() => {setLoading(false); }); } }, [loading, onClick, setLoading]); return ( <Button type={type} className={className} onClick={handleClick} loading={loading} disabled={loading || disabled} > {!(loading && disabledHideContent) && children} </Button> ); };
-
- 测试用例
- react-test-renderer 便于解决 event
import React from 'react'; | |
// import {render, screen, fireEvent} from '@testing-library/react'; | |
import renderer from 'react-test-renderer'; | |
import {LoadingButton} from './index'; | |
describe('LoadingButton', () => {test('LoadingButton snapshot', () => {const promise = Promise.resolve(); | |
const fn = jest.fn(() => promise); | |
const dom = renderer.create(<LoadingButton onClick={fn}>test</LoadingButton>, | |
); | |
let snapshot: any = dom.toJSON(); | |
expect(snapshot).toMatchSnapshot(); | |
snapshot.props.onClick(); | |
snapshot = dom.toJSON(); | |
expect(snapshot).toMatchSnapshot(); | |
await act(() => promise); | |
}); | |
}); |
-
@testing-library/react
import React from 'react'; // import renderer from 'react-test-renderer'; import {render, fireEvent} from '@testing-library/react'; import {LoadingButton} from './index'; describe('LoadingButton', () => { const text = 'text'; test('snapshot', () => {const promise = Promise.resolve(); const fn = jest.fn(() => promise); const {asFragment, getByText} = render(<LoadingButton onClick={fn}>{text}</LoadingButton>, ); const dom = asFragment(); expect(dom).toMatchSnapshot(); fireEvent.click(getByText(text)); expect(asFragment()).toMatchSnapshot(); await act(() => promise); }); }); - asFragment:
- render 返回
container: HTMLDivElement
baseElement: HTMLBodyElement {},
debug: [Function: debug],
unmount: [Function: unmount],
rerender: [Function: rerender],
asFragment: [Function: asFragment],
queryAllByLabelText: [Function: bound],
queryByLabelText: [Function: bound],
getAllByLabelText: [Function: bound],
getByLabelText: [Function: bound],
findAllByLabelText: [Function: bound],
findByLabelText: [Function: bound],
queryByPlaceholderText: [Function: bound],
queryAllByPlaceholderText: [Function: bound],
getByPlaceholderText: [Function: bound],
getAllByPlaceholderText: [Function: bound],
findAllByPlaceholderText: [Function: bound],
findByPlaceholderText: [Function: bound],
queryByText: [Function: bound],
queryAllByText: [Function: bound],
getByText: [Function: bound],
getAllByText: [Function: bound],
findAllByText: [Function: bound],
findByText: [Function: bound],
queryByDisplayValue: [Function: bound],
queryAllByDisplayValue: [Function: bound],
getByDisplayValue: [Function: bound],
getAllByDisplayValue: [Function: bound],
findAllByDisplayValue: [Function: bound],
findByDisplayValue: [Function: bound],
queryByAltText: [Function: bound],
queryAllByAltText: [Function: bound],
getByAltText: [Function: bound],
getAllByAltText: [Function: bound],
findAllByAltText: [Function: bound],
findByAltText: [Function: bound],
queryByTitle: [Function: bound],
queryAllByTitle: [Function: bound],
getByTitle: [Function: bound],
getAllByTitle: [Function: bound],
findAllByTitle: [Function: bound],
findByTitle: [Function: bound],
queryByRole: [Function: bound],
queryAllByRole: [Function: bound],
getAllByRole: [Function: bound],
getByRole: [Function: bound],
findAllByRole: [Function: bound],
findByRole: [Function: bound],
queryByTestId: [Function: bound],
queryAllByTestId: [Function: bound],
getByTestId: [Function: bound],
getAllByTestId: [Function: bound],
findAllByTestId: [Function: bound],
findByTestId: [Function: bound]
-
生成快照
exports[`LoadingButton LoadingButton snapshot 1`] = ` <button className="byted-btn byted-btn-size-md byted-btn-type-primary byted-btn-shape-angle byted-can-input-grouped" disabled={false} onClick={[Function]} style={ Object {"display": undefined,} } type="button" > test </button> `; exports[`LoadingButton LoadingButton snapshot 2`] = ` <button className="byted-btn byted-btn-disabled byted-btn-size-md byted-btn-type-primary byted-btn-shape-angle byted-can-input-grouped byted-btn-loading" disabled={true} onClick={[Function]} style={ Object {"display": undefined,} } type="button" > <span className="byted-btn-loading-icon" > <svg display="block" height={14} preserveAspectRatio="xMidYMid" style={ Object { "background": "0 0", "margin": "auto", } } viewBox="0 0 100 100" width={14} > <rect fill="#333" height={24} rx={4} ry={5.76} width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(45 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(90 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(135 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(180 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(225 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(270 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(315 50 50)" width={8} x={46} y={6} /> </svg> </span> test </button> `;
常见问题:
https://kentcdodds.com/blog/f…
测试覆盖率计算
- https://juejin.cn/post/684490…
正文完
发表至: javascript
2022-03-25