🍝

VitestでReactコンポーネントをテストする

2022/07/27に公開

今回テストする対象は各ページで利用が想定されるヘッダーコンポーネントです。テストする項目はスナップショットテストに加え、Zennのヘッダーコンポーネントのようにログイン状態を表す機能を持っているのでその辺りもテストしたいと思います。

前提環境

表現したい状態とその時の画面の状態は以下の通りです。

サービスの状態 画面の状態
ログイン状態を監視していない時 画面には何も表示しない
ログイン状態を監視している + ログインしていない時 画面にはログインボタンを表示
ログイン状態を監視している + ログインしている時 画面にはユーザー名を表示

状態管理にRecoilを使用しているので上記の状態を表現するAtom,Selectorの定義です。

// stores/auth.ts
import { atom, selector } from "recoil"

const authState = atom<Atom>({
  key: "auth",
  default: { subscription: undefined, openAuthModalRequest: false },
});
export const uidState = selector<string | undefined>({
  key: "auth/uid",
  get: ({ get }) => get(authState)?.subscription?.uid,
});
export const isSubscribedState = selector<boolean>({
  key: "auth/isSubscribed",
  get: ({ get }) => get(authState)?.subscription !== undefined,
});

isSubscribedStateがログイン状態を監視しているかを判定し、uidStateがログインをしているかを判定します。

上記で定義したステートを利用してヘッダーを実装します。

// header.tsx
import { FC } from "react";
import Link from "next/link";
import {
  isSubscribedState,
  uidState,
} from "~/stores/auth";
import { useRecoilValue } from "recoil";
import { profileState } from "~/stores/profile";

const Header: FC = () => {
  const { currentUser } = useRecoilValue(profileState);
  const uid = useRecoilValue(uidState);
  const isSubscribed = useRecoilValue(isSubscribedState);
  return (
    <>
      <header className="container">
        <Link href={"/"}>
          <a className="link">
            //logo img
          </a>
        </Link>
        {isSubscribed
          ? (uid
            ? <span>{currentUser?.name}</span>
            : (
              <button onClick={() => toggle()}>
                Login
              </button>
            ))
          : null}
      </header>
      <style jsx>{`...`}<style>
    </>
  );
};

export default Header;

説明を省きましたが、profileStateは先のuidStateに特定のidがセットされたタイミングでプロフィールを取得、更新される様になっています。

Storyを定義

// header.stories.tsx
import { ComponentMeta, ComponentStory, DecoratorFn } from "@storybook/react";
import { useEffect } from "react";
import { useSetRecoilState, RecoilRoot } from "recoil";
import Header from "~/components/header";
import { authState } from "~/stores/auth";
import { profileState } from "~/stores/profile";

export const recoilProvider: DecoratorFn = (Story) => {
  return (
    <RecoilRoot>
      <Story />
    </RecoilRoot>
  );
};


type Story = ComponentStory<typeof Header>;
type Meta = ComponentMeta<typeof Header>;

export default {
  component: Header,
  decorators: [recoilProvider],
} as Meta;

export const Default: Story = () => {
  return <Header />;
};

export const ShouldLogin: Story = () => {
  const setAuth = useSetRecoilState(authState);
  useEffect(() => {
    setAuth({ subscription: {}, openAuthModalRequest: false });
  }, []);
  return <Header />;
};

export const LoggedIn: Story = () => {
  const setAuth = useSetRecoilState(authState);
  const setProfile = useSetRecoilState(profileState);
  useEffect(() => {
    setAuth({ subscription: { uid: "xxxx" }, openAuthModalRequest: false });
    setProfile({
      currentUser: {
        name: "logged in user",
      },
    });
  }, []);
  return <Header />;
};

Recoilをそれぞれのケースで独立して利用するためにdecoratorにRecoilRootを追加します。

他にも方法はあると思いますが、レンダリング直後のuseEffect内で必要なステートをセットする方式で実装しました。

今回はRecoilからデータを取り出す責務をヘッダーが持っていますが、Storybook、テストの実装を単純化するためにpropsで注入する形のコンポーネントを定義するのも一つの方法として考えられます。

テストの定義

本題のVitestを利用してテストを実装していきます。

vite.config.tsの構成は以下の通りです。

//vite.config.ts
/// <reference types="vitest" />

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "~/": path.join(__dirname, "src/"),
    },
  },
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/__tests__/setup.ts"],
  },
});

VitestではSpy,Mock機能が提供されているので、ログインボタンをクリックした時のテストを用意してみます。

const toggle = vi.fn();
const useToggle = vi.spyOn(authStore, "useToggleAuthModalRequest");
useToggle.mockImplementation(() => toggle);
const { getByRole } = render(<Story />);
expect(useToggle).toHaveBeenCalled();
user.click(getByRole("button", { name: "Login" }));
expect(toggle).toHaveBeenCalledTimes(1);

useToggleはボタンをクリックしたタイミングで呼ばれる関数を返却する関数です。レンダー直後にuseToggleが呼ばれたか、ユーザーのクリックによってtoggle関数が呼ばれたかをチェックします。

スナップショットテストの実装ですが、describe.eachを使用することで各Storyに対して都度テストを実装する必要がなくなります。

const cases: [stories: keyof typeof stories][] = [
  ["Default"],
  ["ShouldLogin"],
  ["LoggedIn"],
];
describe("Header component", () => {
  describe.each(cases)("%s", (story) => {
    const Story = composeStory(stories[story], stories.default);
    it("render component", () => {
      const { container } = render(<Story />);
      expect(container.firstChild).toMatchSnapshot();
    });
  });
});

また、if (story === ストーリー名)でStory毎にブロック内で個別のテスト項目を用意することができます。

if (story === "ShouldLogin") {
  it("displayed login button", () => {
    const { queryByRole, queryByText } = render(<Story />);
    expect(queryByText("logged in user")).toBeNull();
    expect(queryByRole("button", { name: "Login" })).not.toBeNull();
  });
  it("call toggle auth modal request", () => {
    const toggle = vi.fn();
    const useToggle = vi.spyOn(authStore, "useToggleAuthModalRequest");
    useToggle.mockImplementation(() => toggle);
    const { getByRole } = render(<Story />);
    expect(useToggle).toHaveBeenCalled();
    user.click(getByRole("button", { name: "Login" }));
    expect(toggle).toHaveBeenCalledTimes(1);
   });
}

テスト全体

// header.spec.tsx
import * as stories from "~/stories/header.stories";
import "@testing-library/jest-dom";
import { render } from "@testing-library/react";
import { composeStory } from "@storybook/react";
import user from "@testing-library/user-event";
import * as authStore from "~/stores/auth";
import { vi } from "vitest";

const cases: [stories: keyof typeof stories][] = [
  ["Default"],
  ["ShouldLogin"],
  ["LoggedIn"],
];

describe("Header component", () => {
  afterEach(() => {
    vi.resetAllMocks();
  });
  describe.each(cases)("%s", (story) => {
    const Story = composeStory(stories[story], stories.default);
    it("render component", () => {
      const { container } = render(<Story />);
      expect(container.firstChild).toMatchSnapshot();
    });
    if (story === "LoggedIn") {
      it("displayed user name", () => {
        const { queryByText } = render(<Story />);
        expect(queryByText("logged in user")).not.toBeNull();
      });
    }
    if (story === "ShouldLogin") {
      it("displayed login button", () => {
        const { queryByRole, queryByText } = render(<Story />);
        expect(queryByText("logged in user")).toBeNull();
        expect(queryByRole("button", { name: "Login" })).not.toBeNull();
      });
      it("call toggle auth modal request", () => {
        const toggle = vi.fn();
        const useToggle = vi.spyOn(authStore, "useToggleAuthModalRequest");
        useToggle.mockImplementation(() => toggle);
        const { getByRole } = render(<Story />);
        expect(useToggle).toHaveBeenCalled();
        user.click(getByRole("button", { name: "Login" }));
        expect(toggle).toHaveBeenCalledTimes(1);
      });
    }
  });
});

まとめ

以前はUIに対するテストは面倒だという認識をもっていましたが、StorybookやVitestのリッチな機能によって面倒な設定等をせずにUIテストを用意することができました。チーム開発は勿論の事、個人開発に於いても積極的にテストを書く習慣を身につけたいです。

Discussion