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'],
})

react-select-event | Testing Library

上記のサンプルコードによると selectEvent.select(<react-select内部のinput要素>, <選択するオプション>) というAPIを使用することで選択できる模様。

ここで、react-select内部のinput要素 は、react-selectinputId を指定すると任意の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]);
  });
});

ここで、以下にあげるように、いくつかの問題がある。

  1. IDを指定しているのでこのコンポーネントを一つの画面で複数使用している場合うまくいかない
  2. 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