📝

App Routerで学ぶテスト実践会のレポート

2024/12/16に公開

はじめに

先日(2024/11/30)に「App Router で学ぶテスト実践会」を主催しました。

https://devguil.connpass.com/event/336183/

この勉強会を開催したきっかけは、筆者含め App Router のテストに関して情報が少なく、他社のエンジニアがテストをどのように行っているかを知りたいという思いがあったからです。また、この勉強会を通じて、新しいテスト手法があればそれを知りたいという思いもありました。実際にチャレンジした内容とその結果をレポートとしてまとめました。

要件・仕様

  • ログインページからダッシュボードページ、請求一覧、登録、編集、削除できるアプリケーション
  • Next.js のチュートリアルで作成した管理画面に 1 からテストを導入する
  • どこか 1 つのページにテストを書いて正常にパスがしていること
  • 設計やライブラリ選定はチームで決めてもらう

実際のアプリケーションはこちらです。

Screen shot of Sort JSON command

レポート

Playwright で E2E テスト

@bukkan817, @hiroshi_mochy, @workspring9029

当チームでは、まずアプリケーションの核となる部分に対して E2E テストを導入しました。「核となる部分」にテスト範囲を限定することで、スコープを明確に保ちながら、主要なユーザーストーリーやビジネスロジックを確実にカバーできます。さらに、徐々にカバレッジを拡大しつつ、ユニットテストやインテグレーションテストを追加していく方針を取っています。また、Next.js の公式ドキュメントでも、非同期コンポーネントのテストには Jest や Vitest のサポートが不十分なため、E2E テストの導入が推奨されています。個人的にも、この段階的なテスト導入は非常に理にかなったアプローチだと感じています。

Good to know: Since async Server Components are new to the React ecosystem, Jest currently does not support them. While you can still run unit tests for synchronous Server and Client Components, we recommend using an E2E tests for async components.

https://nextjs.org/docs/app/building-your-application/testing/vitest

このチームでは、ログイン → 請求一覧 → 請求登録 → 請求一覧で登録したデータを確認するというユーザーストーリーをテストしました。

テストコード

playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: "html",
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
  },
  projects: [
    { name: "setup", testMatch: /.*\.setup\.ts/ }, // ログイン処理を行うテスト
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: "./playwright/.auth/user.json",
      },
      dependencies: ["setup"], // ログイン処理を行うテストを先に実行する
    },
    {
      name: "firefox",
      use: {
        ...devices["Desktop Firefox"],
        storageState: "./playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
    {
      name: "webkit",
      use: {
        ...devices["Desktop Safari"],
        storageState: "./playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
  webServer: {
    command: "yarn dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
  },
});
auth.setup.ts
import { test as setup, expect } from "@playwright/test";

const authFile = "playwright/.auth/user.json";

setup("authenticate", async ({ page }) => {
  await page.goto("http://localhost:3000/");
  await page.getByRole("link", { name: "Log in" }).click();

  // ログイン
  await expect(
    page.getByRole("heading", { name: "Please log in to continue.", level: 1 })
  ).toBeVisible();
  await page.getByRole("textbox", { name: "Email" }).fill("[email protected]");
  await page.getByRole("textbox", { name: "Password" }).fill("123456");
  await page.getByRole("button", { name: "Log in" }).click();
  await page.waitForURL("/dashboard");

  await page.context().storageState({ path: authFile });
});
invoice.test.ts
import { test, expect } from "@playwright/test";

test.beforeEach(async ({ page }) => {
  await page.goto("http://localhost:3000/dashboard/invoices");
});

test("請求書の登録", async ({ page }) => {
  await expect(
    page.getByRole("heading", { name: "Invoices", level: 1 })
  ).toBeVisible();

  // フォーム入力
  await page.getByRole("link", { name: "Create Invoice" }).click();
  await page
    .getByRole("combobox", { name: "Choose customer" })
    .selectOption({ label: "Balazs Orban" });
  await page
    .getByRole("spinbutton", { name: "choose an amount" })
    .fill("123456");
  await page.getByRole("radio", { name: "Pending" }).click();

  // フォーム送信
  await page.getByRole("button", { name: "Create Invoice" }).click();
  await page.waitForURL("/dashboard/invoices");

  // テーブルの一番上に表示されていることを検証
  await expect(
    page.locator("table > tbody > tr:nth-of-type(1) > td:nth-of-type(1)")
  ).toHaveText("Balazs Orban");
  await expect(
    page.locator("table > tbody > tr:nth-of-type(1) > td:nth-of-type(2)")
  ).toHaveText("$123,456.00");
});

test("請求書の更新", async ({ page }) => {});

test("請求書の削除", async ({ page }) => {});

詳細

auth.setup.ts では認証処理を実行しています。Playwright の認証機能を利用することで、テスト開始時に一度だけ認証処理を行い、以降は認証済みの状態でテストを実行できます。
仕組みとして、初回の認証処理で JSON ファイルが生成され、これをストレージに保存します。
各テストではその JSON ファイルをブラウザが読み込むことでログイン状態を再現します。

https://playwright.dev/docs/auth

実際に生成される JSON ファイルに認証情報が入っていることが確認できました。

user.json
{
  "cookies": [
    {
      "name": "authjs.csrf-token",
      "value": "56a3d4c3e91898d8f0610f53dd070b7adc0acece10fcfca1f437d29a99bbb3be%7C7d92311a21d45e8b835ff33a7f982dbc2f8be0d0178c8aae7db2905c36ec7201",
      "domain": "localhost",
      "path": "/",
      "expires": -1,
      "httpOnly": true,
      "secure": false,
      "sameSite": "Lax"
    },
    {
      "name": "authjs.callback-url",
      "value": "http%3A%2F%2Flocalhost%3A3000%2Flogin",
      "domain": "localhost",
      "path": "/",
      "expires": -1,
      "httpOnly": true,
      "secure": false,
      "sameSite": "Lax"
    },
    {
      "name": "authjs.session-token",
      "value": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..zrs2p5-TIDyPCAkN.YiiAH40oSCm33pPNTRpbuIjNYzVmYzUYhbiuD9ujgW2e9sQiV25NA1a2OkQl7K8lHuko1t_tebBApQ5O_162k7LaixjBxjdPopG7X5CCdAbMIs_YVHz5cVpy-h_PRWxqmNTqvxSdwiF-wYiUU3yeneMjNMLGiKC9Hs_ogbItV2UGIT4ZqlUUEzkWu_nH2CtwQ-PX14ahWIx8wgDD13NOl0engQ12BJQ.5NBpLiKaSj4TYP14Qk9zJw",
      "domain": "localhost",
      "path": "/",
      "expires": 1736177763.205842,
      "httpOnly": true,
      "secure": false,
      "sameSite": "Lax"
    }
  ],
  "origins": []
}

故に、invoice.test.tsではログイン処理はすでに完了しており、ログイン後の状態でテストを実行しています。
このページのテストでは、登録、更新、削除といった操作をテストできますが、各操作ごとにログイン処理を記述する必要がないため、コードのメンテナンス性が向上します。

テストが通ることが確認できました!

yarn test
yarn run v1.22.22
$ playwright test

Running 10 tests using 8 workers
  10 passed (5.8s)

To open last HTML report run:

  yarn playwright show-report

✨  Done in 6.25s.

Vitest + React Testing Library によるコンポーネントテスト

@engineerYodaka, @ask_nugey, @tezmasatoo

このチームでは、Vitest と React Testing Library を利用して Form コンポーネントのユニットテストを導入しました。Server Actions を利用しているコンポーネントのテストを筆者も経験したことがなかったので非常に勉強になりました。
以下、請求登録できる Form コンポーネントです。

Form.tsx
"use client";

import { CustomerField } from "@/app/lib/definitions";
import Link from "next/link";
import {
  CheckIcon,
  ClockIcon,
  CurrencyDollarIcon,
  UserCircleIcon,
} from "@heroicons/react/24/outline";
import { Button } from "@/app/ui/button";
import { createInvoice } from "@/app/lib/actions";
import { useFormState } from "react-dom";

export default function Form({ customers }: { customers: CustomerField[] }) {
  const initialState = { message: "", errors: {} };
  const [state, dispatch] = useFormState(createInvoice, initialState);

  return (
    <form action={dispatch}>
      <div className="rounded-md bg-gray-50 p-4 md:p-6">
        {/* Customer Name */}
        <div className="mb-4">
          <label htmlFor="customer" className="mb-2 block text-sm font-medium">
            Choose customer
          </label>
          <div className="relative">
            <select
              id="customer"
              name="customerId"
              className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
              defaultValue=""
              aria-describedby="customer-error"
            >
              <option value="" disabled>
                Select a customer
              </option>
              {customers.map((customer) => (
                <option key={customer.id} value={customer.id}>
                  {customer.name}
                </option>
              ))}
            </select>
            <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
          </div>
          <div id="customer-error" aria-live="polite" aria-atomic="true">
            {state.errors?.customerId &&
              state.errors.customerId.map((error: string) => (
                <p className="mt-2 text-sm text-red-500" key={error}>
                  {error}
                </p>
              ))}
          </div>
        </div>

        {/* Invoice Amount */}
        <div className="mb-4">
          <label htmlFor="amount" className="mb-2 block text-sm font-medium">
            Choose an amount
          </label>
          <div className="relative mt-2 rounded-md">
            <div className="relative">
              <input
                id="amount"
                name="amount"
                type="number"
                step="0.01"
                placeholder="Enter USD amount"
                aria-describedby="amount-error"
                className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
              />
              <CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
            <div id="amount-error" aria-live="polite" aria-atomic="true">
              {state.errors?.amount &&
                state.errors.amount.map((error: string) => (
                  <p className="mt-2 text-sm text-red-500" key={error}>
                    {error}
                  </p>
                ))}
            </div>
          </div>
        </div>

        {/* Invoice Status */}
        <fieldset>
          <legend className="mb-2 block text-sm font-medium">
            Set the invoice status
          </legend>
          <div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
            <div className="flex gap-4">
              <div className="flex items-center">
                <input
                  id="pending"
                  name="status"
                  type="radio"
                  value="pending"
                  aria-describedby="status-error"
                  className="h-4 w-4 border-gray-300 bg-gray-100 text-gray-600 focus:ring-2 focus:ring-gray-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-gray-600"
                />
                <label
                  htmlFor="pending"
                  className="ml-2 flex items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300"
                >
                  Pending <ClockIcon className="h-4 w-4" />
                </label>
              </div>
              <div className="flex items-center">
                <input
                  id="paid"
                  name="status"
                  type="radio"
                  value="paid"
                  aria-describedby="status-error"
                  className="h-4 w-4 border-gray-300 bg-gray-100 text-gray-600 focus:ring-2 focus:ring-gray-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-gray-600"
                />
                <label
                  htmlFor="paid"
                  className="ml-2 flex items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white dark:text-gray-300"
                >
                  Paid <CheckIcon className="h-4 w-4" />
                </label>
              </div>
            </div>
            <div id="status-error" aria-live="polite" aria-atomic="true">
              {state.errors?.status &&
                state.errors.status.map((error: string) => (
                  <p className="mt-2 text-sm text-red-500" key={error}>
                    {error}
                  </p>
                ))}
            </div>
          </div>
        </fieldset>
      </div>
      <div className="mt-6 flex justify-end gap-4">
        <Link
          href="/dashboard/invoices"
          className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
        >
          Cancel
        </Link>
        <Button type="submit">Create Invoice</Button>
      </div>
    </form>
  );
}
Form.action.ts
import { sql } from "@vercel/postgres";
import { redirect } from "next/navigation";
import { z } from "zod";

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string({
    invalid_type_error: "Please select a customer.",
  }),
  amount: z.coerce
    .number()
    .gt(0, { message: "Please enter an amount greater than $0." }),
  status: z.enum(["pending", "paid"], {
    invalid_type_error: "Please select an invoice status.",
  }),
  date: z.string(),
});

const InvoiceSchema = FormSchema.omit({ id: true, date: true });

export type State = {
  errors?: {
    customerId?: string[];
    amount?: string[];
    status?: string[];
  };
  message?: string | null;
};

export async function createInvoice(prevState: State, formData: FormData) {
  const validateFields = InvoiceSchema.safeParse({
    customerId: formData.get("customerId"),
    amount: formData.get("amount"),
    status: formData.get("status"),
  });

  if (!validateFields.success) {
    return {
      errors: validateFields.error.flatten().fieldErrors,
      message: "Missing Fields. Failed to Create Invoice.",
    };
  }
  const { customerId, amount, status } = validateFields.data;
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split("T")[0];

  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
      `;

    redirect("/dashboard/invoices");
  } catch (error) {
    return { massage: "Something went wrong", error };
  }
}

Form コンポーネントの詳細としては、入力欄に値を入力して作成ボタンを押下すると、請求が登録され、値に問題があればバリデーションエラーが表示されるというものです。Form.action.ts は Form コンポーネントで受け取った値をバリデーションにかけて問題なければ請求を DB に登録する処理をしている Server Actions です。また、テストを記載していくうちに、課題が上がってきました。

useFormState のエラー

Form コンポーネントでは、useFormState を利用しています。useFormState では、渡されたアクションの結果に基づき state を更新する hook です。
useFormState と Server Actions を使用しているテストで以下、エラーが発生しました。

TypeError: (0 , _reactdom.useFormState) is not a function or its return value is not iterable.

stack overflow にも同じように悩んでいる人がいました。

https://stackoverflow.com/questions/78136654/testing-a-next-js-component-with-useformstate-from-react-dom-or-alternatives

また、useFormState 自体を mock にするとエラーが解消しそうな記事も発見しました。

https://stackoverflow.com/questions/77705420/jest-next14-useformstate-typeerror-0-reactdom-useformstate-is-not-a-functi

さらに調べていくと、Next.js の issue にコメントがありました。

I had some discussions regarding other problems with the Testing library and NextJS some weeks ago in their repo. They suggested that there's a mismatched version between NextJS envs and test envs. Meanwhile Next is using React Canary, probably the test environment is still in React 18.2. That could explain as well this possible issue. React 19 should solve this issue (if I am not wrong).

https://github.com/vercel/next.js/issues/54757#issuecomment-2015168447

このコメントによると、Next.js と React Testing Library のバージョンが一致していないことが原因でありそうです。
ほんまかと思いながら、React19 にアップデートする必要がありそうなのでアップデートしてみました。(ここからは筆者がやってみました)

package.json
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
- "next": "^14.0.0",
- "@types/react": "^18.2.21",
- "@types/react-dom": "^18.2.14",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "next": "^15.0.4",
+ "@types/react": "^19.0.1",
+ "@types/react-dom": "^19.0.1",

React 19 からは useFormState から useActinonState に変更されているため該当箇所を修正します。

https://ja.react.dev/reference/react/useActionState

From.tsx
- import { useFormState } from "react-dom";
+ import { useActionState } from "react";

- const [state, dispatch] = useFormState(createInvoice, initialState);
+ const [state, dispatch] = useActionState(createInvoice, initialState);

テストを実行するとエラーが解消しました。

yarn test Form.test.tsx
yarn run v1.22.22
$ vitest Form.test.tsx
The CJS build of Vite's Node API is deprecated. See https://vite.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.

 ✓ app/ui/invoices/Form.test.tsx (1)
   ✓ CreateForm (1)
     ✓ 正常な値でフォームをサブミットした場合、createInvoiceが呼び出される

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  00:54:11
   Duration  764ms (transform 36ms, setup 107ms, collect 96ms, tests 91ms, environment 187ms, prepare 32ms)

テストコード

Form.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createInvoice } from "./form-action";
import Form from "./create-form";

const mockCustomers = [
  {
    id: "1",
    name: "John Doe",
  },
  {
    id: "2",
    name: "Hannah Montana",
  },
];

// Server Action をモックする
vi.mock("./form.action", () => ({
  createInvoice: vi.fn().mockResolvedValue({
    message: "",
    errors: [],
  }),
}));

describe("CreateForm", () => {
  test("正常な値でフォームをサブミットした場合、createInvoiceが呼び出される", async () => {
    render(<Form customers={mockCustomers} />);

    const selectBox = screen.getByRole("combobox");
    await userEvent.selectOptions(selectBox, "John Doe");
    expect(selectBox).toHaveValue("1");

    const input = screen.getByRole("spinbutton");
    await userEvent.type(input, "100");
    expect(input).toHaveValue(100);

    const radios = screen.getAllByRole("radio");
    await userEvent.click(radios[0]);
    expect(radios[0]).toBeChecked();

    const submitButton = screen.getByRole("button", { name: "Create Invoice" });
    await userEvent.click(submitButton);

    expect(createInvoice).toHaveBeenCalled();
  });
});
Form.action.test.ts
import { sql } from "@vercel/postgres";
import { createInvoice } from "./form";
import { vi } from "vitest";

const { redirectMock } = vi.hoisted(() => {
  return { redirectMock: vi.fn() };
});

vi.mock("next/navigation", () => ({
  redirect: redirectMock,
}));

vi.mock("@vercel/postgres", () => ({
  sql: vi.fn(),
}));

describe("createInvoice", () => {
  it("正常に請求が作成される場合", async () => {
    const formData = new FormData();
    formData.set("customerId", "1");
    formData.set("amount", "10000");
    formData.set("status", "paid");

    await createInvoice({}, formData);

    const [[queryParts, customerId, amountInCents, status, date]] = (
      sql as unknown as { mock: { calls: any[][] } }
    ).mock.calls;

    const queryString = queryParts.join(" ");
    expect(queryString).toContain(
      "INSERT INTO invoices (customer_id, amount, status, date)"
    );
    expect(customerId).toBe("1");
    expect(amountInCents).toBe(1000000); // 10000 * 100
    expect(status).toBe("paid");
    expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);

    expect(redirectMock).toHaveBeenCalledWith("/dashboard/invoices");
  });

  it("バリデーションエラーの場合、エラーメッセージを返す", async () => {
    const formData = new FormData();
    formData.set("customerId", "");
    formData.set("amount", "-100");
    formData.set("status", "invalid");

    const result = await createInvoice({}, formData);

    expect(result).toEqual({
      errors: expect.any(Object),
      message: "Missing Fields. Failed to Create Invoice.",
    });
  });
});

詳細

Form.test.tsx では、Form コンポーネントをレンダリングしたうえで、フォームの入力欄に値を入力し、送信ボタンをクリックした際に createInvoice が呼び出されることをテストしています。Form コンポーネントの責務は、ユーザーが入力したデータを正しく取得し、サーバーアクションに渡すことに限定されます。そのため、createInvoice の内部処理(サーバーサイドロジック)については Form.action.test.ts で別途テストを行います。

サーバーアクション呼び出しは vi.mock を用いてモック化しており、以下のように設定しています。

vi.mock("./form.action", () => ({
  createInvoice: vi.fn().mockResolvedValue({
    message: "",
    errors: [],
  }),
}));

一方、Form.action.test.ts では、サーバーアクション(createInvoice)自体のテストを行います。ここでは、実際に実行される SQL クエリやそのパラメータが妥当か、エラー発生時に適切なエラーメッセージが返ってくるかを検証します。また、サーバー側で redirect が実行される場合、リダイレクト先の URL が正しく設定されているかもテストし、異なる値が渡された場合はテストが失敗することを確認できます。

 FAIL  app/ui/invoices/action.test.ts > createInvoice > 正常に請求が作成される場合
AssertionError: expected "spy" to be called with arguments: [ '/dashboard/invoice' ]

Received:

  1st spy call:

  Array [
-   "/dashboard/invoice",
+   "/dashboard/invoices",
  ]


Number of calls: 1

 ❯ app/ui/invoices/action.test.ts:38:26

このような形でユニットテストを分割することで、クライアントコンポーネントとサーバーアクションがそれぞれ固有の責務に対して正しく機能しているかを担保できます。
型定義やテストコードの記述方法には、まだ改善の余地が残されていますが、現状でも最低限の品質は確保できていると考えられます。
最後に、すべてのテストが成功することを確認しました。

yarn test
yarn run v1.22.22
$ vitest run
The CJS build of Vite's Node API is deprecated. See https://vite.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.

 RUN  v2.1.8 /Users/shogofukami/Documents/Next.js13-learn

 ✓ app/ui/invoices/form.action.test.ts (2)
 ✓ app/ui/invoices/form.test.tsx (1)

 Test Files  2 passed (2)
      Tests  3 passed (3)
   Start at  13:07:44
   Duration  755ms (transform 46ms, setup 255ms, collect 127ms, tests 95ms, environment 441ms, prepare 62ms)

✨  Done in 1.24s.

Vitest によるバックエンドテスト

@yossydev, @yasushi_cohi, tatsuya.n

こちらのチームでは、Vitest を利用してバックエンドのテスト、Storybook 導入、E2E テストの導入にチャレンジしていますが、一部完成していない部分があり、ここでは Vitest を利用したバックエンドテストのチャレンジを紹介します。

data.ts
export async function fetchCardData() {
  try {
    const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
    const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
    const invoiceStatusPromise = sql`SELECT
         SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
         SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
         FROM invoices`;

    const data = await Promise.all([
      invoiceCountPromise,
      customerCountPromise,
      invoiceStatusPromise,
    ]);

    const numberOfInvoices = Number(data[0].rows[0].count ?? "0");
    const numberOfCustomers = Number(data[1].rows[0].count ?? "0");
    const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? "0");
    const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? "0");

    return {
      numberOfInvoices,
      numberOfCustomers,
      totalPaidInvoices,
      totalPendingInvoices,
    };
  } catch (error) {
    console.error("Database Error:", error);
    throw new Error("Failed to fetch revenue data.");
  }
}

fetchCardData 関数内部で直接 sql クエリを呼び出していました。そのため、テスト環境では実際のデータベース接続や SQL 実行部分をモックする必要があり、テストコードが複雑になりがちでした。

修正後の data.ts
interface QueryResult<T> {
  rows: T[];
}

interface CountResult {
  count: number;
}

interface InvoiceStatusResult {
  paid: number;
  pending: number;
}

export async function fetchCardData(
  invoiceCountQuery: Promise<QueryResult<CountResult>>,
  customerCountQuery: Promise<QueryResult<CountResult>>,
  invoiceStatusQuery: Promise<QueryResult<InvoiceStatusResult>>
): Promise<{
  numberOfCustomers: number;
  numberOfInvoices: number;
  totalPaidInvoices: string;
  totalPendingInvoices: string;
}> {
  try {
    const [invoiceCount, customerCount, invoiceStatus] = await Promise.all([
      invoiceCountQuery,
      customerCountQuery,
      invoiceStatusQuery,
    ]);

    const numberOfInvoices = Number(invoiceCount.rows[0].count ?? "0");
    const numberOfCustomers = Number(customerCount.rows[0].count ?? "0");
    const totalPaidInvoices = formatCurrency(invoiceStatus.rows[0].paid ?? "0");
    const totalPendingInvoices = formatCurrency(
      invoiceStatus.rows[0].pending ?? "0"
    );

    return {
      numberOfCustomers,
      numberOfInvoices,
      totalPaidInvoices,
      totalPendingInvoices,
    };
  } catch (error) {
    throw new Error("Failed to card data.");
  }
}

DI 対応後は、fetchCardData 関数が invoiceCountQuery, customerCountQuery, invoiceStatusQuery といったクエリ結果の Promise を引数として受け取るように変更されています。この変更により、テスト時には、実際のデータベース接続や SQL を実行せずに、任意のモックデータを fetchCardData に注入することが可能になります。これにより、データベース環境や外部リソースに依存せず、純粋に関数ロジックをテストできるようになります。

テストコード

data.test.ts
import { expect, test, describe } from "vitest";
import { fetchCardData } from "./data";
import { formatCurrency } from "./utils";

// SQLクエリの代わりのモックデータ
const mockSqlQueries = {
  invoiceCount: Promise.resolve({
    rows: [{ count: 1 }],
  }),
  customerCount: Promise.resolve({
    rows: [{ count: 1 }],
  }),
  invoiceStatus: Promise.resolve({
    rows: [{ paid: 549932, pending: 1000251164 }],
  }),
};

describe("fetchCardData", () => {
  test("正しくデータを取得して返すこと", async () => {
    const result = await fetchCardData(
      mockSqlQueries.invoiceCount,
      mockSqlQueries.customerCount,
      mockSqlQueries.invoiceStatus
    );

    expect(result).toStrictEqual({
      numberOfCustomers: 1,
      numberOfInvoices: 1,
      totalPaidInvoices: formatCurrency(549932),
      totalPendingInvoices: formatCurrency(1000251164),
    });
  });
});

詳細

このテストコードでは、fetchCardData 関数に渡される SQL クエリの結果をモック化しています。これにより、データベース接続や SQL 実行を行わずに、テスト環境でデータを注入できるようになりました。これにより、fetchCardData 関数のテストがより簡潔かつ独立性が高くなりました。fetchCardData を呼び出して、結果が正常に返却されるシンプルなテストになりました。

テストが通る事を確認できました!

yarn test
yarn run v1.22.22
$ vitest

 DEV  v2.1.6 /Users/shogofukami/Documents/Next.js13-learn

 ✓ |0| app/lib/data.test.ts (1)
   ✓ fetchCardData (1)
     ✓ 正しくデータを取得して返すこと

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  00:28:19
   Duration  1.21s (transform 0ms, setup 0ms, collect 21ms, tests 7ms, environment 0ms, prepare 159ms)

 PASS  Waiting for file changes...

まとめ

今回ご参加いただいた皆さま、誠にありがとうございました。
テストケースやテストコードの書き方にはまだまだ改善の余地がありますが、実際に他社のエンジニアと一緒にコードを書きながら議論できる機会は貴重で、とても有意義な時間となりました。

来年度もこのイベントは継続していく予定ですので、ぜひまたご参加ください!

GitHubで編集を提案

Discussion