React Select を使ったコンポーネントのテストをする
react-select
という複数選択やoptionにサムネイルを追加したりできる高機能なselect
を提供するライブラリがある。
OptionSelector.tsx:
import { useCallback } from 'react'; import Select from 'react-select'; export type Option = { value: string; label: string; }; export const options: Option[] = [ { value: '1', label: 'Option 1' }, { value: '2', label: 'Option 2' }, { value: '3', label: 'Option 3' }, ]; type Props = { value: Option | undefined; onChange: (option: Option | undefined) => void; }; export default function OptionSelector({ value, onChange } : Props) { const onChangeSelect = useCallback((option: Option | null) => { onChange(option || undefined); }, [onChange]); return ( <div data-testid="OptionSelector"> <Select value={value} options={options} onChange={onChangeSelect} /> </div> ); };
使う分にはいいが、このライブラリを使っているコンポーネントのテストをする時は少々やっかいで、たとえば、特定のオプションを選択してから何かをするようなテストを書く場合などである。
__tests__/OptionSelector.test.tsx:
import { render, screen } from '@testing-library/react'; import OptionSelector, { Option, options } from '../OptionSelector'; describe('<OptionSelector />', () => { it('calls onChange() prop with selected value', async () => { let option: Option | undefined = undefined; const onChange = jest.fn((newOption: Option | undefined) => { option = newOption; })); render(<OptionSelector value={option} onChange={onChange} />); // なんらかの値を選択したい expect(onChange).toHaveBeenCalledTimes(1); expect(option).toBe(options[1]); }); });
このような場合は、モックに置き換えるのが一般的だが、react-select-event
というライブラリを使うとモックに置き換えることなく任意の値を選択することができる。
import React from 'react' import Select from 'react-select' import {render} from '@testing-library/react' import selectEvent from 'react-select-event' const {getByTestId, getByLabelText} = render( <form data-testid="form"> <label htmlFor="food">Food</label> <Select options={OPTIONS} name="food" inputId="food" isMulti /> </form>, ) expect(getByTestId('form')).toHaveFormValues({food: ''}) // empty select // select two values... await selectEvent.select(getByLabelText('Food'), ['Strawberry', 'Mango']) expect(getByTestId('form')).toHaveFormValues({food: ['strawberry', 'mango']}) // ...and add a third one await selectEvent.select(getByLabelText('Food'), 'Chocolate') expect(getByTestId('form')).toHaveFormValues({ food: ['strawberry', 'mango', 'chocolate'], })
上記のサンプルコードによると selectEvent.select(<react-select内部のinput要素>, <選択するオプション>)
というAPIを使用することで選択できる模様。
ここで、react-select内部のinput要素
は、react-select
の inputId
を指定すると任意のIDを付与できるので、label
要素のfor
属性と同じ値を指定することで紐づけられる。そのため、getByLabelText(<labelに指定している文字列>)
で取得できるようになる。
これにより、以下のようにテストが書けるようになる。
OptionSelector.tsx:
export default function OptionSelector({ value, onChange } : Props) { const onChangeSelect = useCallback<OnChangeSelect>((option) => { onChange(option || undefined); }, [onChange]); return ( <div data-testid="OptionSelector"> <label htmlFor="option_selector">option:</label> <Select value={value} options={options} onChange={onChangeSelect} inputId="option_selector" /> </div> ); };
__tests__/OptionSelector.test.tsx:
import { render, screen } from '@testing-library/react'; import selectEvent from 'react-select-event'; import OptionSelector, { Option, options } from '../OptionSelector'; describe('<OptionSelector />', () => { it('calls onChange() prop with selected value', async () => { let option: Option | undefined = undefined; const onChange = jest.fn((newOption: Option | undefined) => { option = newOption; })); render(<OptionSelector value={option} onChange={onChange} />); await selectEvent.select(screen.getByLabelText('option:'), [options[1].label]); expect(onChange).toHaveBeenCalledTimes(1); expect(option).toBe(options[1]); }); });
ここで、以下にあげるように、いくつかの問題がある。
- IDを指定しているのでこのコンポーネントを一つの画面で複数使用している場合うまくいかない
- label要素がない場合は自分でコンポーネント内部のIDをもとに
document.getElementById()
などで取得する必要がある
まず 1.
の問題についてだが、ジェネレーターなどを使ってコンポーネント生成毎に異なるIDを生成することで解決できる。
OptionSelector.tsx:
function* inputIdGenerator(): Generator<string, string, string> { let id = 1; while (true) { yield `option_selector_${id++}`; } } const inputIdIterator = inputIdGenerator(); export default function OptionSelector({ value, onChange } : Props) { const onChangeSelect = useCallback<OnChangeSelect>((option) => { onChange(option || undefined); }, [onChange]); const inputId = useMemo(() => inputIdIterator.next().value, []); return ( <div data-testid="OptionSelector"> <label htmlFor={inputId}>option:</label> <Select value={value} options={options} onChange={onChangeSelect} inputId={inputId} /> </div> ); };
そして次に 2.
の問題についてだが、コンポーネントに data-inputid
属性を追加してここにIDを指定して、テスト時に参照することで解決できる。
OptionSelector.tsx:
export default function OptionSelector({ value, onChange } : Props) { const onChangeSelect = useCallback<OnChangeSelect>((option) => { onChange(option || undefined); }, [onChange]); const inputId = useMemo(() => inputIdIterator.next().value, []); return ( <div data-testid="OptionSelector" data-inputid={inputId}> <label htmlFor={inputId}>option:</label> <Select value={value} options={options} onChange={onChangeSelect} inputId={inputId} /> </div> ); };
__tests__/OptionSelector.test.tsx:
describe('<OptionSelector />', () => { it('calls onChange() prop with selected value', async () => { ... await selectEvent.select( document.getElementById(screen.getByTestId('OptionSelector').dataset.inputid!)!, [options[1].label] ); ... }); });
さらにヘルパー関数を作成しておくとよい。
OptionSelector.tsx:
export function getReactSelectInputElement(el: HTMLElement | undefined | null): HTMLInputElement { if (!el) throw new Error('Element is null'); if (el.dataset.testid !== 'OptionSelector') throw new Error('Element is not OptionSelector'); if (typeof el.dataset.inputid === 'undefined') throw new Error('InputId is empty'); const input = el.querySelector<HTMLInputElement>(`#${el.dataset.inputid}`); if (!input) throw new Error('input element not found'); return input; }
__tests__/OptionSelector.test.tsx:
describe('<OptionSelector />', () => { it('calls onChange() prop with selected value', async () => { ... await selectEvent.select( getReactSelectInputElement(screen.getByTestId('OptionSelector')), [options[1].label] ); ... }); });
以上で、react-select
を使ったコンポーネントもテストできるようになった。また、上記の方針で実装したサンプルコードは sandbox/ts-react-select にある。
環境:
- React:
18.2.0
- react-select:
5.8.0
- react-select-event:
5.5.1