Material UI Selectに対するStorybookのインタラクションテストを実施する

Cover Image for Material UI Selectに対するStorybookのインタラクションテストを実施する
monotalk
monotalk

Material UIのSelectに対するStorybookのインタラクションテストを書こうとしたが、うまく動かなくて四苦八苦した。
動かすまでに調べたことを記載する。


Material UIのSelectについて

Material UIのSelectは、HTMLネイティブなSelectタグではなく、divタグなどを使って自作されている。
以下の記事が参考になった。
material-ui の Select の挙動まとめ | blog.ojisan.io

Storybookのインタラクションテストは、RTLを使って作ったのだが、Material UIのSelectの作りが影響して、selectOptions で要素の選択ができなかった。


RTLで、Material UI Select のテストケースを書く

Web検索すると、以下のページがヒットした。試したがうまくMenuItemの内容を選択状態にできなかった。
今思うと waitFor をうまく使えてなかった気がする。Selectをclick後のgetTextでしばらく待つとうまく動きそうではある。


どのように動かしたか?

以下が、Selectに対しての操作を行うコード。

  const select = canvas.getByTestId("subjects");
  await userEvent.click(select);
  await userEvent.keyboard("[ArrowDown][Enter]")

SelectのpulldownのエレメントにtestIdを付与して、keyコードを送信して、対象の要素を選択している。
SelectタグはtestId付与のため以下のようになる。

          <Select
            id="subject"
            type="text"
            name="subject"
            label="件名"
            variant="standard"
            SelectDisplayProps={{ "data-testid": "subjects" } as SubjectSelectDisplayProps }
            value={values.subject}
            onChange={handleChange}
            error={touched.subject && Boolean(errors.subject)}
          >
  • outerContactForm.stories.tsx
import { Provider } from 'react-redux';
import OuterContactForm from './outerContactForm';
import * as React from "react";
import store from "../../store";
import { userEvent, within, waitFor } from '@storybook/testing-library';
import { expect } from '@storybook/jest';

export default {
    title: 'ContactForm',
    component: OuterContactForm,
    decorators: [
        (Story) => (
          <Provider store={store as any}>
            <Story />
          </Provider>
        ),
      ],    
  };

const Template = (args) => <OuterContactForm {...args} />;
export const ReactDefaultForm = Template.bind({});

ReactDefaultForm.parameters = {};

export const RecaptchaCheckRequired = Template.bind({});
RecaptchaCheckRequired.parameters = { ...ReactDefaultForm.parameters };
RecaptchaCheckRequired.play = async ({ canvasElement }) => {
  const canvas = within(canvasElement);
  await userEvent.type(canvas.getByPlaceholderText('山田 太郎'), '山田の太郎');
  await userEvent.type(canvas.getByPlaceholderText('monotalk@example.com',),'test@example.com');
  await userEvent.type(canvas.getByPlaceholderText('お問い合わせ内容'), 'お問合せ');

  const select = canvas.getByTestId("subjects");
  await userEvent.click(select);
  await userEvent.keyboard("[ArrowDown][Enter]")

  await userEvent.click(canvas.getByTestId('send'));
  // 👇 Assert DOM structure
  await waitFor(() => expect(canvas.getByText('ロボットではないことへの同意は必須です。')).toBeInTheDocument())
};
  • contactForm.tsx
declare function require(path: string): any;
const siteConfig: any = require("../../data/siteConfig");
import * as React from "react";
import { connect } from "react-redux";
import FormHelperText from "@material-ui/core/FormHelperText";
import { Select, Typography, TextField, MenuItem } from "@material-ui/core";
import Button from "@material-ui/core/Button";
import FormControl from "@material-ui/core/FormControl";
import InputLabel from "@material-ui/core/InputLabel";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import { withFormik } from "formik";
import { FormikProps } from "formik";
import ReCAPTCHA from "react-google-recaptcha";
import * as Yup from "yup";
import {submitContactForm} from "../../actions";
interface SubjectSelectDisplayProps extends React.HTMLAttributes<HTMLDivElement> {
  "data-testid"?: string;
}
const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      display: "flex",
      flexWrap: "wrap",
    },
    textField: {
      marginLeft: theme.spacing(1),
      marginRight: theme.spacing(1),
      width: "25ch",
    },
    formControl: {
      margin: theme.spacing(1),
      minWidth: 120,
    },
  })
);
const ContactSchema = Yup.object().shape({
  fullName: Yup.string()
    .max(50, "氏名が長すぎます。")
    .required("氏名は必須です。"),
  email: Yup.string()
    .email("Emailの形式が不正です。")
    .required("Emailは必須です。"),
  subject: Yup.string().required("件名は必須です。"),
  bodyText: Yup.string()
    .max(3000, "問い合わせ内容が長すぎます。")
    .required("問い合わせ内容は必須です。"),
  token: Yup.string().required("ロボットではないことへの同意は必須です。"),
});

interface FormValues {
  email: string;
  fullName: string;
  subject: string;
  bodyText: string;
  token: string;
}

type Props = {
  submitContactForm: any;
};

// @ts-ignore
const InnerContactForm = (props: FormikProps<FormValues>) => {
  const { handleSubmit, values, touched, errors, handleChange } = props;
  const subjects = [
    {
      value: "",
      label: "None",
    },
    {
      value: "entryReqest",
      label: "記事リクエスト",
    },
    {
      value: "question",
      label: "質問",
    },
    {
      value: "etc",
      label: "その他",
    },
  ];
  const recaptchaRef = React.useRef<ReCAPTCHA>(null);
  async function executeRecaptcha() {
    // @ts-ignore
    const recaptchaValue = recaptchaRef.current.getValue();
    // const token = await recaptchaRef!.current!.executeAsync();
    // @ts-ignore
    values.token = recaptchaValue;
  }
  function onChange(value: any) {
    // @ts-ignore
    values.token = value;
  }
  const classes = useStyles();
  return (
    <main className="mainContainer mt5rem">
      <form className={classes.root} autoComplete="off" onSubmit={handleSubmit}>
        <Typography variant="h4" align="center" component="h2" gutterBottom>
          Contact
        </Typography>
        <TextField
          id="fullName"
          name="fullName"
          label="お名前"
          style={{ margin: 8 }}
          placeholder="山田 太郎"
          fullWidth
          margin="normal"
          data-testid="fullName"
          InputLabelProps={{
            shrink: true,
          }}
          variant="filled"
          value={values.fullName}
          onChange={handleChange}
          error={touched.fullName && Boolean(errors.fullName)}
          helperText={touched.fullName && errors.fullName}
        />
        <TextField
          name="email"
          type="email"
          label="Email"
          style={{ margin: 8 }}
          placeholder="monotalk@example.com"
          fullWidth
          margin="normal"
          data-testid="email"
          InputLabelProps={{
            shrink: true,
          }}
          variant="filled"
          value={values.email}
          onChange={handleChange}
          error={touched.email && Boolean(errors.email)}
          helperText={touched.email && errors.email}
        />
        <FormControl className={classes.formControl}>
          <InputLabel htmlFor="subject">件名</InputLabel>
          <Select
            id="subject"
            type="text"
            name="subject"
            label="件名"
            variant="standard"
            SelectDisplayProps={{ "data-testid": "subjects" } as SubjectSelectDisplayProps }
            value={values.subject}
            onChange={handleChange}
            error={touched.subject && Boolean(errors.subject)}
          >
            {subjects.map((option, index) => (
              <MenuItem key={index} value={option.value}>
                {option.label}
              </MenuItem>
            ))}
          </Select>
          <FormHelperText error>
            {touched.subject && errors.subject}
          </FormHelperText>
        </FormControl>
        <TextField
          id="bodyText"
          name="bodyText"
          label="お問い合わせ内容"
          data-testid="bodyText"
          style={{ margin: 8 }}
          placeholder="お問い合わせ内容"
          fullWidth
          margin="normal"
          multiline
          InputLabelProps={{
            shrink: true,
          }}
          variant="filled"
          value={values.bodyText}
          onChange={handleChange}
          error={touched.bodyText && Boolean(errors.bodyText)}
          helperText={touched.bodyText && errors.bodyText}
        />
        <FormControl className={classes.formControl}>
          <ReCAPTCHA
            ref={recaptchaRef}
            sitekey={siteConfig.reCAPTCHAv2Sitekey}
            onChange={onChange}
          />
          <FormHelperText error>{touched.token && errors.token}</FormHelperText>
        </FormControl>
        <Button
          data-testid="send"
          color="primary"
          onClick={executeRecaptcha}
          variant="contained"
          fullWidth
          type="submit"
        >
          送信
        </Button>
      </form>
    </main>
  );
};
const ContactForm = withFormik<Props, FormValues>({
  mapPropsToValues: () => {
    return {
      fullName: "",
      email: "",
      subject: "",
      bodyText: "",
      token: "",
    };
  },
  validationSchema: ContactSchema,
  handleSubmit: (values: FormValues, { props, setSubmitting }) => {
    //@ts-ignore
    const { submitContactForm } = props;
    const payload = {
      fullName: values.fullName,
      email: values.email,
      subject: values.subject,
      bodyText: values.bodyText,
      token: values.token,
    };
    setSubmitting(true);
    // react redux でリクエスト送信
    submitContactForm(payload);
  },
  //@ts-ignore 2345
})(InnerContactForm);
function mapStateToProps(state: any) {
  return {
    status: state.contact.status,
    message: state.contact.message,
  };
}
export default connect(mapStateToProps, { submitContactForm })(ContactForm);

  • outerContactForm.tsx
import * as React from "react";
import { connect } from "react-redux";
import ConnectedContactForm from "./contactForm";
import { SUBMIT_CONTACT_FAILURE, SUBMIT_CONTACT_SUCCESS } from "../../actions";
import { Typography } from "@material-ui/core";
import CardActions from "@material-ui/core/CardActions";
import CardContent from "@material-ui/core/CardContent";

const Form = (props: any) => {
  //@ts-ignore
  if (
    props.contact.status == SUBMIT_CONTACT_SUCCESS ||
    props.contact.status == SUBMIT_CONTACT_FAILURE
  ) {
    //@ts-ignore
    const message = props.contact.message;
    const detailMessage = message.details.join("\n");
    return (
      <main className="mainContainer mt5rem">
        <CardActions>
          <Typography variant="h2" component="h1">
            {message.caption}
          </Typography>
        </CardActions>
        <CardContent>
          <Typography
            variant="body1"
            className="text"
            style={{ whiteSpace: "pre-line" }}
          >
            {detailMessage}
          </Typography>
        </CardContent>
      </main>
    );
  } else {
    return <ConnectedContactForm></ConnectedContactForm>;
  }
};

const mapStateToProps = (state: any) => ({
  contact: state.contact,
});

export default connect(mapStateToProps)(Form);

testIdを付与せず、うまく処理できないかもう少し試してみたい。


その他 Storybookの実装で学んだこと

RTLのHTML要素取得API

Interaction tests を最初に確認して、

// 👇 Simulate interactions with the component
  await userEvent.type(canvas.getByTestId('email'), 'email@provider.com');

というサンプルの記載があり?となり、調べてRTLの存在を知った。
About Queries | Testing Library

React + Redux を使ったプロジェクトで、Storybook を使う

React + Redux Toolkit + Storybook のプロジェクトを作成する | いつかの熊右衛門 に記載があるが、Providerタグでテススト対応コンポーネントを囲む必要がある。

   <Provider store={store}>
        <Story />
   </Provider>

また、Storybookを導入したプロジェクトでは、storeがエントリーポイントに直書きされていたので、外部化する必要があった。