Kent C. Dodds 氏による Avoid Nesting when you’re Testing という記事を翻訳させていただきました。
以下、本文。
これからご紹介するのは、React コンポーネントのテストに適用される一般的なテスト原則です。例に React を使用していますが、この概念正しく理解するのに役立つことを願っています。
テストしたい React コンポーネントはこちらです:
login.js
import * as React from "react";
function Login({ onSubmit }) {
const [error, setError] = React.useState("");
function handleSubmit(event) {
event.preventDefault();
const {
usernameInput: { value: username },
passwordInput: { value: password },
} = event.target.elements;
if (!username) {
setError("username is required");
} else if (!password) {
setError("password is required");
} else {
setError("");
onSubmit({ username, password });
}
}
return (
div>
form onSubmit={handleSubmit}>
div>
label htmlFor="usernameInput">Usernamelabel>
input id="usernameInput" />
div>
div>
label htmlFor="passwordInput">Passwordlabel>
input id="passwordInput" type="password" />
div>
button type="submit">Submitbutton>
form>
{error ? div role="alert">{error}div> : null}
div>
);
}
export default Login;
そして以下のようにレンダリングされます:
訳注: 元記事では実際に動作するコンポーネントですが、ここでは画像で表示しています
長年の経験で、以下のようなテストスイートをよく見かけます。
__tests__/login.js
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as React from "react";
import Login from "../login";
describe("Login", () => {
let utils,
handleSubmit,
user,
changeUsernameInput,
changePasswordInput,
clickSubmit;
beforeEach(() => {
handleSubmit = jest.fn();
user = { username: "michelle", password: "smith" };
utils = render(Login onSubmit={handleSubmit} />);
changeUsernameInput = (value) =>
userEvent.type(utils.getByLabelText(/username/i), value);
changePasswordInput = (value) =>
userEvent.type(utils.getByLabelText(/password/i), value);
clickSubmit = () => userEvent.click(utils.getByText(/submit/i));
});
describe("ユーザー名とパスワードが提供されている場合", () => {
beforeEach(() => {
changeUsernameInput(user.username);
changePasswordInput(user.password);
});
describe("送信ボタンがクリックされたとき", () => {
beforeEach(() => {
clickSubmit();
});
it("ユーザー名とパスワードで onSubmit が呼び出される", () => {
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith(user);
});
});
});
describe("パスワードが提供されていない場合", () => {
beforeEach(() => {
changeUsernameInput(user.username);
});
describe("送信ボタンがクリックされたとき", () => {
let errorMessage;
beforeEach(() => {
clickSubmit();
errorMessage = utils.getByRole("alert");
});
it("エラーメッセージが表示される", () => {
expect(errorMessage).toHaveTextContent(/password is required/i);
});
});
});
describe("ユーザー名が提供されていない場合", () => {
beforeEach(() => {
changePasswordInput(user.password);
});
describe("送信ボタンがクリックされたとき", () => {
let errorMessage;
beforeEach(() => {
clickSubmit();
errorMessage = utils.getByRole("alert");
});
it("エラーメッセージが表示される", () => {
expect(errorMessage).toHaveTextContent(/username is required/i);
});
});
});
});
これにより、このコンポーネントが設計通りに動作し、今後も動作し続けるという 100% の確信が得られるはずです。そして実際に動作しています。しかし、このテストについて私が気に入らない点は以下の通りです:
過度な抽象化
changeUsernameInput
や clickSubmit
のようなユーティリティは便利ですが、テストが単純な場合はコードを複製した方が簡潔になることもあります。この小さなテストセットでは関数の抽象化はあまりメリットがなく、開発者がファイル内でこれらの関数が定義されている場所を探すコストがかかります。
ネスト
上記のテストは Jest API を使って記述されていますが、主要な JavaScript のフレームワークには同様の API が存在します。具体的には、テストをグループ化する describe
、共通のセットアップやアクションを実行する beforeEach
、そして実際のアサーションを実行する it
などです。
私はこのようなネストを大変嫌っています。これまでこのように記述された何千ものテストを保守してきました。この 3 つの単純なテストだけでも大変な作業なのに、何千行ものテストになり、さらにネストが深くなると状況はさらに悪化します。
なぜこれほど複雑なのでしょうか? 例えば、次の部分を見てみましょう:
it("ユーザー名とパスワードで onSubmit が呼び出される", () => {
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith(user);
});
handleSubmit
はどこから来て、その値は何ですか? user
はどこから来て、その値は何ですか? もちろん、どこで定義されているかは自分で調べられます:
describe("Login", () => {
let utils,
> handleSubmit,
> user,
changeUsernameInput,
changePasswordInput,
clickSubmit;
});
しかし、それがどこで代入されているかを把握する必要もあるのです:
beforeEach(() => {
> handleSubmit = jest.fn();
> user = { username: "michelle", password: "smith" };
});
そしてその変数が、さらにネストされた beforeEach
の中で別の値に代入されていないか確認する必要があります。コードをたどって変数とその値の変化を時系列で追跡しなければいけない。私がネストされたテストを強く推奨しない最大の理由がこれです。このような些細なことを頭に抱え込む量が増えれば増えるほど、目の前の重要なタスクを達成するための余裕が少なくなってしまいます。
変数の再代入は「アンチパターン」であり避けるべきだという意見があります。それには同意しますが、すでに複雑なリンティングルールにさらにルールを追加するのは良い解決策とは言えません。変数の再代入をまったく気にすることなく、この共通設定を共有できる方法があったらどうでしょうか?
インライン化しましょう!
このシンプルなコンポーネントの場合、抽象化をできる限り排除するのが最善の解決策だと思います。こちらをご覧ください:
__tests__/login.js
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as React from "react";
import Login from "../login";
test("送信ボタンがクリックされるとユーザー名とパスワードで onSubmit が呼び出される", () => {
const handleSubmit = jest.fn();
const { getByLabelText, getByText } = render(
Login onSubmit={handleSubmit} />
);
const user = { username: "michelle", password: "smith" };
userEvent.type(getByLabelText(/username/i), user.username);
userEvent.type(getByLabelText(/password/i), user.password);
userEvent.click(getByText(/submit/i));
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith(user);
});
test("ユーザー名が提供されていない状態で送信ボタンがクリックされるとエラーメッセージが表示される", () => {
const handleSubmit = jest.fn();
const { getByLabelText, getByText, getByRole } = render(
Login onSubmit={handleSubmit} />
);
userEvent.type(getByLabelText(/password/i), "anything");
userEvent.click(getByText(/submit/i));
const errorMessage = getByRole("alert");
expect(errorMessage).toHaveTextContent(/username is required/i);
expect(handleSubmit).not.toHaveBeenCalled();
});
test("パスワードが提供されていない状態で送信ボタンがクリックされるとエラーメッセージが表示される", () => {
const handleSubmit = jest.fn();
const { getByLabelText, getByText, getByRole } = render(
Login onSubmit={handleSubmit} />
);
userEvent.type(getByLabelText(/username/i), "anything");
userEvent.click(getByText(/submit/i));
const errorMessage = getByRole("alert");
expect(errorMessage).toHaveTextContent(/password is required/i);
expect(handleSubmit).not.toHaveBeenCalled();
});
少し重複していることに気づくでしょう(これは後ほど説明します)。しかし、これらのテストの明確さを見てください。一部のテストユーティリティとログインコンポーネント自体を除いて、テスト全体が自己完結的です。これにより、スクロールすることなく各テスト内で起こっていることを把握しやすくなりました。このコンポーネントにさらにテストを増やす場合、そのメリットはさらに大きくなるでしょう。
また、全体を describe
ブロックでネストしていないことにも注意してください。これは本当に必要ないからです。ファイル内のすべては明らかに login
コンポーネントのテストなので、1 レベルであってもネストするのは無意味です。
AHA(Avoid Hasty Abstractions: 早まった抽象化を避ける)を適用する
AHA principle では、次のように述べられています。
誤った抽象化よりも重複を優先し、変更しやすさを第一に考えるべき
このシンプルなログインコンポーネントなら、テストはこのままでも十分でしょう。しかし、もう少し複雑になってコードの重複が問題になり始め、それを減らしたいと考えているとしましょう。その場合は beforeEach
を使うべきでしょうか? beforeEach
はそのためのものですよね?
確かにそれも可能です。しかし、その場合はミュータブルな変数への代入についてまた考えなければならなくなり、それは避けたいですね。テスト間でコードを共有するには他の方法はないでしょうか? あぁ!(AHA!) 関数を使えばいいのか!
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as React from "react";
import Login from "../login";
function setup() {
const handleSubmit = jest.fn();
const utils = render(Login onSubmit={handleSubmit} />);
const user = { username: "michelle", password: "smith" };
const changeUsernameInput = (value) =>
userEvent.type(utils.getByLabelText(/username/i), value);
const changePasswordInput = (value) =>
userEvent.type(utils.getByLabelText(/password/i), value);
const clickSubmit = () => userEvent.click(utils.getByText(/submit/i));
return {
...utils,
handleSubmit,
user,
changeUsernameInput,
changePasswordInput,
clickSubmit,
};
}
function setupSuccessCase() {
const utils = setup();
utils.changeUsernameInput(utils.user.username);
utils.changePasswordInput(utils.user.password);
utils.clickSubmit();
return utils;
}
function setupWithNoPassword() {
const utils = setup();
utils.changeUsernameInput(utils.user.username);
utils.clickSubmit();
const errorMessage = utils.getByRole("alert");
return { ...utils, errorMessage };
}
function setupWithNoUsername() {
const utils = setup();
utils.changePasswordInput(utils.user.password);
utils.clickSubmit();
const errorMessage = utils.getByRole("alert");
return { ...utils, errorMessage };
}
test("ユーザー名とパスワードで onSubmit が呼び出される", () => {
const { handleSubmit, user } = setupSuccessCase();
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith(user);
});
test("ユーザー名が提供されていない状態で送信ボタンがクリックされると、エラーメッセージが表示される", () => {
const { handleSubmit, errorMessage } = setupWithNoUsername();
expect(errorMessage).toHaveTextContent(/username is required/i);
expect(handleSubmit).not.toHaveBeenCalled();
});
test("パスワードが提供されていない場合はエラーメッセージが表示される", () => {
const { handleSubmit, errorMessage } = setupWithNoPassword();
expect(errorMessage).toHaveTextContent(/password is required/i);
expect(handleSubmit).not.toHaveBeenCalled();
});
これで、これらのシンプルな setup
関数を使ったテストをたくさん作成できるようになりました。また、これらを組み合わせることで、先ほどのネストされた beforeEach
と同様の動作を実現できることにも注目してください。ただし、ミュータブルな変数を意識的に管理する必要はなくなります。
AHA テストのメリットについて詳しくは、AHA Testing をご覧ください。
テストのグループ化については?
describe
関数は関連するテストをグループ化するためのもので、特にテストファイルが大きくなった場合に、異なるテストを視覚的に区別するのに適しています。しかし、テストファイルが大きくなるのは好ましくありません。そこで私は describe
ブロックではなく、ファイル単位でグループ化しています。つまり、同じ「ユニット」のコードに対して論理的に異なるテストがグループ化されている場合は、それらを完全に別のファイルに分割します。また、テスト間で本当にコードを共有する必要がある場合は、__tests__/helpers/login.js
ファイルを作成して共有コードを格納します。
この方法には以下の利点があります: テストの論理的なグループ化、テスト固有のセットアップの完全な分離、そして特定のコード部分に取り組む際の認知負荷の軽減です。また、テストフレームワークがテストを並列実行できる場合は、テストの実行速度も速くなる可能性があります。
クリーンアップは?
この記事は、beforeEach
/afterEach
などのユーティリティを批判するものではありません。テストにおけるミュータブルな変数の使用や、抽象化について注意を促すことが目的です。
クリーンアップに関しては、テスト対象がグローバル環境に変更を加え、その後クリーンアップが必要になる状況に陥ることがあります。そのようなコードをテスト内にインラインで記述しようとすると、テストが失敗したときにクリーンアップが実行されず、他のテストも失敗する可能性があります。最終的には、デバッグが困難になるような大量のエラー出力につながる可能性があります。
たとえば React Testing Library はコンポーネントを document に挿入しますが、各テストの後にクリーンアップしないと、テスト同士が干渉し合う可能性があります。
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as React from "react";
import Login from "../login";
test("example 1", () => {
const handleSubmit = jest.fn();
const { getByLabelText } = render(Login onSubmit={handleSubmit} />);
userEvent.type(getByLabelText(/username/i), "kentcdodds");
userEvent.type(getByLabelText(/password/i), "ilovetwix");
});
test("example 2", () => {
const handleSubmit = jest.fn();
const { getByLabelText } = render(Login onSubmit={handleSubmit} />);
userEvent.type(getByLabelText(/username/i), "kentcdodds");
});
これを修正するのは非常に簡単で、@testing-library/react
からインポートした cleanup
メソッドを各テストの後に実行する必要があります。
> import { cleanup, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as React from "react";
import Login from "../login";
test("example 1", () => {
const handleSubmit = jest.fn();
const { getByLabelText } = render(Login onSubmit={handleSubmit} />);
userEvent.type(getByLabelText(/username/i), "kentcdodds");
userEvent.type(getByLabelText(/password/i), "ilovetwix");
> cleanup();
});
test("example 2", () => {
const handleSubmit = jest.fn();
const { getByLabelText } = render(Login onSubmit={handleSubmit} />);
userEvent.type(getByLabelText(/username/i), "kentcdodds");
> cleanup();
});
ただし、afterEach
を使わずにこの方法を採用すると、テストが失敗したときにクリーンアップは実行されません。次のようになります:
test("example 1", () => {
const handleSubmit = jest.fn();
const { getByLabelText } = render(Login onSubmit={handleSubmit} />);
userEvent.type(getByLabelText(/username/i), "kentcdodds");
>
>
> userEvent.type(getByLabelText(/passssword/i), "ilovetwix");
cleanup();
});
このため、”example 1″ の cleanup
関数は実行されず、”example 2″ も正常に実行されません。そのため、1 つのテストが失敗したのではなく、すべてのテストが失敗したと表示され、デバッグがはるかに困難になります。
そのため、代わりに afterEach
を使用してください。これにより、テストが失敗した場合でもクリーンアップを実行できます。
import { cleanup, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as React from "react";
import Login from "../login";
afterEach(() => cleanup());
test("example 1", () => {
const handleSubmit = jest.fn();
const { getByLabelText } = render(Login onSubmit={handleSubmit} />);
userEvent.type(getByLabelText(/username/i), "kentcdodds");
userEvent.type(getByLabelText(/password/i), "ilovetwix");
});
test("example 2", () => {
const handleSubmit = jest.fn();
const { getByLabelText } = render(Login onSubmit={handleSubmit} />);
userEvent.type(getByLabelText(/username/i), "kentcdodds");
});
さらに、before*
が有効なユースケースも確かにありますが、通常は after*
で必要なクリーンアップと組み合わせて使用されます。例えば、サーバーの起動と停止などです:
let server;
beforeAll(async () => {
server = await startServer();
});
afterAll(() => server.close());
これを実現するための確実な方法は他にありません。私がこれらのフックを使った別のユースケースとして思いつくのは console.error
呼び出しのテストです。
beforeAll(() => {
jest.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
console.error.mockClear();
});
afterAll(() => {
console.error.mockRestore();
});
こうしたフックのユースケースは確かに存在します。ただ、コード再利用のメカニズムとしてはお勧めしません。そのためには関数を使います。
おわりに
以下のツイートで私が何を意味していたのかがこれで明確になれば幸いです:
これまで様々なフレームワークやスタイルで何万ものテストを書いてきましたが、経験上、変数の変更を減らすことでテストのメンテナンスが大幅に簡素化されました。頑張ってください!
追伸: サンプルコードを試してみたい方は codesandbox をご覧ください
Views: 0