Vitest + React Testing Libraryでテストを書きたい
このスクラップの内容をまとめて下記の記事を書きました。
動機
仕事でReactはよく書くが、コンポーネントのUnit Testは全く書いていないので、後の布教を含めて書き残す。
プロジェクトのセットアップ
pnpm create vite react-unit-test --template react-ts
cd react-unit-test
pnpm install
テスト系ライブラリ
pnpm i -D vitest happy-dom @testing-library/react @testing-library/user-event
config
vite.config.ts
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: "happy-dom",
},
});
テスト実行
pnpm vitest
最初のテスト
@testing-library/user-event
を使った方がリアルなシミュレーションテストらしい。
とりあえず最初のテストを書いてみる。
import { FC, forwardRef, ComponentPropsWithRef } from "react";
import { it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const Button: FC<ComponentPropsWithRef<"button">> = forwardRef((props, ref) => (
<button ref={ref} {...props}>
{props.children}
</button>
));
it("Button, display children and function called", async () => {
const user = userEvent.setup();
const mockFunction = vi.fn();
render(<Button onClick={mockFunction}>a</Button>);
const target = screen.getByRole("button");
expect(target.innerText).toBe("a");
await user.click(target);
expect(mockFunction).toBeCalledTimes(1);
});
cleanupを手動で呼び出さないとレンダリング結果が残ってしまう
下記でも報告されているが、threads: false
にするとtesting-library/reactのクリーンアップ関数が自動で実行されないので、以前のレンダリング結果が残ってしまうらしい。
ただ手元で確認した限りだと、デフォルト設定のthreads: true
でも起きてしまう。
回避策としてはcleanup
をテスト後に毎回実行するようにする。
import { FC, forwardRef, ComponentPropsWithRef } from "react";
import { it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const Button: FC<ComponentPropsWithRef<"button">> = forwardRef((props, ref) => (
<button ref={ref} {...props}>
{props.children}
</button>
));
afterEach(() => {
cleanup();
});
it("Button, display children and function called", async () => {
const user = userEvent.setup();
const mockFunction = vi.fn();
render(<Button onClick={mockFunction}>a</Button>);
const target = screen.getByRole("button");
expect(target.innerText).toBe("a");
await user.click(target);
expect(mockFunction).toBeCalledTimes(1);
});
it("Button, display children and function called", async () => {
const user = userEvent.setup();
const mockFunction = vi.fn();
render(<Button onClick={mockFunction}>b</Button>);
const target = screen.getByRole("button");
expect(target.innerText).toBe("b");
await user.dblClick(target);
expect(mockFunction).toBeCalledTimes(2);
});
テキスト入力Hooksのテスト
単純なクリックテストだけじゃなく、テキスト入力のテストもしてみる。
userEvent
がElementを対象にするので、Custom Hooksの場合は何かしらでレンダリングする必要がありそう。
下記を参考にセットアップ用関数も作成する。
書いてみたテストは下記。
import { ChangeEvent, ReactElement, useCallback, useState } from "react";
import { describe, test, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const useInput = (initialValue: string = "") => {
const [input, setInput] = useState(initialValue);
const handler = useCallback(
(e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value),
[]
);
return { input, handler };
};
type TestConfig = {
initialValue: string;
};
const Input: React.FC<TestConfig> = ({ initialValue }) => {
const { input, handler } = useInput(initialValue);
return <input value={input} onChange={handler} />;
};
const setup = (jsx: ReactElement) => {
return {
user: userEvent.setup(),
...render(jsx),
input: screen.getByRole<HTMLInputElement>("textbox"),
};
};
afterEach(() => {
cleanup();
});
describe("useInput", () => {
test("initial value is empty", () => {
const { input } = setup(<Input initialValue="" />);
expect(input.value).toBe("");
});
test("initial value is hello", () => {
const { input } = setup(<Input initialValue="hello" />);
expect(input.value).toBe("hello");
});
test("update input", async () => {
const { user, input } = setup(<Input initialValue="" />);
await user.type(input, "aaa");
expect(input.value).toBe("aaa");
});
});
当然だけどscreen.getByRole<HTMLInputElement>("textbox")
をrender
より先に書くとElementが取得できなくて失敗する。
// これはレンダリング前にinputを取得しようとするので失敗する
const setup = (jsx: ReactElement) => {
return {
user: userEvent.setup(),
input: screen.getByRole<HTMLInputElement>("textbox"),
...render(jsx),
};
};
保守性を考えるならテストで毎回const input = screen.getByRole<HTMLInputElement>("textbox")
を呼び出す方が見やすいと思う。
test("initial value is empty", () => {
setup(<Input initialValue="" />);
const input = screen.getByRole<HTMLInputElement>("textbox");
expect(input.value).toBe("");
});
APIからのデータ取得コンポーネント
よくあるReactでレンダリング時にfetch APIで取得するパターン。
エラーハンドリングはAPIによって異なるので、jsonplaceholderを例にする。
(IDに対するデータが見つからないときはどんなResponseステータスを返すとか、サーバーのデータベース接続エラー時にはどういうステータスを返すか、は取り決めする)
全部の処理をコンポーネント内にまとめると分かりづらいので、データ取得関数とコンポーネントを分離する。
データ取得処理はReact公式の例を参考にした。余談だが、React公式としては、クライアントサイドの手動fetchはあまりやってほしくないらしい。useSWRなどのライブラリを使った方がいいらしい。
import { useState, useEffect, FC } from "react";
export type UserModel = {
id: number;
name: string;
username: string;
};
export const fetchUser = async (userId: string): Promise<UserModel> => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
if (response.ok || response.status === 200) {
return response.json();
} else {
throw new Error("fetching error");
}
};
export const User: FC<{ id: string }> = ({ id }) => {
const [user, setUser] = useState<UserModel | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let ignore = false;
const startFetching = async () => {
setUser(null);
try {
const result = await fetchUser(id);
if (!ignore) {
setUser(result);
}
} catch {
setError(true);
}
};
startFetching();
return () => {
ignore = true;
};
}, [id]);
if (user === null && error) {
return <p data-testid="error-text">Not Found</p>;
}
if (user === null) {
return <p data-testid="loading-text">Loading...</p>;
}
return (
<div data-testid="user-wrapper">
<p style={{ fontWeight: "bold" }} data-testid="user-name">
{user.name}
</p>
<p data-testid="user-username">{user.username}</p>
</div>
);
};
なぜテストにモックを使うのか
外部APIをそのまま利用するテストもありだが、実際にはモックが必要
- 外部APIがダウンしてしまう可能性がある
- 常に同じレスポンスを返すとは限らない
- テスト用のデータをテスト内に記載したい
- 同じ通信先でエラーを返すパターンを検証したい
JavaScriptではMSWを使ったネットワーキングモックが一般的なのでそれを利用する。
pnpm i -D msw
書いてみたテストは下記。成功系と失敗系でモックサーバーを書いている。
データ取得関数とコンポーネントの両方テストを書いている。
モックサーバーをちゃんとテストコードが膨らんでいくので、モックするパス毎にテストをまとめたい気もする。
すぐに表示される文字など、同期的に要素を取得するときはgetBy
、通信後に表示される文字など、非同期に要素を取得するときはfindBy
で取得する。
import { it, expect, afterEach, beforeAll, afterAll, describe } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { rest, RestHandler } from "msw";
import { setupServer } from "msw/node";
import { User, fetchUser } from "./User";
describe("Success pattern", () => {
const successHandler: RestHandler = rest.get(
"https://jsonplaceholder.typicode.com/users/:userId",
(req, res, ctx) => {
const { userId } = req.params;
if (userId === "1") {
return res(
ctx.status(200),
ctx.json({
id: 1,
name: "John Doe",
username: "jd",
})
);
} else {
return res(
ctx.status(404),
ctx.json({
message: "Not Found",
})
);
}
}
);
const successServer = setupServer(successHandler);
beforeAll(() => {
successServer.listen();
});
afterEach(() => {
cleanup();
successServer.resetHandlers();
});
afterAll(() => {
successServer.close();
});
it("fetchUser return user data", async () => {
const result = await fetchUser("1");
expect(result).toEqual({
id: 1,
name: "John Doe",
username: "jd",
});
});
it("User ID:1, John Doe, jd", async () => {
render(<User id="1" />);
// check loading text
expect(screen.getByTestId("loading-text").innerHTML).toBe("Loading...");
// show user info
expect((await screen.findByTestId("user-name")).innerHTML).toBe("John Doe");
expect((await screen.findByTestId("user-username")).innerHTML).toBe("jd");
});
});
describe("Failed pattern", () => {
const failedHandler: RestHandler = rest.get(
"https://jsonplaceholder.typicode.com/users/:userId",
(_, res, ctx) => {
return res(
ctx.status(404),
ctx.json({
message: "Not Found",
})
);
}
);
const failedServer = setupServer(failedHandler);
beforeAll(() => {
failedServer.listen();
});
afterEach(() => {
cleanup();
failedServer.resetHandlers();
});
afterAll(() => {
failedServer.close();
});
it("fetchUser throws error", async () => {
await expect(fetchUser("1")).rejects.toThrowError();
});
it("User, network error", async () => {
render(<User id="1" />);
// check loading text
expect(screen.getByTestId("loading-text").innerHTML).toBe("Loading...");
// show not found
expect((await screen.findByTestId("error-text")).innerHTML).toBe(
"Not Found"
);
});
});
data-testid
の利用について
テスト用にdata-testid
属性を付与した。これは「HTMLとして表示とは関係ない属性がテスト用に埋め込まれる」ので賛否が分かれそうではある。
return (
<div data-testid="user-wrapper">
<p style={{ fontWeight: "bold" }} data-testid="user-name">
{user.name}
</p>
<p data-testid="user-username">{user.username}</p>
</div>
);
ただこれを使わないでマークアップした場合、HTML構造や表示テキストが変わった途端にscreen.getAllByRole("textbox")[0]
やscreen.getAllByText("Some Text")[1]
で取得したテストを書き直すことになったりするので、開発としてはやり辛くはなる。
特にLoading用のコンポーネントやSVGだと悩む…のである意味トレードオフ。
Test用のUtils関数
毎回Userのセットアップを書くのは面倒だと思ったが、下記に記載がある通りカスタムレンダリング関数を定義して、それを呼び出すようにする。
import userEvent from "@testing-library/user-event";
import { ReactElement } from "react";
import { render } from "@testing-library/react";
export const setup = (jsx: ReactElement) => {
return {
user: userEvent.setup(),
...render(jsx),
};
};
親コンポーネントに依存したコンポーネントのテスト
React ReduxやReact Routerのような親コンポーネントでラップするタイプのテスト。
他のコンポーネントのテストとやることは変わらない。
サンプルで外部ライブラリに依存したくはないので書くのはContext APIを使った例を書く。
import { FC, ReactNode, createContext, useCallback, useState } from "react";
type Theme = "light" | "dark";
type ContextType = {
theme: Theme;
update: () => void;
};
export const ThemeContext = createContext<ContextType>({
theme: "light",
update: () => {},
});
export const ThemeProvider: FC<{
children: ReactNode;
}> = ({ children }) => {
const [theme, setTheme] = useState<Theme>("light");
const update = useCallback(() => {
setTheme((c) => (c === "light" ? "dark" : "light"));
}, []);
return (
<ThemeContext.Provider value={{ theme, update }}>
{children}
</ThemeContext.Provider>
);
};
import { it, expect, afterEach } from "vitest";
import { screen, cleanup } from "@testing-library/react";
import { useContext } from "react";
import { setup } from "./testUtils";
import { ThemeContext, ThemeProvider } from "./ThemeContext";
function DisplayTheme() {
const { theme } = useContext(ThemeContext);
return <p data-testid="theme-display">Current theme is {theme}</p>;
}
function UpdateTheme() {
const { update } = useContext(ThemeContext);
return (
<>
<button data-testid="theme-toggle" onClick={update}>
toggle theme
</button>
</>
);
}
afterEach(() => {
cleanup();
});
it("Theme context, initial theme is light", () => {
setup(
<ThemeProvider>
<DisplayTheme />
</ThemeProvider>
);
expect(screen.getByTestId("theme-display").innerHTML).toBe(
"Current theme is light"
);
});
it("toggle light to dark", async () => {
const { user } = setup(
<ThemeProvider>
<DisplayTheme />
<UpdateTheme />
</ThemeProvider>
);
await user.click(screen.getByTestId("theme-toggle"));
expect(screen.getByTestId("theme-display").innerHTML).toBe(
"Current theme is dark"
);
});
何回もラッパーコンポーネントを書く必要がある場合は下記で記述するのもできる。
結合テストを書くのにいいかもしれない。
export const setup = (jsx: ReactElement) => {
return {
user: userEvent.setup(),
...render(jsx, {wrapper: ThemeProvider}),
};
};
本当はwindow.matchMedia("(prefers-color-scheme: dark)")
を使いたかったがVitest環境ではできなさそう。
Playwrightではできるみたい。