mcpField
S
safe-action-testing

safe-action-testing

github.com/next-safe-action测试与质量11.1k 次安装8 周前更新
代码仓库

关于 safe-action-testing

safe-action-testing 是面向 TypeScript 项目的 next-safe-action 测试技能。它通过示例介绍使用 Vitest 和 React Testing Library 测试 Action、中间件、Hook、校验错误及框架错误处理。

主要功能

  1. 01直接测试异步服务端 Action
  2. 02测试绑定参数的 Action
  3. 03检查中间件链行为
  4. 04使用 renderHook 测试 Hook
  5. 05检查校验与框架错误

适用场景

  1. 01验证有效输入时的 Action 结果
  2. 02测试绑定参数的 Action 行为
  3. 03检查身份验证中间件
  4. 04测试 Hook 状态与校验错误

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/next-safe-action/skills --skill safe-action-testing

安装后在 Claude Code 中输入/safe-action-testing确认已加载。

详细介绍

测试 next-safe-action

直接测试操作

Server 操作是异步函数,可在测试中直接调用:

// src/__tests__/actions.test.ts
import { describe, it, expect, vi } from "vitest";
import { createUser } from "@/app/actions";

describe("createUser", () => {
  it("returns user data on valid input", async () => {
    const result = await createUser({ name: "Alice", email: "[email protected]" });

    expect(result.data).toEqual({
      id: expect.any(String),
      name: "Alice",
    });
    expect(result.serverError).toBeUndefined();
    expect(result.validationErrors).toBeUndefined();
  });

  it("returns validation errors on invalid input", async () => {
    const result = await createUser({ name: "", email: "not-an-email" });

    expect(result.data).toBeUndefined();
    expect(result.validationErrors).toBeDefined();
    expect(result.validationErrors?.email?._errors).toContain("Invalid email");
  });

  it("returns server error on duplicate email", async () => {
    // Setup: create first user
    await createUser({ name: "Alice", email: "[email protected]" });

    // Attempt duplicate
    const result = await createUser({ name: "Bob", email: "[email protected]" });

    // If using returnValidationErrors:
    expect(result.validationErrors?.email?._errors).toContain("Email already in use");

    // OR if using throw + handleServerError:
    // expect(result.serverError).toBe("Email already in use");

    // OR if using returnServerError (bypasses handleServerError,
    // so assert the exact typed payload):
    // expect(result.serverError).toEqual({ code: "DUPLICATE_EMAIL", message: "Email already in use" });
  });
});

使用 Bind 参数测试操作

import { updatePost } from "@/app/actions";

describe("updatePost", () => {
  it("updates the post", async () => {
    const postId = "123e4567-e89b-12d3-a456-426614174000";
    const boundAction = updatePost.bind(null, postId);

    const result = await boundAction({
      title: "Updated Title",
      content: "Updated content",
    });

    expect(result.data).toEqual({ success: true });
  });

  it("returns validation error for invalid postId", async () => {
    const boundAction = updatePost.bind(null, "not-a-uuid");

    // Bind args validation errors throw ActionBindArgsValidationError
    await expect(boundAction({ title: "Test", content: "Test" }))
      .rejects.toThrow();
  });
});

测试中间件

通过使用特定中间件链创建操作,测试中间件的行为:

import { describe, it, expect, vi } from "vitest";
import { createSafeActionClient } from "next-safe-action";
import { z } from "zod";

// Mock auth
vi.mock("@/lib/auth", () => ({
  getSession: vi.fn(),
}));

import { getSession } from "@/lib/auth";

const authClient = createSafeActionClient().use(async ({ next }) => {
  const session = await getSession();
  if (!session?.user) throw new Error("Unauthorized");
  return next({ ctx: { userId: session.user.id } });
});

const testAction = authClient.action(async ({ ctx }) => {
  return { userId: ctx.userId };
});

describe("auth middleware", () => {
  it("passes userId to action when authenticated", async () => {
    vi.mocked(getSession).mockResolvedValue({
      user: { id: "user-1", role: "user" },
    });

    const result = await testAction();
    expect(result.data).toEqual({ userId: "user-1" });
  });

  it("returns server error when unauthenticated", async () => {
    vi.mocked(getSession).mockResolvedValue(null);

    const result = await testAction();
    expect(result.serverError).toBeDefined();
  });
});

测试 Hooks

使用 React Testing Library 的 renderHook:

import { describe, it, expect, vi } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useAction } from "next-safe-action/hooks";

// Mock the action
const mockAction = vi.fn();

describe("useAction", () => {
  it("starts idle", () => {
    const { result } = renderHook(() => useAction(mockAction));

    expect(result.current.isIdle).toBe(true);
    expect(result.current.isExecuting).toBe(false);
    expect(result.current.result).toEqual({});
  });

  it("executes and returns data", async () => {
    mockAction.mockResolvedValue({ data: { id: "1" } });

    const { result } = renderHook(() =>
      useAction(mockAction, {
        onSuccess: vi.fn(),
      })
    );

    act(() => {
      result.current.execute({ name: "Alice" });
    });

    await waitFor(() => {
      expect(result.current.hasSucceeded).toBe(true);
    });

    expect(result.current.result.data).toEqual({ id: "1" });
  });

  it("handles server errors", async () => {
    mockAction.mockResolvedValue({ serverError: "Something went wrong" });

    const onError = vi.fn();
    const { result } = renderHook(() => useAction(mockAction, { onError }));

    act(() => {
      result.current.execute({});
    });

    await waitFor(() => {
      expect(result.current.hasErrored).toBe(true);
    });

    expect(result.current.result.serverError).toBe("Something went wrong");
    expect(onError).toHaveBeenCalled();
  });

  it("resets state", async () => {
    mockAction.mockResolvedValue({ data: { id: "1" } });

    const { result } = renderHook(() => useAction(mockAction));

    act(() => {
      result.current.execute({});
    });

    await waitFor(() => {
      expect(result.current.hasSucceeded).toBe(true);
    });

    act(() => {
      result.current.reset();
    });

    expect(result.current.isIdle).toBe(true);
    expect(result.current.result).toEqual({});
  });
});

测试验证错误

import { flattenValidationErrors, formatValidationErrors } from "next-safe-action";

describe("validation error utilities", () => {
  const formatted = {
    _errors: ["Form error"],
    email: { _errors: ["Invalid email"] },
    name: { _errors: ["Too short", "Must start with uppercase"] },
  };

  it("flattenValidationErrors", () => {
    const flattened = flattenValidationErrors(formatted);

    expect(flattened.formErrors).toEqual(["Form error"]);
    expect(flattened.fieldErrors.email).toEqual(["Invalid email"]);
    expect(flattened.fieldErrors.name).toEqual(["Too short", "Must start with uppercase"]);
  });

  it("formatValidationErrors is identity", () => {
    expect(formatValidationErrors(formatted)).toBe(formatted);
  });
});

模拟框架错误

import { vi } from "vitest";

// Mock Next.js navigation
vi.mock("next/navigation", () => ({
  // Digest formats are Next.js internals — may change across versions
  redirect: vi.fn((url: string) => {
    throw Object.assign(new Error("NEXT_REDIRECT"), {
      digest: `NEXT_REDIRECT;push;${url};303;`,
    });
  }),
  notFound: vi.fn(() => {
    throw Object.assign(new Error("NEXT_NOT_FOUND"), {
      digest: "NEXT_HTTP_ERROR_FALLBACK;404",
    });
  }),
}));

测试文件组织

遵循项目约定:

packages/next-safe-action/src/__tests__/
├── happy-path.test.ts                  # Core happy path tests
├── validation-errors.test.ts           # Validation error utilities
├── middleware.test.ts                   # Middleware chain behavior
├── navigation-errors.test.ts           # Framework error handling
├── navigation-immediate-throw.test.ts  # Immediate navigation throws
├── server-error.test.ts                # Server error handling
├── bind-args-validation-errors.test.ts # Bind args validation
├── returnvalidationerrors.test.ts       # returnValidationErrors behavior
├── input-schema.test.ts                # Input schema tests
├── metadata.test.ts                    # Metadata tests
├── action-callbacks.test.ts            # Server-level callbacks
└── hooks-utils.test.ts                 # Hook utilities

运行测试:

# All tests
pnpm run test:lib

# Single file
cd packages/next-safe-action && npx vitest run ./src/__tests__/action-builder.test.ts

评论

支持 Markdown