乗りこなせ! モダンフロントエンド

モダンなフロントエンドにおけるテストについて
[Modern Featured Test]

本連載について

はじめまして! サイボウズ フロントエンドエキスパートチームのnus3です。

本連載では、Webフロントエンドに関してもう一歩踏み込んだ知識について、サイボウズフロントエンドエキスパートチームのメンバーによって不定期で解説記事を掲載しています。

本記事では、モダンフロントエンドにおけるテストについて、その種類や導入方法などを紹介します[1]

モダンフロントエンドでのテスト

ここ数年のWebフロントエンドでは次のように、さまざまな変化がありました。

  • ReactやVue.jsといった宣言的UIを採用したライブラリの普及
  • TypeScript中心としたエコシステムの発展
  • ES Modulesの採用の広がり
  • ViteやSWC、RspackやTurbopackなどの新しいビルドツールの登場

この変化に合わせて、Webフロントエンドを対象にしたテストのツールや手法も増えてきました。

特に筆者が大きく変わったと感じているのは、UIコンポーネントを対象にしたツールや手法が増えた点です。これは、宣言的UIを採用したライブラリの普及によって、コンポーネントベースでの開発が主流になったことが大きな要因だと考えています。テスト対象にUIコンポーネントが増えたことで、プロダクトやプロジェクトの状況に合わせたテスト手法の幅が広がりました。

また、Vitestのようにビルドツールと同じ条件でテストを実行できるテストフレームワークや、DenoやBunのようにテストを実行する機能をビルトインで持つランタイムが登場しました。これにより、テストのための環境構築を整えるコストも大きく軽減され、テストを導入するハードルは下がったように感じています。

本記事では、これらのモダンフロントエンドの変化を踏まえ、具体的に次のようなケースのテストを取り上げていきます。

  • 主なテストフレームワークでのテスト例
  • UIコンポーネントを対象にしたテスト

主なテストフレームワークでのテスト例

Webフロントエンドの開発では、TypeScriptが一般的に使われるようになってきました。TypeScriptで書かれたコードは、事前にJavaScriptにトランスパイルされ、Node.js[2]やブラウザなどのランタイムで実行されます。これは、テストでも同様で、TypeScriptで書かれた関数をテストする場合、JavaScriptにトランスパイルするしくみがテストフレームワークにも必要になります。

簡単な数値を加算する関数と、そのテストを例に、各フレームワークの特徴を確認してみましょう。

Jest

JestはBabelやTypeScript、ReactやVueといった多くのライブラリに対応したテストフレームワークです。npm trendsを見ると、ライブラリの更新頻度は少なくなってきたものの、長く運用され、多くのプロジェクトでダウンロードされていることがわかります。

Jestのnpm trendsのStats

JestはデフォルトだとNode.js環境で実行されるため、TypeScriptで書かれた関数をテストする場合、事前にJavaScriptにトランスパイルする必要があります。

Jestの設定にはtransformオプションがあり、対応するライブラリ(トランスフォーマー)を指定することで、テスト対象やテストコードを実行前にトランスパイルできます。TypeScriptの場合においてもこれを用います。

指定できるライブラリには次のようなものがあります。

次の関数とテストを例に、それぞれの導入方法や特徴を見ていきましょう。

export function add(a: number, b: number): number {
  return a + b;
}
import { add } from "./calculator";

test("引数に渡した二つの数値を合算した値を返す", () => {
  expect(add(1, 2)).toBe(3);
  expect(add(-1, 1)).toBe(0);
  expect(add(5, 5)).toBe(10);
});

package.jsonにはJestを実行するためのnpm scriptsを追加しておきましょう。

{
  "scripts": {
    "test": "jest"
  }
}

Babel

BabelにはJest用のプラグインbabel-jestと、TypeScript用のプリセット@babel/preset-typescriptがあり、これらを組み合わせることで、Jestでのテスト実行時にTypeScriptで書かれた関数をBabelの設定に従ってトランスパイルし、JavaScriptとして実行できます。

@babel/preset-typescriptではテスト実行時に型チェックは行われないので、型チェックを行いたい場合は、別途TypeScriptのコンパイラtscを使って型チェックを行う必要があります。

導入方法としては、まず必要なライブラリをインストールします。

# JestとTypeScriptのインストール
npm install -D typescript jest @types/jest

# Babelに必要なライブラリのインストール
npm install -D @babel/core @babel/preset-env @babel/preset-typescript babel-jest

次に、Babelの設定ファイルbabel.config.jsonを作成し、TypeScriptのプリセットを指定します。

{
  "presets": [
    ["@babel/preset-env", { "targets": { "node": "current" } }],
    "@babel/preset-typescript"
  ]
}

最後にJestの設定ファイルjest.config.jsを作成しtransformbabel-jestを指定することで、Jest実行時にBabelを使ってTypeScriptのトランスパイルをします。

/** @type {import('jest').Config} */
module.exports = {
  transform: {
    "^.+\\.ts$": "babel-jest",
  },
};

ts-jest

ts-jestはJestで使えるTypeScript用のトランスフォーマーです。ts-jestはBabelの@babel/preset-typescriptとは異なり、テストの実行時に合わせて型チェックを行うのが特徴です。

導入方法としては、まず必要なライブラリをインストールします。

# JestとTypeScriptはインストールしている前提
npm install -D ts-jest

次にJestの設定ファイルjest.config.jsを作成し、transformts-jestを指定することで、Jest実行時にTypeScriptのトランスパイルをします。

/** @type {import('jest').Config} */
module.exports = {
  transform: {
    "^.+\\.ts$": "ts-jest",
  },
};

@swc/jest

@swc/jestSWCをトランスパイラーとして使うJest用のトランスフォーマーです。SWCはRustで書かれたコンパイラーで、高速な処理が特徴であり、公式ドキュメントにも次のように書かれています。

SWC is 20x faster than Babel on a single thread and 70x faster on four cores.

この記載のように、SWCをベースにした@swc/jestはトランスパイル速度の向上により、Babelやts-jestに比べてテストの実行時間が短縮されることが期待できます。

また、@swc/jest@babel/preset-typescriptと同様に、テストの実行時に型チェックを行いません。

導入方法としては、まず必要なライブラリをインストールします。

# JestとTypeScriptはインストールしている前提
npm install -D @swc/core @swc/jest

次にJestの設定ファイルjest.config.jsを作成しtransform@swc/jestを指定することで、Jest実行時にSWCを使ってTypeScriptのトランスパイルをします。

/** @type {import('jest').Config} */
module.exports = {
  transform: {
    "^.+\\.ts$": "@swc/jest",
  },
};

テスト実行時間の比較

同じテストを対象に、Babel、ts-jest@swc/jestを使って手元でテストを実行すると、それぞれ実行時間は次のようになりました。

トランスフォーマー 実行時間
Babel 8.966 秒
ts-jest 7.055 秒
@swc/jest 1.239 秒

環境や条件によって実行時間は変わると思いますが、@swc/jestの特徴にあるように、ほか2つのトランスフォーマーに比べてテストの実行時間が短縮されていることがわかります。

Vitest

Vitestは、フロントエンドのビルドツールであるViteネイティブなテストフレームワークです。⁠Viteネイティブ」と言及したとおり、筆者が思うVitestの一番の特徴は、アプリケーションのビルドツールとしてViteを採用していた場合、その設定をテストでもそのまま使える点です。簡単にアプリケーションと同じ条件でテスト対象のコードをビルドし、テストを実行できます。

また、後発なテストフレームワークということもあり、Vitestでは、Jestと互換性のあるAPIの提供や、ES Modulesファースト、TypeScriptやJSXのサポート、ブラウザ上でテストが実行できるBrowser Modeといった昨日や特徴があります。

Jestと比較した際にVitestは後発のテストフレームワークであり、Jestの良い点を継承しつつ、TypeScriptサポートやESM対応といった課題であった点が解消されていることなどから、Vitestを採用するケースも増えてきているように感じています。npm trendsからもViteを採用するプロジェクトが増えていることが伺えます。

Vitest内部でViteに依存しており、ViteではTypeScriptのトランスパイルが機能として組み込まれているので、TypeScriptで書かれた関数をテストする場合のみであれば、特に設定を行う必要はありません。

導入方法としては、必要なライブラリをインストールするだけです。

npm install -D typescript vitest

Vitestのデフォルトでは.test..spec.がファイル名に含まれるファイルをテストファイルとして認識するので、calculator.test.tsファイルを作成します。

import { test, expect } from "vitest";
import { add } from "./calculator";

test("引数に渡した二つの数値を合算した値を返す", () => {
  expect(add(1, 2)).toBe(3);
  expect(add(-1, 1)).toBe(0);
  expect(add(5, 5)).toBe(10);
});

最後にテスト用のnpm scriptsを追加して実行することで、TypeScriptで書かれた関数をVitestでテストできます。

{
  "scripts": {
    "test": "vitest"
  }
}

Rstest

余談にはなりますが、Vitestと同様にビルドツールネイティブなテストフレームワークとして、Rstestの開発[3]が進められています。

GitHubのリポジトリにあるREADMEに記載されている内容を読むと、次のような特徴があるようです。

  • Rspackをベースとしたテストフレームワーク
  • Jest互換のAPIを提供
  • TypeScriptやES Modulesのネイティブサポート

RspackはRustで実装されたバンドラーで、webpackとの高い互換性が特徴です。

Rstestはまだ開発段階ですが、ViteとVitestのように、Rspackを使ったアプリケーションに対して、同じ条件でテストを実行できるテストフレームワークとして、今後の動向が楽しみです。

ロードマップには2025年6月中にプレビューバージョンをリリースし、2025年中には正式リリースを計画していることが記載されています。

DenoとBun

DenoBunのように、テストランナーをビルトインで持つランタイムも登場しています。どちらもTypeScriptをネイティブでサポートしています。

これら2つのランタイムでは、どちらもTypeScriptをネイティブでサポートしています。また、テストを実行するのにライブラリのインストールや設定は不要で、それぞれbun testdeno testを実行するだけでTypeScriptの関数をテストできます。

Denoでは、Deno.testを使うことでテストを定義できます。

// Denoの標準ライブラリから、テスト用のアサーション関数をインポート
import { assertEquals } from "@std/assert";
// Denoの標準ライブラリから、Jestライクのexpect APIをインポート
import { expect } from "jsr:@std/expect";
import { add } from "./main.ts";

// Deno.testでテストケースを定義し、アサート
Deno.test("add", () => {
  assertEquals(add(2, 3), 5);
});

Deno.test("add", () => {
  const result = add(2, 3);
  // Jestのような記法も利用できる
  expect(result).toBe(5);
});

Bunでは、Jest互換のテストランナーがビルトインされており、定義方法もJestと同様の記法が使えます。

// Bun組み込みのアサーション関数をインポート
import { expect, test } from "bun:test";
import { add } from "./main.ts";

// Jest互換のtest関数でテストケースを定義
test("add", function addTest() {
  expect(add(2, 3)).toBe(5);
});

UIコンポーネントを対象にしたテスト

ReactやVue.jsといった宣言的UIを採用したライブラリの普及によって、コンポーネントベースでの開発が主流になりました。この変化に伴い、先述のテストフレームワークと組み合わせてUIコンポーネントを対象にしたテストを導入するケースも増えてきました。

ここでは、次のようなReactで実装したフォームコンポーネントを例に、どういったライブラリを使い、どのようなテストができるか見ていきましょう。

import { type FormEvent, useState, type FC } from "react";

export type FormProps = {
  onSubmit: (name: string) => void;
};

export const Form: FC<FromProps> = ({ onSubmit }) => {
  const [name, setName] = useState("");

  // Name変更時のイベントハンドラー。入力値をstateに保持する
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setName(e.target.value);
  };

  // サブミット時のイベントハンドラー。入力値をonSubmitに渡す
  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    onSubmit(name);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" type="text" value={name} onChange={handleChange} />
      <button type="submit">送信</button>
    </form>
  );
};

Node.js環境でUIコンポーネントをテストする

JestやVitestなどのテストフレームワークとjsdomhappy-domを組み合わせることで、Node.js環境にもかかわらず、ブラウザ上でUIコンポーネントの表示や操作をしたかのようにテストを実行できます。

jsdomhappy-domといったライブラリでは、DOMHTMLなどのブラウザ上で実行できるWeb APIをエミュレートします。これによりNode.js環境でもブラウザと同じようにDOM操作や、イベントをシミュレートできます。

実際に、Vitestとjsdomを組み合わせて、Reactで実装したフォームコンポーネントをテストしてみましょう。

Vitestでjsdomを使う

Vitestではテストで使用する環境をenvironmentオプションで指定できます。デフォルトではNode.js環境で実行されますが、このほかにjsdomhappy-domedge-runtimeの環境を指定できます。

導入方法としては、まず必要なライブラリをインストールします。

# React関連
npm install react react-dom
npm install -D @types/react @types/react-dom

# Vite関連
npm install -D vite @vitejs/plugin-react

# Vitestとテスト関連
npm install -D vitest @testing-library/react jsdom @testing-library/user-event

Viteの設定ファイルであるvite.config.tsを作成し、Reactプラグインの追加と、Vitestでのテストの実行にjsdomを指定します。

/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  // Reactプロジェクト用のViteプラグインを追加
  plugins: [react()],
  test: {
    // describe、test、expectなどをインポートせずに(グローバルAPI)使用するかどうか
    globals: true,
    // テストに使用する環境
    environment: "jsdom",
    // テスト実行前に実行されるセットアップファイルを指定
    setupFiles: "./setup.ts",
  },
});

@testing-library/reactを使う場合、Vitestのglobalsオプションを有効にすることで、自動的にcleanupされますglobalsオプションを使わずに次のようにテスト実行後にcleanupを行う処理をsetupFilesに追加することで同様のことができます。そのため、globalsオプションを使うかどうかは、ドキュメントに記載されているとおり、グローバルなAPIとして扱うかどうかプロジェクトの方針に合わせて選択するのが良いでしょう。

import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";

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

次にテストファイルを作成し、フォームコンポーネントに対して次のようなテストを実行します。

  1. フォームコンポーネントを描画
  2. name用のinput要素に値を入力
  3. 送信ボタンをクリック
  4. submit時に入力された値が渡されていることを確認
import { describe, test, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Form } from "./Form";

describe("Form", () => {
  test("フォームに名前を入力して送信ボタンをクリックすると、onSubmitが呼ばれる", async () => {
    const onSubmit = vi.fn();
    // 1. フォームコンポーネントを描画
    render(<Form onSubmit={onSubmit} />);

    // 2. name 用の input 要素に値を入力
    const nameField = screen.getByRole("textbox", { name: "Name" });
    await userEvent.type(nameField, "nus3");

    // 3. 送信ボタンをクリック
    const submitButton = screen.getByRole("button", { name: "送信" });
    await userEvent.click(submitButton);

    // 4. submit 時に入力された値が渡されていることを確認
    expect(onSubmit).toHaveBeenCalledWith("nus3");
  });
});

このように、Vitestとjsdomを組み合わせることでNode.js環境でテストが完結するため、UIコンポーネントを対象にしたテストの導入や実行のハードルが低くなります。

JSXのトランスパイル

Reactでは、UIコンポーネントを実装する際にJavaScriptの拡張であるJSXを使います。これまで紹介してきたJestやVitestといったテストフレームワークでJSXを対象にテストを実行する場合、JSXをJavaScriptにトランスパイルする必要があります。

Vitestでは、ベースにあるViteでJSXのトランスパイルがサポートされており、さらにVite公式のプラグインである@vitejs/plugin-reactを組み合わせて使うことで、テスト実行時にもReactに適したビルドを行います。

Jestでは、本記事で紹介した次の3つのトランスフォーマーでそれぞれJSXのトランスパイルをサポートしています。

ブラウザ環境でUIコンポーネントをテストする

jsdomhappy-domといったライブラリを使い、Node.js環境で完結したUIコンポーネントのテストはテストを導入、実行するまでのハードルが低くなるメリットがある反面、あくまでブラウザをエミュレートしたものに過ぎないため、実際のブラウザ上での動作を保証するものではありません。

たとえば、次のようにまったく同じ位置に2つのボタンが重なって配置されているコンポーネントがあるとします。

import { type FC } from "react";

export type OverlappingButtonsProps = {
  // 背面ボタンをクリックしたときのイベントハンドラ
  onBackButtonClick: () => void;
  // 前面ボタンをクリックしたときのイベントハンドラ
  onFrontButtonClick: () => void;
};

export const OverlappingButtons: FC<OverlappingButtonsProps> = ({
  onBackButtonClick,
  onFrontButtonClick,
}) => {
  return (
    <div style={{ position: "relative" }}>
      {/* 背面に配置されるボタン(z-index: 1で下に表示) */}
      <button
        onClick={onBackButtonClick}
        style={{
          position: "absolute",
          top: "10px",
          left: "10px",
          zIndex: 1,
        }}
      >
        背面ボタン
      </button>
      {/* 前面に配置されるボタン(z-index: 2で上に表示) */}
      <button
        onClick={onFrontButtonClick}
        style={{
          position: "absolute",
          top: "10px",
          left: "10px",
          zIndex: 2,
        }}
      >
        前面ボタン
      </button>
    </div>
  );
};

このコンポーネントに対して、次のように前面、背面それぞれのボタンをクリックするテストをjsdom環境で実行した場合、テストは成功します。

import { describe, test, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { OverlappingButtons } from "./OverlappingButtons";

describe("OverlappingButtons (jsdom環境)", () => {
  test("前面ボタンをクリック", async () => {
    // クリック時のイベントハンドラ用のモック関数を作成
    const onBackButtonClick = vi.fn();
    const onFrontButtonClick = vi.fn();

    // まったく同じ位置に 2 つのボタンが重なって配置されているコンポーネントをレンダリング
    render(
      <OverlappingButtons
        onBackButtonClick={onBackButtonClick}
        onFrontButtonClick={onFrontButtonClick}
      />
    );

    // 前面ボタンをクリック
    const frontButton = screen.getByRole("button", { name: "前面ボタン" });
    await userEvent.click(frontButton);

    // 前面ボタンのクリックイベントが呼ばれ、背面ボタンのクリックイベントは呼ばれないことを確認
    expect(onFrontButtonClick).toHaveBeenCalledTimes(1);
    expect(onBackButtonClick).not.toHaveBeenCalled();
  });

  test("背面ボタンをクリック", async () => {
    const onBackButtonClick = vi.fn();
    const onFrontButtonClick = vi.fn();

    render(
      <OverlappingButtons
        onBackButtonClick={onBackButtonClick}
        onFrontButtonClick={onFrontButtonClick}
      />
    );

    // 背面ボタンをクリック
    const backButton = screen.getByRole("button", { name: "背面ボタン" });
    await userEvent.click(backButton);

    // 背面ボタンのクリックイベントが呼ばれ、前面ボタンのクリックイベントは呼ばれないことを確認
    expect(onBackButtonClick).toHaveBeenCalledTimes(1);
    expect(onFrontButtonClick).not.toHaveBeenCalled();
  });
});

しかし、実際のブラウザ上でこのコンポーネントを表示した場合、背面に配置されているボタンはクリックできません。これは、jsdomが要素の重なり(z-index)やレンダリングの詳細を完全には再現できないためです。このように、jsdom環境でのテストでは検出できない問題が、実際のブラウザ環境では発生する可能性があります。

したがってNode.js環境で実行したテストに比べて、実際にブラウザ上でUIコンポーネントをテストすることは、ブラウザ上での動作を保証するという点が大きなメリットです。しかし、それ以外にも、たとえば操作手順が多いコンポーネントのテストを実際にブラウザ上で視覚的に確認できる点もNode.js環境でのテストにはないメリットだと筆者は考えています。

最近では、ブラウザ環境でUIコンポーネントをテストする機能を提供するライブラリやツールが増えてきたので、実際にいくつか見ていきましょう。

Vitest Browser Mode

ブラウザ環境でUIコンポーネントをテストするために、VitestではBrowser Modeという機能が実験的に提供されています。

Vitestの設定でプロバイダにPlaywrightWebdriverIOを指定することで、Vitestで実行するテストがブラウザ上で実行されます。

導入方法としては、まず必要なライブラリをインストールします。

npm install -D vitest @vitest/browser
# Browser ModeでReactコンポーネントをレンダリング
npm install -D vitest-browser-react

# CIでもVitest Browser Modeを実行する場合はプロバイダもインストールする
# 今回はPlaywrightを使う
npm install -D playwright

VitestでBrowser Modeを使う方法はいくつかあり、Viteの設定やVitest用の設定ファイルで指定しても良いですが、今回はVitestのTest Projects機能を使って、次のように異なる設定でUIコンポーネントのテストを実行するようなプロジェクトを作成します。

  • *.node.{test,spec}.tsであればNode.js環境でテストを実行
  • *.browser.{test,spec}.tsであればブラウザ環境jsdomでテストを実行

vitest.config.tsを新しく作成し、上記の設定を追加します。

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    projects: [
      {
        // Node.js環境でテストを実行するプロジェクト
        test: {
          // プロジェクト名
          name: "node",
          globals: true,
          environment: "jsdom",
          // Node.js環境で実行するテストのファイル名が、xx.node.test.tsやxx.node.test.tsxのものを対象にする
          include: ["**/*.node.{test,spec}.{ts,tsx}"],
        },
      },
      {
        // ブラウザ環境でテストを実行するプロジェクト
        test: {
          name: "browser",
          // ブラウザ環境で実行するテストのファイル名が、xx.browser.test.tsやxx.browser.test.tsxのものを対象にする
          include: ["**/*.browser.{test,spec}.{ts,tsx}"],
          browser: {
            enabled: true,
            // 使用するブラウザの設定
            instances: [{ browser: "chromium" }],
            // 使用するプロパイダ
            provider: "playwright",
          },
        },
      },
    ],
  },
});

このTest Projects機能を使うことで、指定したプロジェクトごとにテストを実行することもできます。

{
  "scripts": {
    // projectで定義したすべてのプロジェクトを実行
    "test": "vitest",
    // Node.js環境でテストを実行するプロジェクトを実行
    "test:node": "vitest --project node",
    // ブラウザ環境でテストを実行するプロジェクトを実行
    "test:browser": "vitest --project browser"
  }
}

次にブラウザ環境で実行するテストファイルForm.browser.test.tsxを作成します。

import { describe, test, expect, vi } from "vitest";
import { render } from "vitest-browser-react"; // ①
import { page, userEvent } from "@vitest/browser/context"; // ②
import { Form } from "./Form";

describe("Form", () => {
  test("フォームに名前を入力して送信ボタンをクリックすると、onSubmitが呼ばれる", async () => {
    const onSubmit = vi.fn();
    render(<Form onSubmit={onSubmit} />);

    // 入力フィールドを取得
    const nameField = page.getByRole("textbox", { name: "Name" });

    // 入力フィールドにテキストを入力
    await userEvent.type(nameField, "nus3");

    // 送信ボタンをクリック
    const submitButton = page.getByRole("button", { name: "送信" });
    await userEvent.click(submitButton);

    // onSubmit関数が正しい引数で呼ばれたか確認
    expect(onSubmit).toHaveBeenCalledWith("nus3");
  });
});

このテストファイルではVitestのBrowser ModeでReactコンポーネントをレンダリングし、実際のブラウザ上でイベントを実行するために次のライブラリを使います。

  1. vitest-browser-react
  2. @vitest/browser/context

jsdomを使ったUIコンポーネントのテストではイベントをシミュレートするのに@testing-library/user-eventを使いましたが、VitestのBrowser Modeでは実際にブラウザ上でイベントを実行するために、@vitest/browser/contextが提供するuserEventを使います。

最後にBrowser Modeでテストを実行することで、実際のブラウザ上でUIコンポーネントをテストされていることを確認できます。

npm run test:browser
VitestのBrowser Modeを使うことで、実際のブラウザ上でコンポーネントのテストが実行される

Storybook

Storybookは、UIコンポーネントを開発するためのツールで、Storyと呼ばれるコンポーネントの状態を定義することで、Storybook(ブラウザ)上にコンポーネントが描画されます。Storyを定義しておけば、アプリケーション全体を起動せずとも、UIコンポーネント単位で開発ができます。

このStorybookではUIコンポーネントをブラウザ上でテストする、次のような機能を提供しています。

Storybookの導入方法と、Storybookが提供する機能をそれぞれ見ていきましょう。

Storybookの導入

Storybookの導入方法としては、まず必要なライブラリをインストールします。

npm install -D storybook @storybook/react-vite

次に、Storybookの設定ファイル.storybook/main.tsを作成します。ここでは、ReactとViteを使ったプロジェクトを想定しています。

import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  // srcディレクトリ以下の.stories.拡張子のファイルを対象にする
  stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
  framework: {
    // ReactとViteを使ったプロジェクト
    name: "@storybook/react-vite",
    options: {},
  },
};
export default config;

そして、Storybook上に描画したいUIコンポーネントのStoryを作成します。今回は、先ほど作成したフォームコンポーネントを例に、Form.stories.tsxを作成します。

import type { Meta, StoryObj } from "@storybook/react-vite";
import { Form } from "./Form";

// Storybook用のメタデータの設定
const meta = {
  title: "Components/Form",
  // Storybook上で描画するコンポーネント
  component: Form,
  parameters: {
    // Storybook上のコンポーネントを描画するレイアウトの設定
    layout: "centered",
  },
} satisfies Meta<typeof Form>;

export default meta;
type Story = StoryObj<typeof meta>;

// Story
export const Default: Story = {
  // Formコンポーネントに渡すpropsをargsとして定義
  args: {
    onSubmit: (name) => {
      console.log(`Submitted name: ${name}`);
    },
  },
};

最後に、Storybookを起動するためのnpm scriptsを追加し、実行します。

{
  "scripts": {
    "storybook": "storybook dev -p 6006"
  }
}

Storybookが起動したら、ブラウザでhttp://localhost:6006にアクセスすることで、Storybook上に描画されたフォームコンポーネントを確認できます。

Storybook上にフォームコンポーネントが描画される

play関数を使ったStorybook上でのテスト

Storybook上に描画したUIコンポーネントに対してplay関数を使うことで、定義したユーザーの動作をStorybook上で視覚的に確認できます。

今回は先ほど作成したフォームコンポーネントに対して、play関数内で値を入力し、送信ボタンをクリックする一連の操作を定義してみましょう。

import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, fn, waitFor, expect } from "storybook/test";
import { Form } from "./Form";

// ...

// play関数を使ったStory
export const SubmitInteraction: Story = {
  args: {
    // fn()でモック関数を作成
    onSubmit: fn(),
  },
  // canvasがStorybook上に描画されたコンポーネント
  play: async ({ canvas, step, args }) => {
    // 実際のユーザー操作をステップごとに定義
    await step("名前フィールドに名前を入力", async () => {
      await userEvent.type(
        canvas.getByRole("textbox", { name: "Name" }),
        "nus3"
      );
    });

    await step("送信ボタンをクリック", async () => {
      await userEvent.click(canvas.getByRole("button", { name: "送信" }));
    });

    await step("submit時に入力された値が渡される", async () => {
      // Formコンポーネントのpropsとして渡したargs.onSubmitが呼ばれたことを確認
      await waitFor(() => expect(args.onSubmit).toHaveBeenCalledWith("nus3"));
    });
  },
};

Storybookを起動するとInteractionsパネルが表示され、play関数で定義したステップごとの操作をStorybook上で確認できます。

Storybook上に追加されたInteractionパネルが表示される

このplay関数によって、事前にステップを定義することで、そのステップごとに視覚的にUIコンポーネントの状態を確認できます。この点が、ほかのテストフレームワークにはない特徴だと筆者は考えています。

@storybook/test-runnerを使ったテスト

@storybook/test-runnerというテストランナーを使うことで Storyのplay関数で定義したテストを実行できます。

@storybook/test-runnerでは内部でJestとPlaywrightを使い、事前に起動したStorybookに対して、play関数のテストを実行します。

導入方法としては、必要なライブラリをインストールし、package.jsonにテスト用のnpm scriptsを追加します。

npm install -D @storybook/test-runner
{
  "scripts": {
    "test-storybook": "test-storybook"
  }
}

事前にStorybookを起動し、test-storybookを実行することでplay関数で定義したテストを実行できます。

npm run test-storybook

> storybook-test@1.0.0 test-storybook
> test-storybook

 PASS   browser: chromium  src/components/Form.stories.tsx
  Components/Form
    SubmitInteraction
      ✓ play-test (55 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.89 s
Ran all test suites.

@storybook/addon-vitestを使ったテスト

@storybook/addon-vitestを使うことで、Storyのplay関数で定義したテストをVitestのBrowser Modeで実行できます。

事前にStorybookを起動する必要がない点と、内部ではJestではなくVitestが使われる点が@storybook/test-runnerとの大きな違いです。

導入方法としては、まず必要なライブラリをインストールします。

npm install -D @storybook/addon-vitest

次にStorybookの設定ファイル.storybook/main.tsに、@storybook/addon-vitestを追加します。

import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  // ...
  addons: ["storybook/addon-vitest"],
  // ...
};
export default config;

.storybook/vitest.setup.tsを作成し、Vitestのセットアップ時の処理を追加します。このセットアップ時の処理の中で、VitestにStorybookの設定情報を認識させるためsetProjectAnnotationsを呼び出します。

import { beforeAll } from "vitest";
import { setProjectAnnotations } from "@storybook/react-vite";

const project = setProjectAnnotations([]);

beforeAll(project.beforeAll);

VitestのTest Projects機能を使い、vitest.config.tsにStorybook用のプロジェクトの設定を作成します。

import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";

// __dirnameがない環境でも動作するように、Node.jsのモジュールを使ってdirnameを取得
const dirname =
  typeof __dirname !== "undefined"
    ? __dirname
    : path.dirname(fileURLToPath(import.meta.url));

export default defineConfig({
  // @storybook/addon-vitestをプラグインとして追加
  plugins: [
    storybookTest({
      // .storybookディレクトリのパスを指定
      configDir: path.join(dirname, ".storybook"),
      // Storybookを起動するためのnpm scriptを指定
      storybookScript: "npm run storybook --ci",
    }),
  ],
  test: {
    name: "storybook",
    // VitestのBrowser Modeを有効にする
    browser: {
      enabled: true,
      headless: true,
      instances: [{ browser: "chromium" }],
      provider: "playwright",
    },
    setupFiles: [".storybook/vitest.setup.ts"],
  },
});

最後に、npm scriptsにStorybook用のテストを実行するためのスクリプトを追加します。

{
  "scripts": {
    "test:storybook": "vitest --project storybook"
  }
}

実際にnpm run test:storybookを実行するとStorybookを起動しなくてもテストが実行されていることが確認できます。

npm run test:storybook

> storybook-test@1.0.0 test:storybook
> vitest run --project storybook

info Using tsconfig paths for react-docgen

 RUN  v3.1.3

 ✓  storybook (chromium)  src/components/Form.stories.tsx (2 tests) 141ms
   ✓ Submit Interaction 80ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  18:30:07
   Duration  1.79s

そのほか⁠ブラウザ環境でUIコンポーネントをテストする機能を提供しているライブラリやツール

ブラウザ環境でUIコンポーネントをテストする機能を提供しているテストフレームワークとして、VitestのBrowser ModeとStorybookを中心に紹介しました。このほかにも次のように、同様の機能を提供しているテストフレームワークはいくつかあります。

最後に

本記事ではフロントエンドにおけるテストフレームワークの特徴とUIコンポーネントに焦点を当てたテスト手法を紹介しました。今回は紹介しきれませんでしたが、このほかにも見た目の変化があるかを確認するVisual Regression Testingやアクセシビリティのテストなど、Webフロントエンドにおけるテスト手法やツールはまだまだあります。

冒頭でも触れたように、Webフロントエンドの変化に伴い、取れうるテストの選択肢も増えてきました。選択肢が増えることによって、その手法やツールを学ぶことがたいへんになるかもしれませんが、選択肢が多いということは今までよりも自社のプロダクトやプロジェクトに適したテスト手法を選べるということでもあります。

本記事がモダンなフロントエンドにおけるテストを考えるきっかけになればうれしいです。

リポジトリ

本記事で紹介した内容は次のリポジトリにまとめていますので、利用しているライブラリのバージョンや設定を確認したい場合は、こちらを参考にしてください。

おすすめ記事

記事・ニュース一覧