1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Reactコンポーネントのテストを書く時によく使用するコードまとめ

Posted at

はじめに

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
}

最後に

こちらのコードが参考になれたら幸いです。
皆さんの方はどうでしょうか?
便利そうなテストコードがあれば是非共有お願いします。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?