【ランタイム60%短縮】Next.js のフロントエンドテストを Bun で実装する

# はじめに
Next.js のフロントエンドテストといえば、Jest や Vitest を使うことが多いと思いますが、今回は超高速な JavaScript ランタイムの Bun を使う方法をご紹介します。

- [Bunとは](#bunとは)
- [プロジェクトの作成](#プロジェクトの作成)
- [テスト環境の準備](#テスト環境の準備)
- [テスト実装](#テスト実装)
- [テストの実施](#テストの実施)
- [おまけ](#おまけ)
- [まとめ・サンプルコード](#まとめ)

# Bunとは
Bun とは Node.js の代替を目指して作られた**超高速ランタイム**であり、JavaScript や TypeScript でアプリを作るためのすべてがそろった便利なツールです。現在も活発に開発が続けられていて、どんどん便利になっています。
詳しくは公式ドキュメントをお読みください。

**Bun公式ドキュメント**

https://bun.sh/docs

# 環境

- **Next.js:** v15.3.0
- **bun:** v1.2.8
- **typescript:** v5.8.3

# プロジェクトの作成
今回は Bun を使ってサクッと Next.js のプロジェクトを作っていきます。
作成手順の詳細は公式ドキュメントを見てください。

https://bun.sh/guides/ecosystem/nextjs

## Bunのインストール
すでに bun が入っている人は飛ばしてOKです。
```bash
curl -fsSL https://bun.sh/install | bash
```

## Next.jsプロジェクトの作成
いくつか質問されるので好みのものを答えてください。私の回答を参考に載せておきます。
```bash
bun create next-app

# ✔ What is your project named?
> next-with-bun
# ✔ Would you like to use TypeScript?
> Yes
# ✔ Would you like to use ESLint?
> No
# ✔ Would you like to use Tailwind CSS?
> Yes
# ✔ Would you like your code inside a `src/` directory?
> Yes
# ✔ Would you like to use App Router? (recommended)
> Yes
# ✔ Would you like to use Turbopack for `next dev`?
> Yes
# ✔ Would you like to customize the import alias (`@/*` by default)?
> Yes
# ✔ What import alias would you like configured?
> @/*
# Creating a new Next.js app in /Users/harunaoka/project/next-with-bun.

# Using bun.

# Initializing project with template: app-tw
```

`Tailwind CSS` はとても便利な CSS フレームワークなので、もし触ったことがない方はこれを機に触ってみてください。

**Tailwind CSS公式サイト**

https://tailwindcss.com/

パッケージのバージョンを最新化しておきます。
```bash
# プロジェクトのディレクトリへ移動
cd next-with-bun
# パッケージの最新化
bun update --latest
```
## 起動してみる
Next.jsのプロジェクトを立ち上げてみます。（デフォルトでは port 3000 で立ち上がりますが、port 3000 が使用中の場合は別のportで立ち上がります）
```bash
bun run dev

# $ next dev --turbopack
#   ▲ Next.js 15.3.0 (Turbopack)
#   - Local:        http://localhost:3000
#   - Network:      http://xxx.xxx.x.xx:3000

# ✓ Starting...
# ✓ Ready in 644ms
```
`http://localhost:3000`にアクセスして下画像のように表示されればOKです。
![next-w-bun-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/4951632b-63e0-4a9d-ad81-cf008036c8a0.png)


# テスト環境の準備

**React Testing Library**
React コンポーネントを操作するためのライブラリが必要なのでインストールします。

https://testing-library.com/docs/react-testing-library/intro/

```bash
bun add -d @testing-library/react @testing-library/dom
```

**happy-dom**
Bun にはブラウザ機能がないため、DOM を使うテストでは仮想のブラウザ環境を提供する happy-dom が必要なのでインストールします。

https://bun.sh/docs/test/dom

```bash
bun add -d @happy-dom/global-registrator
# happydom.ts と bunfig.toml をルート配下に作成
touch happydom.ts bunfig.toml
```
```happydom.ts
import { GlobalRegistrator } from "@happy-dom/global-registrator";

GlobalRegistrator.register();
```
```bunfig.toml
[test]
preload = "./happydom.ts"
```
`bunfig.toml` は Bun の設定ファイルです。ここではテスト実行前に `happydom.ts` を読み込むように指定しています。

**bun-types**

Bun の組み込み API の TypeScript 定義が必要なのでインストールします。

https://bun.sh/docs/typescript

```bash
bun add -d @types/bun
```
`tsconfig.json` に `types` を追加します。
```diff:tsconfig.json
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
-   }
+   },
+   "types": ["bun-types"]
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
```


# テスト実装
例えば以下のような**ボタンコンポーネント**があるとします。
```tsx:src/components/Button.tsx
import React from "react";

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  color?: "gray" | "blue" | "red" | "green";
  className?: string;
}

export default function Button({ color, className, ...props }: ButtonProps) {
  const baseClass = "rounded-md px-6 py-3 text-white";
  const colorClass =
    color === "gray"
      ? "bg-zinc-500 hover:bg-zinc-600"
      : color === "blue"
      ? "bg-sky-600 hover:bg-sky-700"
      : color === "red"
      ? "bg-red-600 hover:bg-red-700"
      : color === "green"
      ? "bg-green-600 hover:bg-green-700"
      : "";

  return (
    <button
      className={baseClass + " " + colorClass + " " + className}
      {...props}
    />
  );
}
```
見た目はこんな感じです。（トップページに表示してみました）
```tsx:src/app/page.tsx
import Button from "@/components/Button";

export default function HomePage() {
  return (
    <>
      <h1 className="mb-8 p-5 text-4xl font-bold text-white text-center">
        Next.js + Bun でフロントエンドテストを実装する
      </h1>
      <div className="flex gap-4 items-center justify-center">
        <Button color="gray">NEGATIVE</Button>
        <Button color="blue">POSITIVE</Button>
        <Button color="red">DELETE</Button>
        <Button color="green">UPDATE</Button>
        <Button className="bg-purple-500 hover:bg-purple-600 rounded-2xl px-10 py-6">
          ORIGINAL
        </Button>
      </div>
    </>
  );
}
```
![next-w-bun-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/7bfdadd1-b05e-4aa0-ac03-d725dfc013fb.png)

テストファイルを作成します。
```bash
mkdir src/componenets/__test__
touch src/componenets/__test__/Button.spec.tsx
```
```tsx:src/componenets/__test__/Button.spec.tsx
import Button from "@/components/Button";
import { render, screen } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";

const baseClassName = "rounded-md px-6 py-3 text-white";
const colorGrayClassName = "bg-zinc-500 hover:bg-zinc-600";
const colorBlueClassName = "bg-sky-600 hover:bg-sky-700";
const colorRedClassName = "bg-red-600 hover:bg-red-700";
const colorGreenClassName = "bg-green-600 hover:bg-green-700";
const colorCustomClassName =
  "bg-purple-500 hover:bg-purple-600 rounded-2xl px-10 py-6";

describe("Button", () => {
  test("色を指定したボタンが表示できる", () => {
    render(<Button color="gray">グレー</Button>);
    const button = screen.getByRole("button", { name: "グレー" });
    expect(button.className).toContain(baseClassName);
    expect(button.className).toContain(colorGrayClassName);
    expect(button.textContent).toBe("グレー");

    render(<Button color="blue">ブルー</Button>);
    const buttonBlue = screen.getByRole("button", { name: "ブルー" });
    expect(buttonBlue.className).toContain(baseClassName);
    expect(buttonBlue.className).toContain(colorBlueClassName);
    expect(buttonBlue.textContent).toBe("ブルー");

    render(<Button color="red">レッド</Button>);
    const buttonRed = screen.getByRole("button", { name: "レッド" });
    expect(buttonRed.className).toContain(baseClassName);
    expect(buttonRed.className).toContain(colorRedClassName);
    expect(buttonRed.textContent).toBe("レッド");

    render(<Button color="green">グリーン</Button>);
    const buttonGreen = screen.getByRole("button", { name: "グリーン" });
    expect(buttonGreen.className).toContain(baseClassName);
    expect(buttonGreen.className).toContain(colorGreenClassName);
    expect(buttonGreen.textContent).toBe("グリーン");
  });
  test("クラス名が適用される", () => {
    render(
      <Button className={colorCustomClassName}>カスタムクラス</Button>,
    );
    const button = screen.getByRole("button", { name: "カスタムクラス" });
    expect(button.className).toContain(baseClassName);
    expect(button.className).toContain(colorCustomClassName);
    expect(button.textContent).toBe("カスタムクラス");
  });
  test("Click イベントが発火する", () => {
    const onClick = mock();
    render(<Button onClick={onClick}>クリック</Button>);
    const button = screen.getByRole("button", { name: "クリック" });
    button.click();
    expect(onClick).toHaveBeenCalled();
  });
});
```
React TestingLibrary は各 HTML タグに対応する role が決まっていて、それらを使って要素を取得できます。（参考：[[React TestingLibrary]各HTMLタグに対応するroleを理解する](https://qiita.com/only/items/8d541f29dd0b9a0901d7)）
上記の例では `getByRole("button", { name: "{button タグの中のテキスト}" });` で特定のボタン要素を取得しています。
また、Bun には Jestライクなマッチャー（`.toContain()` `.toHaveBeenCalled()` `.toBe()` など）が用意されています。現在 Bun で使用できるマッチャーは下記のページを参照してください。Jest との完全互換を目指していますが、一部未対応です。

https://bun.sh/docs/test/writing#matchers

**ラジオボタンコンポーネント**も作成して、テストを書いてみます。
```bash
touch src/componenets/Radio.tsx src/componenets/__test__/Radio.spec.tsx
```
```tsx:src/componenets/Radio.tsx
import React from "react";

interface RadioProps extends React.InputHTMLAttributes<HTMLInputElement> {
  className?: string;
  label: string;
  labelClassName?: string;
}

export default function Radio({
  className,
  label,
  labelClassName,
  ...props
}: RadioProps) {
  const baseClass = "mr-1";
  return (
    <label className={labelClassName}>
      <input type="radio" className={baseClass + " " + className} {...props} />
      {label}
    </label>
  );
}
```
```tsx:src/componenets/__test__/Radio.spec.tsx
import Radio from "@/components/Radio";
import { render, screen } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";

const baseClassName = "mr-1";

describe("Radio", () => {
  test("ラベルとラジオボタンが表示される", () => {
    render(<Radio value="test" label="ラジオボタン表示" />);
    const radioButton = screen.getByLabelText(
      "ラジオボタン表示",
    ) as HTMLInputElement;
    expect(radioButton).not.toBeNull();
    expect(radioButton.className).toContain(baseClassName);
    const label = radioButton.parentElement;
    expect(label).not.toBeNull();
  });

  test("クラス名が適用される", () => {
    render(
      <Radio
        value="test"
        label="クラス名を付けたよ"
        className="text-red-500"
        labelClassName="text-2xl"
      />,
    );
    const radioButton = screen.getByLabelText(
      "クラス名を付けたよ",
    ) as HTMLInputElement;
    expect(radioButton.className).toContain(`${baseClassName} text-red-500`);
    const label = radioButton.parentElement;
    expect(label?.className).toContain("text-2xl");
  });

  test("ラジオボタンのチェックが切り替えられる", () => {
    const handleChange = mock();
    render(
      <>
        <Radio
          name="radio-test"
          value="test1"
          label="ラジオボタン１"
          defaultChecked
          onChange={handleChange}
        />
        <Radio
          name="radio-test"
          value="test2"
          label="ラジオボタン２"
          onChange={handleChange}
        />
      </>,
    );
    const radioButton1 = screen.getByLabelText(
      "ラジオボタン１",
    ) as HTMLInputElement;
    const radioButton2 = screen.getByLabelText(
      "ラジオボタン２",
    ) as HTMLInputElement;
    expect(radioButton1.checked).toBe(true);
    expect(radioButton2.checked).toBe(false);

    // ラジオボタン2を選択する
    radioButton2.click();
    expect(handleChange).toHaveBeenCalled();
    expect(radioButton1.checked).toBe(false);
    expect(radioButton2.checked).toBe(true);
  });
});
```
上記の例では `getByLabelText()` で一致するラベルから要素を取得しています。

`getByRole("radio")` でも取得は可能ですが、この場合 `input` タグの中にテキストが無いため `{ name: "{input タグの中のテキスト}" }` での指定ができません。複数のラジオボタンから特定の要素を取得するために、ラベルで指定しています。

# テストの実施
下記のコマンドでテストを実行してみます。
```bash
# 全てのテストを実行
bun test
# 特定のテストファイルだけ実行
bun test src/components/__test__/Button.spec.tsx
```
下記のようにテストが通ってくれればOKです。
![next-w-bun-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/bfc3ea9b-89f7-4ab7-b8b6-d8749a597814.png)


## Jest との実行時間の比較
同じテストコードをJestで実行したところ、以下の結果になりました。

### Buttonコンポーネント
**Bun => [実行時間: 130ms]**
![next-w-bun-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/bfc3ea9b-89f7-4ab7-b8b6-d8749a597814.png)

**Jest => [実行時間: 421ms]**
![next-w-bun-05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/fca51c3e-371b-4932-8c76-4771f18f4ac6.png)

**Vitest => [実行時間: 509ms]**
![next-w-bun-08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/d350b64a-d69c-4cd0-83fe-b313423e18f2.png)

### Radioコンポーネント
**Bun => [実行時間: 114ms]**
![next-w-bun-06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/c0fc276a-8cfd-4d69-8b17-29d3100a4d08.png)

**Jest => [実行時間: 341ms]**
![next-w-bun-07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/c60400a1-0b8d-4e40-8843-0903b671223f.png)

**Vitest => [実行時間: 375ms]**
![next-w-bun-09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/91dc371b-24e2-41e7-bd1a-7bb6b3aa135d.png)


Bun test が**ダントツで速い**です。
テスト項目が少ないので体感はそこまで変わりませんが、テストが多くなってきたときにこの差のありがたみが感じられそうですね。

## Coverage
ちなみに `bun test` に `--coverage` オプションをつければ、カバレッジも測定できます。先ほどのテストコードを最初の２つだけにして実行すると、以下のようにカバーできていない部分を教えてくれます。
![next-w-bun-04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/5bc8e63f-45f8-494e-92ba-a66c0ae28ce4.png)

# おまけ

## ファイル名のパターン一致でテストを実施する

今回ユニットテストを書くにあたってファイル名を `*.spec.tsx` としたのには理由があります。そも前提として、Bun test は以下のパターンにマッチするファイルを自動でテストしてくれます。

- `*.test.{js|jsx|ts|tsx}`
- `*_test.{js|jsx|ts|tsx}`
- `*.spec.{js|jsx|ts|tsx}`
- `*_spec.{js|jsx|ts|tsx}`

ユニットテストの場合は `*.spec.tsx`、インテグレーションテストの場合は `*.test.tsx` のパターンで作成することで、テスト実行時にユニットテストのみ/インテグレーションテストのみで実行できます。
```bash
# ユニットテストのみ実行したいとき
bun test **/*.spec.tsx

# インテグレーションテストのみ実行したいとき
bun test **/*.test.tsx
```

## テスト名のパターン一致でテストを実施する

一応、`describe()` や `test()` で設定した名前の中から、一致する名前を持つテストのみを実行するオプションもあります。
例えば全てのユニットテストの `describe` にプレフィックスとして `unit:` をつけておけば、`bun test -t "unit:"` で対象のテストのみ実行できます。
```tsx:src/componenets/__test__/Button.spec.tsx
describe("unit:Button", () => {
    .....
});
```
```bash
# unit: が含まれるテストのみ実行したいとき
bun test -t "unit:"
```


# 補足
- Jest/Vitest で実装されたテストを Bun に移行する場合、そのまま実行できるケースが多いが一部は置き換えが必要
- E2E テストには Playwright や Cypress との併用もおすすめ

# まとめ
今回は **Next.js のフロントエンドテストを Bun で実装する方法** を紹介しました。この記事では少ないテスト項目での比較しかできていませんが、Vitest から Bun test に置き換えたら **3 分弱かかっていたテストが 10 秒弱になった**という記事もありました。（参考：[bun testが速いのでvitestから置き換えたらめちゃ高速化された](https://zenn.dev/studio/articles/c5207260e90e8c#dom-test)）
これを機に Bun での実装をぜひ検討してみてください。

他にも、

- Bun を使えば Next.js プロジェクトの作成もサクッとできる
- 既存の Jest/Vitest のテストの多くが Bun でも再利用可能（少しの修正でOK）

などのメリットもあります。
Bun はまだ発展途上な部分もありますが、**スピードやシンプルさの面で非常に魅力的**な選択肢だと思います。

今回紹介した内容のサンプルコードも公開しているので、ぜひ参考にしてみてください。

https://github.com/kaiharu/next-with-bun