🧪

Vitest+ React Testing Library+user-eventでテストを書く

2023/09/22に公開

動機

React+TypeScriptを業務で書いているが、単体テストは全く書いていません。
今後、単体テストを業務で書くときの参考や、どう書けばいいのが分からない人への布教として、よくあるケースをまとめました。

今回はサンプルのため素のReactで、かつ単体テストに近い形で書いています。実際には外部ライブラリを利用していると思いますが、考え方は一緒です。

サンプル用リポジトリは下記です。

https://github.com/ryokryok/rtl-vitest-msw-examples

利用するライブラリ

  • Vitest
    テストランナー。同等のライブラリJestがある。
  • Happy-Dom
    Webブラウザをシミュレートするためのライブラリ。これによってNode.js上でブラウザのテストができる。同等のライブラリにJSDOMがある。
  • React Testing Library
    Reactコンポーネントをシミュレートしたブラウザ環境でレンダリングする
  • user-event
    ユーザーの操作をシミュレートするためのライブラリ。Testing Libraryに同梱しているfireEventでも似たことはできるが、こっちの方がよりユーザーの挙動をシミュレートできるらしい。
  • MSW
    ネットワーク通信のモッキング

プロジェクトのセットアップ

pnpmでセットアップしていますが、ここは読み替えて大丈夫です。

pnpm create vite react-unit-test --template react-ts
cd react-unit-test
pnpm install

テスト系ライブラリのインストール

pnpm i -D vitest @vitest/coverage-v8 happy-dom @testing-library/react @testing-library/user-event msw

Configの設定

/// <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",
  },
});

テスト用ヘルパー関数の作成。必須ではないが何度もImport文を書く手間が省けるのでやった方がいいです。

touch src/testUtils.ts
import { ReactElement } from "react";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

export const setup = (jsx: ReactElement) => {
  return {
    user: userEvent.setup(),
    ...render(jsx),
  };
};

package.json にテストコマンドを追加します。coverageは必須ではないのですが、全てのテスト結果を一覧で確認できるので振り返るときに便利です。

  "scripts": {
    "test": "vitest",
    "coverage": "vitest run --coverage",
  },

テストの流れ

テスト内の流れは大まかには下記です

  • Reactコンポーネントをレンダリングする
  • 要素を取得して、表示をチェックする、非同期で表示される場合はawait findByで待ちの処理を入れる
  • ユーザーインタラクションがある場合はその処理を書き、表示は変わるなら再度表示のチェック

Vitestでcleanup()が自動実行されない不具合(2023-09-22時点)

下記にて報告されていますが、デフォルト設定で実行しても前回のレンダリング結果が残ってしまう不具合が見られます。

回避策として、React Testing Libraryのcleanup()を毎回終了後に呼び出します。

afterEach(() => {
  cleanup();
});

https://github.com/vitest-dev/vitest/issues/1430

最初のテスト

簡単なボタンのコンポーネントのテスト。

import { FC, forwardRef, ComponentPropsWithRef } from "react";
import { it, expect, vi, afterEach } from "vitest";
import { screen, cleanup } from "@testing-library/react";
import { setup } from "./testUtils";

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 mockFunction = vi.fn();
  const { user } = setup(<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 mockFunction = vi.fn();
  const { user } = setup(<Button onClick={mockFunction}>b</Button>);
  const target = screen.getByRole("button");
  expect(target.innerText).toBe("b");
  await user.dblClick(target);
  expect(mockFunction).toBeCalledTimes(2);
});

実行します。

pnpm test

テキスト入力Hooksのテスト

userEvent がElementを対象にするので、Custom Hooksの場合はテスト用にコンポーネントを用意してやる必要があります。

import { useState, useCallback, ChangeEvent } from "react";

export const useInput = (initialValue: string = "") => {
  const [input, setInput] = useState(initialValue);

  const handler = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value),
    []
  );

  return { input, handler };
};
import { FC } from "react";
import { describe, test, expect, afterEach } from "vitest";
import { screen, cleanup } from "@testing-library/react";
import { setup } from "./testUtils";
import { useInput } from "./useInput";

type TestConfig = {
  initialValue: string;
};

const Input: FC<TestConfig> = ({ initialValue }) => {
  const { input, handler } = useInput(initialValue);
  return <input value={input} onChange={handler} />;
};

afterEach(() => {
  cleanup();
});

describe("useInput", () => {
  test("initial value is empty", () => {
    setup(<Input initialValue="" />);
    const input = screen.getByRole<HTMLInputElement>("textbox");

    expect(input.value).toBe("");
  });

  test("initial value is hello", () => {
    setup(<Input initialValue="hello" />);
    const input = screen.getByRole<HTMLInputElement>("textbox");

    expect(input.value).toBe("hello");
  });

  test("update input", async () => {
    const { user } = setup(<Input initialValue="" />);
    const input = screen.getByRole<HTMLInputElement>("textbox");

    await user.type(input, "aaa");
    expect(input.value).toBe("aaa");
  });
});

外部APIへのfetchを行うコンポーネントのテスト

外部APIの例としてjsonplaceholderを使います。

https://jsonplaceholder.typicode.com/

コンポーネントとしてはPropsからIDを受け取ってそれを元に取得する形です。

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>Not Found</p>;
  }
  if (user === null) {
    return <p>Loading...</p>;
  }
  return (
    <div>
      <p style={{ fontWeight: "bold" }}>{user.name}</p>
      <p>{user.username}</p>
    </div>
  );
};

これからテストを書いていきますが、外部APIと通信する場合はモックが必要です。

なぜテストに通信モックを使うのか

外部APIをそのまま利用するテストもありますが、結合度の低いテストでは、実行の安定性を高めるためにモックが必要です。

  • 外部APIがダウンしてしまう可能性がある
  • 常に同じレスポンスを返すとは限らない
  • テスト用のデータをテスト内に記載したい
  • 同じ通信先でエラーを返すパターンを検証したい

JavaScriptではMSWを使ったネットワーキングモックが一般的なのでそれを利用します。
ここでは成功系と失敗系を両方のモックと処理を書いています。

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.getByText("Loading...")).not.toBeUndefined();

    // show user info
    expect(await screen.findByText("John Doe")).not.toBeUndefined();
    expect(await screen.findByText("jd")).not.toBeUndefined();
  });
});

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.getByText("Loading...")).not.toBeUndefined();

    // show not found
    expect(await screen.findByText("Not Found")).not.toBeUndefined();
  });
});

親コンポーネントに依存したコンポーネントのテスト

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>
  );
};

テストコードは下記。Contextのコンポーネントをレンダリングする以外は特に変わりありません。

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>Current theme is {theme}</p>;
}

function UpdateTheme() {
  const { update } = useContext(ThemeContext);
  return (
    <>
      <button onClick={update}>toggle theme</button>
    </>
  );
}

afterEach(() => {
  cleanup();
});

it("Theme context, initial theme is light", () => {
  setup(
    <ThemeProvider>
      <DisplayTheme />
    </ThemeProvider>
  );

  expect(screen.getByText("Current theme is light")).not.toBeUndefined();
});

it("toggle light to dark", async () => {
  const { user } = setup(
    <ThemeProvider>
      <DisplayTheme />
      <UpdateTheme />
    </ThemeProvider>
  );

  await user.click(screen.getByRole("button"));

  expect(screen.getByText("Current theme is dark")).not.toBeUndefined();
});

感想

Testing Libraryでユーザーインタラクションがある場合はuser-eventを使うのが推奨されているのですが、それを使った場合ってどう書けばいいっけ?って詰まったのと、基本的なコンポーネントテストの流れを復習できました。

これからテストを書き始める参考になったら幸いです。

参考

サイボウズさんにてテストの考え方や例などもあるのでこちらも大変参考になりました。

https://blog.cybozu.io/entry/2022/08/29/110000

https://speakerdeck.com/cybozuinsideout/web_frontend_testing_and_automation-2023

Discussion