👻
fast-check × @testing-library/reactでテストが失敗した件
えぐい詰まったのでメモとして残します。
結論
@testing-library/reactのrender関数をラップしてcontainerを渡すようにし、テストではこの関数の返値からクエリをするようにしましょう
const renderFC = (ui: ReactNode, options: RenderOptions = {}) => {
return render(ui, {
container: document.body.appendChild(document.createElement("div")),
...options,
});
};
const result = renderFC(<Sample />)
result.getByRole("button",{name: "hoge"})
何が起こっていたのか
次のコンポーネントのテストを書いていました。
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { FC } from "react";
import { useForm } from "react-hook-form";
import zod from "zod";
const schema = zod
.object({
name: zod.string().min(1),
password: zod
.string()
.min(8, "パスワードは8文字以上で入力してください")
.regex(
/^(?=.*?[a-z])(?=.*?\d)[a-z\d]{8,100}$/i,
"パスワードは半角英数字混合で入力してください",
),
passwordConfirm: zod.string().min(1),
})
.superRefine(({ password, passwordConfirm }, ctx) => {
if (password !== passwordConfirm) {
ctx.addIssue({
path: ["passwordConfirm"],
code: "custom",
message: "パスワードが一致しません",
});
}
});
export const Form: FC = () => {
const {
handleSubmit,
register,
formState: { errors },
} = useForm({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit(() => void 0)}>
<label>
Name
<input type="text" {...register("name")} />
</label>
<label>
Password
<input type="password" {...register("password")} />
</label>
<label>
Password Confirmation
<input type="password" {...register("passwordConfirm")} />
</label>
<button type="submit">送信</button>
<div data-testid="error">
{Object.keys(errors).length > 0 && "エラーがあります"}
</div>
</form>
);
};
テストは以下(いくつか関数宣言を端折っていますが、関数名で挙動は察してください)
import { fc, it } from "@fast-check/jest";
import {
RenderOptions,
RenderResult,
render,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ReactNode } from "react";
import { Form } from "./Sample4";
it.prop([fc.gen(), fc.integer({ min: 1, max: 2 ** 3 - 1 })])(
"1つでもエラーがあればエラーが表示される",
async (gen, comb) => {
let c = comb;
const result = render(<Form />);
const password = gen(properString, 8, 20);
await [() => inputName(result, gen), () => inputInvalidName()][c % 2]();
c = c >> 1;
await [
() => inputPassword(result, gen, password),
() => inputInvalidPassword(),
][c % 2]();
c = c >> 1;
await [
() => inputPasswordConfirm(result, gen, password),
() => inputInvalidPasswordConfirm(),
][c % 2]();
await clickButtonA(result);
expect(result.getByText("エラーがあります")).toBeInTheDocument();
},
);
it.prop([fc.gen()])("すべてにエラーがないならエラーはない", async (gen) => {
const result = render(<Form />);
const password = gen(properString, 8, 20);
await inputName(result, gen);
await inputPassword(result, gen, password);
await inputPasswordConfirm(result, gen, password);
await clickButtonA(result);
expect(within(result.container).getByTestId("error")).toBeEmptyDOMElement();
});
すると次のエラーが出ました。
Got TestingLibraryElementError: Found multiple elements with the role "textbox" and name "Name"
Name用のテキストボックスは1つしかないはずなのですが、なぜか複数あるとみなされます。
そこでscreen.debugをしてみました。
すると以下のように確かに複数レンダリングされてました。
<body>
<div>
<form>
<label>
Name
<input
name="name"
type="text"
/>
</label>
...
<div>
<form>
<label>
Name
<input
name="name"
type="text"
/>
</label>
...
<div>
<form>
<label>
Name
<input
name="name"
type="text"
/>
</label>
どうやらこれは1テスト中で複数回@testing-libraryのrender回数を呼び出すことによって起きているみたいです。
いろいろ試行錯誤しましたが、結局冒頭の結論によって解決しました。
Discussion