フロントエンド単体テスト入門 [vitestハンズオン]

に公開

はじめに

前半は単体テストの説明を行い、後半はハンズオン形式で簡単なヘッダー、フッターのテスト、解説を行います。ハンズオンだけ見たい方は、目次から「ハンズオン編」に飛んでご覧ください。
今回の記事では、フロントエンドの単体テストを初めて書くあるいは関心がある人向けに一般的なブログ記事のヘッダーとフッターのテストを行います。ヘッダーにおいては、auth.jsのライブラリ機能を用いたログイン機能、Next.jsのserver actionを使用して作成されているものを想定しています。フッターは一般的な作成者のリンクなどの簡単な付帯情報があるものを想定しています。
それぞれのテストの工程や記述内容を確認いただき、普通のページの単体テストやモック作成、非同期処理のテストなどをご確認いただけます。

テストとは、その種類について

テストと一口に言っても、実は目的や粒度の異なるいくつかの種類があります。フロントエンド開発における主なテストの種類を簡単に紹介していきます。

  • 単体テスト
    主にJest, vitestを使う最小単位の関数やコンポーネントのテスト。最も結果のフィードバックが早く、実装コストも低い。この「最小単位の関数やコンポーネント」が単体テストの"単体"にあたります。

  • 結合テスト
    React Testing Library等を使い、コンポーネント、hooks を組み合わせて正しく動作するかをテストする。実行速度や実装コストは単体テストとE2Eテストの中間に位置する。

  • E2Eテスト
    playwright等を使うブラウザ上でユーザーと同じユースケースを想定したシステム全体のテスト。
    実行時間が最も遅く、実装コストも他のテストと比較すると高いが、ユーザーと近い環境でテストを行うため、信頼性が高い。

何をテストするのか?

では具体的に、単体テストでは「どんなことをテストすればよいのか?」という疑問が出てくると思います。ここでは今回説明する「ヘッダー」と「フッター」のコンポーネントを例に考えてみましょう。

ヘッダーのテスト観点(ログイン機能あり)

  • ログインしていないときに「ログイン」ボタンが表示されるか

  • ログイン状態で「マイページ」などが表示されるか

  • ログインボタンを押すと signIn() 関数が呼ばれるか

  • ログアウトボタンを押すと signOut() 関数が呼ばれるか

フッターのテスト観点(リンク表示)

  • 表示される文言や年号が正しいか

  • 外部リンク(GitHubやポートフォリオなど)が正しいURLを持っているか

  • target="_blank" など安全性に配慮されているか

一通りの例を出してみました。では一体このようなテスト内容をどうやって判断し、立項するのでしょうか?内容の決め方について紹介していきます。

テスト内容の決め方

テストを書くうえで最も重要なのは、「どう動いてほしいか」と「どうなると困るか」を明確にすることです。具体的には以下の基準が挙げられます。

  • 「どう動いてほしいか」を基準にする
    まずは仕様通りに動いているかを確認します。
    たとえば先ほどのヘッダー,フッターの例の場合
    ログインしていないときに「ログイン」ボタンが表示されるか,ログイン状態で「マイページ」などが表示されるか、といった点が典型ですね。
    これは、仕様確認と正常系の動作確認です。

  • 「どうなると困るか」を基準にする
    バグにつながるようなケースや予期せぬ動作が起きないかも重要な観点です。
    たとえば先ほどのヘッダー,フッターの例の場合
    関数が呼ばれない,外部リンクが正しいURLを持っていない, 安全性に配慮されていない等々の点が挙げられます。
    このように異常系やミスの影響が大きい部分も重点的にチェックすべきです。

  • 人に説明できる仕様かどうか
    「このボタンは何のためにあるのか?」「この状態でこれが出るのは正しいか?」と聞かれて説明できるなら、それはテストする価値のあるポイントです。テスト対象の機能の必要性や実装のMECEさを再確認する機会にもなります。対象を設計する際に生まれた無駄な部分や危険を生み出しかねない脅威を事前に排除することもできるでしょう。

単体テスト ツール紹介

ここでは単体テストをする時に使うツールを紹介していきます。名前と簡単な機能だけでも覚えておくと今後役立つでしょう。

Vitest

VitestはViteと同じ開発チームが提供するテストランナーで、Vite環境に最適化されています。
テストの起動・実行が非常に高速で、開発中に何度もテストを実行するような場面でもストレスなく使えます。構文はJestと似ており、過去にJestを使ったことがある人であれば学習コストも低く済みます。
また、--watch モードによってファイルの変更を自動で検知し、即座にテストを再実行できるため、TDD(テスト駆動開発)にも非常に向いています。
https://vitest.dev/

Jest

JestはMeta(旧Facebook)製のテストフレームワークで、フロントエンドにおけるテストのデファクトスタンダードです。
特にNext.jsなどで長く利用されてきた歴史があり、Reactと非常に親和性が高く、モック機能やスナップショットテスト、タイマーテストなど多機能な点も魅力です。
ただし、設定がやや複雑で起動もVitestに比べると重めなので、大規模プロジェクトやレガシーコードを扱う場合に強みを発揮します。
https://jestjs.io/ja/

React Testing Library

React Testing Libraryは、「ユーザーの視点でUIをテストする」ことに特化したテストライブラリです。
コンポーネントの内部構造や実装ではなく、ユーザーが目にする要素の表示・操作を重視してテストを書きます。
たとえば getByRole や findByText などの直感的なAPIを使って、実際の利用シーンに近い形でバグを検出できるのが特徴です。VitestやJestと併用されることが多いです。

JestとVitestの比較

JestとVitestはどちらもJavaScript/TypeScript向けの単体テストフレームワークとして広く使われており、文法や基本的な使い方は非常によく似ています。そのため、Jestの知識があればVitestへの移行はスムーズに行うことができます。しかし、両者にはいくつかの本質的な違いがあります。
まず大きな違いは、ビルド方式と実行速度にあります。VitestはViteと同様にESM(ECMAScript Modules)ベースで動作しており、モジュールの読み込みやホットリロードの仕組みをViteからそのまま引き継いでいます。そのため、テストの起動や再実行が非常に速く、開発中のフィードバックループを短縮できるのが特徴です。対してJestは長らくCJS(CommonJS)ベースで動作してきた背景があり、大規模なコードベースにおいても安定した実行が可能ですが、その分起動がやや重たく感じられる場面があります。
また、設定ファイルのシンプルさにも違いが見られます。VitestはViteと統合される形で設定できるため、特にViteベースのプロジェクトでは導入も簡潔です。一方、Jestは柔軟で多機能な分、プロジェクトの構成によっては設定が煩雑になりやすい傾向があります。
ただし、この柔軟さが逆にJestの強みとも言え、たとえばモック機能やタイマーテスト、並列実行の制御など、大規模開発や複雑なテスト要件を満たす際にはJestの方が選ばれるケースも少なくありません。

総じて言えるのは、小規模〜中規模のモダンなフロントエンドプロジェクトでスピードと快適さを重視するならVitest、大規模開発や堅牢なテスト戦略が必要な場合にはJestが適している ということです。どちらを選ぶにしても、共通の知識が活かせるため、プロジェクトの性質やチームのニーズに応じて柔軟に選択することが重要です。

ハンズオン編

ディレクトリ構成

ディレクトリ構成は、テストのファイルを作ってまとめても良いですが、小規模(個人ブログ程度)であればコンポーネントの中に隣接して書いてもいいでしょう。ファイルを見ただけで「どのコンポーネントに対するテストか」が一目でわかる。開発とテストが一体化して色ので保守性が高い。ファイル移動やリネーム時にテストも一緒に動かしやすい。などのメリットがあります。

src/
├── components/
│   ├── Footer/
│   │   ├── Footer.tsx
│   │   └── Footer.test.tsx  ← ✅ 
│   ├── Header/
│   │   ├── Header.tsx
│   │   └── Header.test.tsx ← ✅ 

フッター

改めて、単体テストは関数、コンポーネントを一単位に分類し機能を確認する目的があります。
今回の場合はフッターというReactの一コンポーネントの機能、仕様をテストするのでUIに関するテストになります。この際Vitestにはテストを書いたのちに画面描画されているのかを確認するためのレンダリング機能がありません。よって先ほど紹介した、React testing libraryを併用します。

フッターのコードを見ていきましょう。

footer.tsx
import Link from "next/link";
import { FaGithub } from "react-icons/fa";
import { FaXTwitter } from "react-icons/fa6";
import { SiGmail } from "react-icons/si";

export default function Footer() {
    return (
      <footer className="bg-[#111827] text-white">
        <div className="container mx-auto flex justify-between items-center border-white border-b p-5">
          <h1>Navigation_bar</h1>
          <div className="flex gap-8">
            <Link href="https://github.com/hayatonanba"><SiGmail className="size-6 sm:size-8" /></Link>
            <Link href="https://github.com/hayatonanba"><FaGithub className="size-6 sm:size-8" /></Link>
            <Link href="https://x.com/hayatonanba0228"><FaXTwitter className="size-6 sm:size-8" /></Link>
          </div>
        </div>
        <small className="flex justify-center p-4">© 2024 My blog. All rights reserved.</small>
      </footer>
    );
}

このコードを読み取り、フッターで何ができて欲しいのか何が描画されていて欲しいのかを考えます。
そうすると、

  • 表記が正しく表示されるのか。
  • リンクが正しく繋がるのか。
  • 安全性があるのか

以上の3点に絞り込めそうです。では、その内容をチェックするテストコードの書き方を解説していきます。

テストの書き方

まず環境構築を行なっていきます。(ヘッダー、フッターのテスト対象はできているものとします)

はじめにVitestを導入していきます。

npm install -D vitest

次にvitest.config.tsを作ります。*すでにViteを使用している人は別の記述をします。(下に記述)

vitest.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
  },
})

既にViteを使用している人は、Vite.config.tsに上書きする形で書きます。

vite.config.ts
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'

export default defineConfig({
  test: {
    // ... Specify options here.
  },
})

次にtesting libraryをインストールしていきます。

npm install --save-dev @testing-library/react @testing-library/dom @types/react @types/react-dom
npm install --save-dev @testing-library/jest-dom

その後先ほどのテストコードを書いていきます。

footer.test.tsx
import React from "react";
import { render, screen } from "@testing-library/react";
import Footer from "./footer";
import { describe, it, expect } from "vitest";
import "@testing-library/jest-dom"; 

describe("Footer", () => {
  it("ロゴテキストが表示されていること", () => {
    render(<Footer />);
    expect(screen.getByText("Navigation_bar")).toBeInTheDocument();
  });

  it("著作権表記が表示されていること", () => {
    render(<Footer />);
    expect(
      screen.getByText(/© 2024 My blog\. All rights reserved\./)
    ).toBeInTheDocument();
  });

  it("Gmailのリンクが存在し、正しいURLを持つこと", () => {
    render(<Footer />);
    const links = screen.getAllByRole("link");
    expect(links[0]).toHaveAttribute("href", "https://github.com/hayatonanba"); // Gmail
  });

  it("GitHubのリンクが存在し、正しいURLを持つこと", () => {
    render(<Footer />);
    const links = screen.getAllByRole("link");
    expect(links[1]).toHaveAttribute("href", "https://github.com/hayatonanba");
  });

  it("Xのリンクが存在し、正しいURLを持つこと", () => {
    render(<Footer />);
    const links = screen.getAllByRole("link");
    expect(links[2]).toHaveAttribute("href", "https://x.com/hayatonanba0228");
  });
});

コードの詳細を説明します。

describe("Footer", () => {...}

の部分でテストをグループ化します。今回は "Footer" コンポーネントで行うテストをまとめています。
次に中身で先ほど3点に絞り込んだテスト内容を記述していきます。まず一個目を書いていきます。

  it("ロゴテキストが表示されていること", () => {
    render(<Footer />);
    expect(screen.getByText("Navigation_bar")).toBeInTheDocument();
  });

it(...)で個別のテストケースを定義します。"〜こと"という文言で「〇〇が期待される動作であること」を表します。

次に render(<Footer />)はテスト対象の Footer コンポーネントを仮想的にレンダリングします。 加えて、screen.getByText("Navigation_bar")で画面上のテキスト "Navigation_bar" を取得 しています。ここで記述が違うなどの間違いがあるとテストに失敗します。これらの部分はReact testing libraryの部位に由来しています。

.toBeInTheDocument()では、その要素が実際にDOMに存在するかを検証します。こちらはjest-domに由来します。

次にリンクの確認テストの記述方法を書いていきます。

 it("GitHubのリンクが存在し、正しいURLを持つこと", () => {
    render(<Footer />);
    const links = screen.getAllByRole("link");
    expect(links[1]).toHaveAttribute("href", "https://github.com/hayatonanba");
  });

it(...)とrender(<Footer />)は先ほどと同じです。
screen.getAllByRole("link")で、すべての <a> 要素(=リンク)を配列で取得しています。先ほどはテキスト(.getByText)だったのが、.getByRoleではボタンやリンクなどの役割が正しく表示・機能しているか?を確かめたいというわけです。その後、expect(links[1]).toHaveAttribute("href","https://github.com/hayatonanba" )で配列の順番を指定しhref 属性が正しいかを検証します。 配列の順番はテスト対象の記述に依拠しています。今回の場合、Gmailはlinks[0],Xはlink[2]にあたります。

 <div className="flex gap-8">
            <Link href="https://github.com/hayatonanba"><SiGmail className="size-6 sm:size-8" /></Link>
            <Link href="https://github.com/hayatonanba"><FaGithub className="size-6 sm:size-8" /></Link>
            <Link href="https://x.com/hayatonanba0228"><FaXTwitter className="size-6 sm:size-8" /></Link>
          </div>

それではテストしてみましょう!(package.json内の "script"内に"test": vitest を足しておきましょう。)

npm run test

以上のような挙動のテストコードによって先ほど絞り込んだ三つのテスト要件である

  • 表記が正しく表示されるのか。
  • リンクが正しく繋がるのか。
  • 安全性があるのか

を満たすことができました。特にリンクと簡単な書き間違いをここで是正できるといいですね。
安全性をさらに追求したい場合、単体テストのみで完結するものでない場合があるので注意が必要です。
(ex. XSS(スクリプト埋め込み)への耐性は単体テストでは直接カバー不可。静的解析やE2Eテストが必要)

ヘッダー

次もReactの一コンポーネントの機能、仕様をテストします。加えて、先ほど紹介した、React testing libraryも併用します。
今回はログイン状態に応じて「ログイン」「ログアウト」ボタンが切り替わるか。ボタンをクリックすると signIn や signOut が正しく呼ばれるか。といった点を特にチェックしていきます。
ヘッダーのコードを見ていきましょう。

header.tsx
import { auth, signIn, signOut } from "@/auth";

export default async function Header() {
  const session = await auth()

  return (
    <header className="h-[70px] border-b">
      <div className="container mx-auto h-full flex items-center justify-between px-3">
        <h1 className="text-[1.5rem] font-bold">🛹 my_blog</h1>
        {!session && <form
          action={async () => {
            "use server"
            await signIn("github")
          }}
        >
          <button
            type="submit"
            className="bg-yellow-300 py-1 px-3 rounded-full font-bold 
             hover:bg-yellow-400 hover:shadow-md 
             transition-all duration-200 ease-in-out 
             active:scale-95"
          >ログイン</button>
        </form>}

        {session &&<form
          action={async () => {
            "use server"
            await signOut()
          }}
        >
          <button
            type="submit"
            className="bg-yellow-300 py-1 px-3 rounded-full font-bold 
             hover:bg-yellow-400 hover:shadow-md 
             transition-all duration-200 ease-in-out 
             active:scale-95"
          >ログアウト</button>
        </form>}
      </div>
    </header>
  );
}

今回のヘッダーはタイトル以外にもauth.jsの使用と非同期処理、server actionを用いたログイン機能が内蔵されています。その他コードを読み取ると先ほどの記述と重なりますが、

  • ログインしていないときに「ログイン」ボタンが表示されるか

  • ログイン状態で「マイページ」などが表示されるか

  • ログインボタンを押すと signIn() 関数が呼ばれるか

  • ログアウトボタンを押すと signOut() 関数が呼ばれるか

  • タイトル表記が正しいか

以上がテスト要件として十分でしょう。早速テストコードを書いていきましょう。

テストの書き方

header.test.tsx
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import "@testing-library/jest-dom";
import { auth, signIn, signOut } from "../../auth";
import Header from "./header";

vi.mock("../../auth", async () => {
  return {
    auth: vi.fn(),
    signIn: vi.fn(),
    signOut: vi.fn(),
  };
});

describe("Header", () => {
  it("ログインしていないときに『ログイン』ボタンが表示される", async () => {
    (auth as any).mockResolvedValue(null);
    render(await Header());
    expect(screen.getByRole("button", { name: "ログイン" })).toBeInTheDocument();
  });

  it("ログインしているときに『ログアウト』ボタンが表示される", async () => {
    (auth as any).mockResolvedValue({ user: { name: "test-user" } });
    render(await Header());
    expect(screen.getByRole("button", { name: "ログアウト" })).toBeInTheDocument();
  });

  it("ログインボタンを押すと signIn() が呼ばれる", async () => {
    (auth as any).mockResolvedValue(null);
    const mockSignIn = signIn as unknown as ReturnType<typeof vi.fn>;
    render(await Header());
    const loginButton = screen.getByRole("button", { name: "ログイン" });
    fireEvent.click(loginButton);
    // server action の中で呼ばれるので非同期待ち
    await waitFor(() => {
      expect(mockSignIn).toHaveBeenCalledWith("github");
    });
  });

  it("ログアウトボタンを押すと signOut() が呼ばれる", async () => {
    (auth as any).mockResolvedValue({ user: { name: "test-user" } });
    const mockSignOut = signOut as unknown as ReturnType<typeof vi.fn>;
    render(await Header());
    const logoutButton = screen.getByRole("button", { name: "ログアウト" });
    fireEvent.click(logoutButton);
    await waitFor(() => {
      expect(mockSignOut).toHaveBeenCalled();
    });
  });
});

コードの詳細を解説していきます。describe("Footer", () => {...} ,it(...), render(<Header />)は先ほどと同じです。しかし、ここで新しい要素であるモック(mock)が登場します。モックはデータベースやライブラリなどの外部要素を仮置きすることをいいます。今回はAuth.jsを仮置きしています。記述は以下のとおりです。vi.mockと書き対象をダミー関数に置換します。

vi.mock("@/auth", async () => {
  return {
    auth: vi.fn(),
    signIn: vi.fn(),
    signOut: vi.fn(),
  };
});

次に基本記述の紹介です。

(auth as any).mockResolvedValue(null);
render(await Header());
expect(screen.getByRole("button", { name: "ログイン" })).toBeInTheDocument();

ここではユーザー情報がない時はログインされていない画面、つまりヘッダーにログインと書いてあればいいわけです。まずauth()関数をnull(ユーザーなしの意)でモックします。そしてログインボタンが表示されることを検証します。フッターの時にもあった、screenと.toBeInTheDocument()がありますね。screen.getByRoleもフッターのgetByAllRoleを個別に取得するようになっただけです。

サイトにユーザーがログインしている際のテストも体系は同じです。

(auth as any).mockResolvedValue({ user: { name: "test-user" } });
render(await Header());
expect(screen.getByRole("button", { name: "ログアウト" })).toBeInTheDocument();

次にログインボタン押すとsignIn関数が呼ばれるかどうかテストするコードです。

it("ログインボタンを押すと signIn() が呼ばれる", async () => {
    (auth as any).mockResolvedValue(null);
    const mockSignIn = signIn as unknown as ReturnType<typeof vi.fn>;
    render(await Header());
    const loginButton = screen.getByRole("button", { name: "ログイン" });
    fireEvent.click(loginButton);
    // server action の中で呼ばれるので非同期待ち
    await waitFor(() => {
      expect(mockSignIn).toHaveBeenCalledWith("github");
    });
  });

(auth as any).mockResolvedValue(null);は同じですね。ユーザーがサイトにいない、ログインしていない時という意です。その状態でヘッダーコンポーネントを render(await Header()) によって仮想DOM上に描画します。Header() は async コンポーネントなので、await を使って中の auth() の結果が反映された状態で描画されるようにしています。
次に、画面に表示された「ログイン」ボタンを screen.getByRole("button", { name: "ログイン" }) を使って取得します。これは先ほどのボタン描画のテストと同じですね。
続いて fireEvent.click(loginButton); によって、ユーザーがそのボタンをクリックしたことをシミュレートします。今回のコードではボタンを囲む <form> タグの action 属性に非同期の server action が仕込まれており、クリックによって signIn("github") が実行されるようになっています。
ここで重要なのが 、await waitFor(() => { expect(mockSignIn).toHaveBeenCalledWith("github"); }); の部分です。通常の expect() では、非同期処理が完了する前にチェックが走ってしまい、「まだ呼ばれていない」と判断されてテストが失敗することがあります。waitFor() を使えば、指定された条件が一定時間内に満たされるかどうかを繰り返し確認してから合格判定を出すため、非同期で実行される signIn() の呼び出しを正しくキャッチできます。
このように、ユーザーが未ログイン状態のときに「ログイン」ボタンが表示され、ボタンを押すことで signIn("github") が確実に呼ばれるというUIとロジックの連携が、ひとつのテストケースで検証できるようになっています。

サインアウトの場合でもほとんど同じです。

it("ログアウトボタンを押すと signOut() が呼ばれる", async () => {
    (auth as any).mockResolvedValue({ user: { name: "test-user" } });
    const mockSignOut = signOut as unknown as ReturnType<typeof vi.fn>;
    render(await Header());
    const logoutButton = screen.getByRole("button", { name: "ログアウト" });
    fireEvent.click(logoutButton);
    await waitFor(() => {
      expect(mockSignOut).toHaveBeenCalled();
    });
  });

それではテストしてみましょう!

npm run test

以上のような挙動のテストコードによって先ほど絞り込んだテスト要件である

  • ログインしていないときに「ログイン」ボタンが表示されるか
  • ログイン状態で「マイページ」などが表示されるか
  • ログインボタンを押すと signIn() 関数が呼ばれるか
  • ログアウトボタンを押すと signOut() 関数が呼ばれるか
  • タイトル表記が正しいか

を満たすことができました。以上でテストは完了です!多少記述が複雑でしたが噛み砕いて考えると開発する時に考えるときの流れとあまり変わらないので、慣れてくると徐々に書けるようになっていくでしょう!!

最後に

最後までお読みいただきありがとうございました!!
一分野でこんなに長く記事を書いたのは初めてです。
これからも技術系の記事を投稿していきます。今後さらに読みやすさを意識して書いていきます!

Discussion