はじめに
Reactコンポーネントのテスト作成する時のよくあるパターンをまとめてみました。
react-selectコンポーネントのテスト
react-select-eventの代わりに
import userEvent from '@testing-library/user-event'
...
const select = (el: Element, text: string, user = userEvent) => user.type(`${text}{enter}`)
...
await select(screen.getByRole('combobox'), 'test')
カスタムレンダー
今回はReduxストアー、Formikとreact-routerそれぞれ選択できるようにしてますが、
プロジェクトのニーズに合わせて、オプションを修正すれば様々なパータンをカバー出来ます。
タイプ
type RenderWithOpts<T> = {
/* Redux設定 (ignoreDispathはストアーのdispatchファンクションを無効化にするオプション) */
withStore?: { initialState: unknown; ignoreDispatch?: boolean } | boolean
/* Formik設定 */
withFormik?: Omit<FormikConfig<T>, 'onSubmit'>
/* react-router設定 */
withRouter?: boolean | Parameters<typeof createMemoryRouter>[1]
}
コアーファンクション
const wrapWith: <T extends object>(
element: ReactElement,
options: RenderWithOpts<T>,
) => {
component: ReactElement
onSubmit?: jest.Mock
router?: ReturnType<typeof createMemoryRouter>
} & Omit<ReturnType<typeof includeStore>, 'store' | 'dispatchSpy'> &
Partial<Pick<ReturnType<typeof includeStore>, 'store' | 'dispatchSpy'>> = (
element,
{ withStore, withFormik: formikOptions, withRouter },
) => {
const onSubmit = jest.fn()
if (formikOptions) {
element = (
<Formik {...formikOptions} onSubmit={onSubmit}>
{element}
</Formik>
)
}
let router
if (withRouter) {
router = createMemoryRouter(
[
{
path: '*',
element,
},
],
typeof withRouter === 'object' ? withRouter : undefined,
)
element = <RouterProvider router={router} />
} else {
router = undefined
}
const { component, ...store } = withStore
? includeStore(element, withStore)
: { component: element }
return {
component,
onSubmit,
router,
...store,
}
}
Redux設定
configureAppStore
は自分のストアー作成ファンクションになります。
OnUnmount
はただテスト後、コンポーネントが処分されたら、ファンクションをコールするコンポーネントです。(なくても問題ないかと思います)
const includeStore = (
el: ReactElement,
withStore: RenderWithOpts<unknown>['withStore'],
) => {
const [store, unsubscribe] = configureAppStore({
useLogger: false,
initialState: typeof withStore === 'object' ? withStore.initialState : {},
})
const dispatchSpy = jest.spyOn(store, 'dispatch')
if (typeof withStore === 'object' && withStore.ignoreDispatch) {
dispatchSpy.mockImplementation(() => {})
}
return {
store,
dispatchSpy,
component: (
<Provider store={store}>
{el}
<OnUnmount fn={unsubscribe} />
</Provider>
),
}
}
レンダーファンクション
testing-library/rectのrenderの上にwrapWithを使用すれば、カスタムレンダーは完成です!
const customRender: <T extends object>(
ui: ReactElement,
renderOptions?: Omit<RenderOptions, 'queries'> & RenderWithOpts<object>,
) => ReturnType<typeof render> &
Omit<ReturnType<typeof wrapWith<T>>, 'component'> = (
ui,
renderOptions = {},
) => {
const { component, ...rest } = wrapWith(ui, renderOptions)
return {
...render(component, {
queries: allQueries,
...renderOptions,
}),
...rest,
}
}
プラスアルファ(フックレンダー)
先程のrenderのrenderHook版:
const renderHookWith: <T extends object, Result, Props>(
hook: Parameters<typeof renderHook<Result, Props>>[0],
renderOptions: Partial<Parameters<typeof renderHook<Result, Props>>[1]> &
RenderWithOpts<T>,
) => ReturnType<typeof renderHook<Result, Props>> &
Omit<ReturnType<typeof wrapWith<T>>, 'component'> = (
hook,
renderOptions = {},
) => {
let result
return {
...renderHook(hook, {
...renderOptions,
wrapper: ({ children }) => {
result = wrapWith(children, renderOptions)
return result.component
},
}),
...omit(result, 'component'),
}
}
ファイルインプットテスト
const fileInput = async (el: HTMLElement, str: string, user = userEvent, fileName = 'values.json', fileType = 'application/JSON') => {
const blob = new Blob([str])
const file = new File([blob], fileName, {
type: fileType,
})
File.prototype.text = jest.fn().mockResolvedValueOnce(str)
await user.upload(el, file)
}
...
await fileInput(screen.getByTestId('testid'), 'testValue')
XHRモック
万が一XHRを使用してるコードをテストしたい時に:
class XHRMock {
listeners: Record<string, ((_: Record<string, unknown>) => void)[]> = {}
headers: Record<string, string> = {}
open = jest.fn()
send = jest.fn()
setRequestHeader = (key: string, val: string) => {
this.headers[key] = val
}
getAllResponseHeaders = () =>
Object.entries(this.headers)
.map(([key, val]) => `${key}Return: ${JSON.stringify(val)}`)
.join('\r\n')
readyState = 4
status = 200
response = ''
addEventListener = (
type: string,
handler: (_: Record<string, unknown>) => void,
) => {
this.listeners[type] = [...(this.listeners[type] || []), handler]
}
upload = {
addEventListener: this.addEventListener,
}
dispatchEvent = (ev: { type: string } & Record<string, unknown>) => {
(this.listeners[ev.type] || []).forEach(handler => handler(ev))
}
}
export const createXHRMock = () => new XHRMock()
...
const xhrMock = createXHRMock()
jest
.spyOn(window, 'XMLHttpRequest')
.mockImplementation(() => xhrMock as unknown as XMLHttpRequest)
...
xhrMock.dispatchEvent({ type: 'progress', total: 120, loaded: 60 })
...
xhrMock.dispatchEvent({ type: 'loadend' })
BroadcastChannelモック
export const createBroadcastChannelMockClass = () =>
class BroadcastChannelMock {
closed = false
static instances: { [name: string]: BroadcastChannelMock } = {}
constructor(name: string) {
BroadcastChannelMock.instances[name] = this
}
public close() {
this.closed = true
}
onmessage(_: { data: unknown }) {}
public postMessage(data: unknown) {
this.onmessage({ data })
}
}
type TeardownFn = () => void | Promise<void | unknown>
export const installBroadcastChannelMock = ({
additionalSetup,
}: {
additionalSetup?: () => Promise<TeardownFn> | TeardownFn | undefined
} = {}) => {
const org = global.BroadcastChannel
const mock = createBroadcastChannelMockClass()
let additionalTeardown: TeardownFn | undefined
beforeAll(async () => {
global.BroadcastChannel = mock as unknown as typeof org
const teardown = additionalSetup?.()
if (teardown instanceof Promise) {
additionalTeardown = await teardown
} else {
additionalTeardown = teardown
}
})
afterAll(() => {
global.BroadcastChannel = org
return additionalTeardown?.()
})
return mock
}
最後に
こちらのコードが参考になれたら幸いです。
皆さんの方はどうでしょうか?
便利そうなテストコードがあれば是非共有お願いします。