0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

テスト全緑なのに実画面で500 —— Next.js 15 Server Action の "use server" 制約に踏んだ話

0
Posted at

環境

検証していたのは自社の管理画面です。構成は以下のとおりです。

  • Next.js 15.x(App Router / dev は Turbopack)
  • React 19(useActionState を使用)
  • TypeScript 5.x
  • テスト: Vitest + React Testing Library(jsdom)
  • パッケージマネージャ: pnpm
  • CI: ユニットテストのみ。next build はデプロイ時に実行

何が起きたか

管理画面のフォーム送信を、API Route から Server Action に寄せていました。Server Action はサーバー側の関数を画面から直接呼べる仕組みで、"use server" を付けたファイルに置いた async 関数がそのままクライアントから呼べます。

ユニットテストは全緑。Server Action の関数を直接 import して呼び出す検証も通っていました。ところが実画面で送信ボタンを押した瞬間、500 になります。

A "use server" file can only export async functions, found object.

"use server" を付けたファイルは、async function しか export してはいけない——というエラーです。

テストが緑だったので完了扱いにしており、その分まるごと手戻りになりました。

原因のコード

犯人はこれです。

// app/admin/users/actions.ts
"use server";

// ❌ これが 500 の原因
export const initialState: FormState = {
  ok: false,
  message: "",
};

export type FormState = {
  ok: boolean;
  message: string;
};

export async function createUser(
  prev: FormState,
  formData: FormData,
): Promise<FormState> {
  const name = String(formData.get("name") ?? "");
  if (!name) {
    return { ok: false, message: "名前を入力してください" };
  }
  // ... DB 書き込みなど
  return { ok: true, message: "作成しました" };
}

useActionState に渡す初期状態オブジェクトを、Server Action と同じファイルに export const していました。React 19 の useActionState(action, initialState) は初期値をクライアント側でも必要とするので、型と値を action の隣に置きたくなるんですよね。ここが落とし穴でした。

なぜテストは緑だったのか

テストコードはこう書いていました。

// actions.test.ts
import { createUser } from "./actions";

it("名前が空ならエラーを返す", async () => {
  const fd = new FormData();
  const result = await createUser({ ok: false, message: "" }, fd);
  expect(result.ok).toBe(false);
});

Vitest は Node の ESM ローダーでモジュールを解決します。"use server" は Next.js のコンパイラ(SWC / Turbopack)が解釈するディレクティブであり、Vitest のトランスフォームでは単なる文字列リテラルとして扱われます。

つまり、Next.js のモジュールローダーを通らない検証では、この制約チェックが一切走らない。テストが検証していたのは「関数のロジック」だけで、「Next.js のモジュールとして妥当か」は検証対象外だったわけです。

検証手段 ロジック "use server" 制約 実際のバンドル
Vitest での直接 import ✅ ❌ 検知不能 ❌
next build ✅ ✅ 検知できる ✅
実画面でのフォーム送信 ✅ ✅ ✅

解決:状態と型を別ファイルに切り出す

やることは単純で、export を action ファイルから追い出すだけです。

// app/admin/users/form-state.ts
export type FormState = {
  ok: boolean;
  message: string;
};

export const initialState: FormState = {
  ok: false,
  message: "",
};
// app/admin/users/actions.ts
"use server";

import type { FormState } from "./form-state";

export async function createUser(
  prev: FormState,
  formData: FormData,
): Promise<FormState> {
  const name = String(formData.get("name") ?? "");
  if (!name) {
    return { ok: false, message: "名前を入力してください" };
  }
  return { ok: true, message: "作成しました" };
}

クライアント側は form-state.ts から型と初期値を import します。

// app/admin/users/user-form.tsx
"use client";

import { useActionState } from "react";
import { createUser } from "./actions";
import { initialState } from "./form-state";

ハマりポイント

  • export type は許容される。型はコンパイル時に消える(type erasure)ため、制約チェックに引っかかりません。ただし export { FormState } と書くと値の export と解釈されうるので、必ず export type を付けること。
  • 定数・enum・Zod スキーマも全部アウト。export const schema = z.object({...}) を action ファイルに置けば同じエラーになります。スキーマは schema.ts に逃がしましょう。
  • export default も避ける。関数なら通りますが、useActionState は参照を渡すだけなので named export のほうが事故が少ないです。
  • export const runtime = "nodejs" を action ファイルに書かない。セグメント設定は page.tsx / layout.tsx 側に書きます。

再発防止の手順

  1. "use server" ファイルには async function 以外を置かない。定数・型・スキーマは必ず別ファイル。
  2. CI に next build を追加する。ユニットテストだけでは Next.js のコンパイラを通らないため、このクラスの不具合は原理的に検出できません。
  3. フォーム送信は Playwright で 1 本だけ E2E を書く。ログイン → 入力 → 送信 → トースト表示までを通すだけでも、今回の 500 は捕まります。
// e2e/user-create.spec.ts
import { test, expect } from "@playwright/test";

test("管理画面からユーザーを作成できる", async ({ page }) => {
  await page.goto("/admin/users/new");
  await page.getByLabel("名前").fill("テスト太郎");
  await page.getByRole("button", { name: "作成" }).click();

  // 500 ならここで落ちる
  await expect(page.getByText("作成しました")).toBeVisible();
  await expect(page).toHaveURL(/\/admin\/users$/);
});

完了条件を変えた

いちばん効いたのはこれです。

テストの緑ではなく、実画面で 1 回押したかを完了条件にする。

プルリクのチェックリストに「実画面で送信ボタンを押して確認」を明記し、スクリーンショットを貼る運用にしました。ユニットテストは「関数が正しいか」しか保証しません。フレームワークの約束事を守っているかは、フレームワークの上で動かさないと分かりません。

FAQ

Q. next build は通っていたのに、dev で 500 になったのはなぜ?
A. Turbopack の dev はモジュールを遅延コンパイルします。該当の action ファイルが最初に要求された時点でエラーになり、それが 500 として表面化します。手元の next build が CI に入っていなければ、本番デプロイまで気づけないこともあります。

Q. 型だけなら同じファイルに置いてもいい?
A. 実務上は問題ありませんが、initialState とセットで管理したほうが差分が読みやすいので、私は型も含めて form-state.ts に切り出しています。

Q. テストの緑が信用できないなら、ユニットテストは無意味?
A. いいえ。ロジックのリグレッション検出には効きます。ただしカバーできる範囲を理解しておく必要があります。今回の学びは「テストが緑 = 動く」ではなく「テストが緑 = テストが検証した範囲が正しい」だということでした。

実画面の確認、完了条件に入れていますか?


この記事を書いた人

BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。

GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。

👉 BENTEN Web Works — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?