Closed8

Vitest + React Testing Libraryでテストを書きたい

mr_ozinmr_ozin

このスクラップの内容をまとめて下記の記事を書きました。

https://zenn.dev/mr_ozin/articles/134d5254ca93bb


動機

仕事で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
mr_ozinmr_ozin

最初のテスト

@testing-library/user-event を使った方がリアルなシミュレーションテストらしい。

https://testing-library.com/docs/user-event/intro

とりあえず最初のテストを書いてみる。

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

mr_ozinmr_ozin

cleanupを手動で呼び出さないとレンダリング結果が残ってしまう

下記でも報告されているが、threads: false にするとtesting-library/reactのクリーンアップ関数が自動で実行されないので、以前のレンダリング結果が残ってしまうらしい。

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

ただ手元で確認した限りだと、デフォルト設定の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);
});

mr_ozinmr_ozin

テキスト入力Hooksのテスト

単純なクリックテストだけじゃなく、テキスト入力のテストもしてみる。

userEvent がElementを対象にするので、Custom Hooksの場合は何かしらでレンダリングする必要がありそう。

下記を参考にセットアップ用関数も作成する。

https://testing-library.com/docs/user-event/intro#writing-tests-with-userevent

書いてみたテストは下記。

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("");
  });
mr_ozinmr_ozin

APIからのデータ取得コンポーネント

よくあるReactでレンダリング時にfetch APIで取得するパターン。
エラーハンドリングはAPIによって異なるので、jsonplaceholderを例にする。
(IDに対するデータが見つからないときはどんなResponseステータスを返すとか、サーバーのデータベース接続エラー時にはどういうステータスを返すか、は取り決めする)

全部の処理をコンポーネント内にまとめると分かりづらいので、データ取得関数とコンポーネントを分離する。

データ取得処理はReact公式の例を参考にした。余談だが、React公式としては、クライアントサイドの手動fetchはあまりやってほしくないらしい。useSWRなどのライブラリを使った方がいいらしい。

https://react.dev/reference/react/useEffect#fetching-data-with-effects

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

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だと悩む…のである意味トレードオフ。

mr_ozinmr_ozin

Test用のUtils関数

毎回Userのセットアップを書くのは面倒だと思ったが、下記に記載がある通りカスタムレンダリング関数を定義して、それを呼び出すようにする。

https://testing-library.com/docs/user-event/intro#writing-tests-with-userevent

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

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

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ではできるみたい。

https://playwright.dev/docs/codegen#emulate-color-scheme

このスクラップは2023/10/18にクローズされました