👻

fast-check × @testing-library/reactでテストが失敗した件

2024/03/01に公開

えぐい詰まったのでメモとして残します。

結論

@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