mcpField
S
safe-action-testing

safe-action-testing

github.com/next-safe-actionTesting & quality11.1k installsUpdated 8w ago
Repository

About safe-action-testing

safe-action-testing is a skill for testing next-safe-action in TypeScript projects. It covers direct action calls, middleware, hooks, validation errors, and framework error handling with examples using Vitest and React Testing Library.

Key features

  1. 01Test async server actions directly
  2. 02Test actions with bound arguments
  3. 03Exercise middleware chains
  4. 04Test hooks with renderHook
  5. 05Check validation and framework errors

Use cases

  1. 01Verify action results for valid input
  2. 02Test bound action behavior
  3. 03Check authentication middleware
  4. 04Test hook state and validation errors

Install

Copy the command and run it in a terminal.

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

After installing, type/safe-action-testingin Claude Code to confirm it loaded.

Details

From the project README

Testing next-safe-action

Testing Actions Directly

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

// 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" });
  });
});

Testing Actions with Bind Args

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:

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

Testing Hooks

Use React Testing Library's 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({});
  });
});

Testing Validation Errors

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

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",
    });
  }),
}));

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:

# All tests
pnpm run test:lib

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

Comments

Markdown supported