Safe Action Testing

next-safe-action/skills/skills/safe-action-testing

by next-safe-actiona2605bd2e84321245cba8f5718c144a6e4a5fa47No licenseListed Oct 9, 2026Updated Oct 9, 2026

Use when writing tests for next-safe-action actions or hooks -- Vitest patterns for testing server actions directly, middleware behavior, hooks with React Testing Library, validation errors, and server errors

Instructions onlySoftware Development
AI-generated overview

Vitest patterns for testing next-safe-action server actions, middleware, hooks, validation errors, and server errors.

What it does
Provides testing patterns and code examples for next-safe-action server actions and hooks. It covers calling actions directly, testing bind arguments, exercising middleware chains, testing hooks with React Testing Library's renderHook, checking validation and server errors, mocking Next.js navigation errors, and organizing test files. It also lists commands for running the test suite or a single test file.
When to use it
Use when writing or reviewing Vitest tests for next-safe-action actions, middleware, or hooks. It fits projects that need patterns for asserting validation errors, server errors, bind-args behavior, or hook state transitions. It also helps when mocking Next.js navigation errors in tests.
Requirements
Vitest, React Testing Library, next-safe-action, zod, and a Next.js project; no scripts are shipped, only instructions and code examples.

Testing next-safe-action

Testing Actions Directly

Server actions are async functions — call them directly in tests:

ts
// src/__tests__/actions.test.tsimport { 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" });  });});

Testing Actions with Bind Args

ts
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();  });});

Testing Middleware

Test middleware behavior by creating actions with specific middleware chains:

ts
import { describe, it, expect, vi } from "vitest";import { createSafeActionClient } from "next-safe-action";import { z } from "zod";
// Mock authvi.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();  });});

Testing Hooks

Use React Testing Library's renderHook:

tsx
import { describe, it, expect, vi } from "vitest";import { renderHook, act, waitFor } from "@testing-library/react";import { useAction } from "next-safe-action/hooks";
// Mock the actionconst 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({});  });});

Testing Validation Errors

ts
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);  });});

Mocking Framework Errors

ts
import { vi } from "vitest";
// Mock Next.js navigationvi.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",    });  }),}));

Test File Organization

Follow the project convention:

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

Run tests:

bash
# All testspnpm run test:lib
# Single filecd packages/next-safe-action && npx vitest run ./src/__tests__/action-builder.test.ts

Source and attribution

Source:next-safe-action/skillsinskills/safe-action-testingat commita2605bd

License: No license

Content belongs to its original authors. SourceWeft indexes it from a public repository.

Report or request removal