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



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でしばらく待つとうまく動きそうではある。
- React Testing Library + Material UI Select - CodeSandbox
- reactjs - React testing library on change for Material UI Select component - Stack Overflow
どのように動かしたか?
以下が、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
がエントリーポイントに直書きされていたので、外部化する必要があった。