safe-action-testing
Skill827 starsChanged 3 months agoMITView on GitHub
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
1 other project uses a version of this, 1 of them edited.
Adopted by
Most recently taken up by
- LuanRoger/next-dxedited, first seen Oct 17, 2026
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 1
- LuanRoger/next-dx★ 1100% same
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository23 other files it gives its agents.
- AGENTS.mdAGENTS.md
- .agents/skills/vercel-composition-patterns/AGENTS.mdAGENTS.md
- .agents/skills/vercel-react-best-practices/AGENTS.mdAGENTS.md
- nextbase-architectureSkill
- nextbase-auth-ssrSkill
- nextbase-cache-componentsSkill
- nextbase-database-testingSkill
- nextbase-safe-actions-dataSkill
- nextbase-testingSkill
- nextbase-ui-patternsSkill
- next-cache-components-optimizerSkill
- safe-action-advancedSkill
- safe-action-clientSkill
- safe-action-hooksSkill
- safe-action-middlewareSkill
- shadcnSkill
- supabase-postgres-best-practicesSkill
- supabase-schema-migrationsSkill
- supabaseSkill
- turborepoSkill
- vercel-composition-patternsSkill
- vercel-react-best-practicesSkill
- web-design-guidelinesSkill
The file
We split this skill into the rules it gives an agent
3 rules. About 0 are common, 2 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 87
Test middleware behavior by creating actions with specific middleware chains:
- Very specific: line 132
Use React Testing Library's renderHook:
- Distinctive: line 261
Follow the project convention:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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: "alice@example.com" });
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: "alice@example.com" });
// Attempt duplicate
const result = await createUser({ name: "Bob", email: "alice@example.com" });
// 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 utilitiesRun tests:
# All tests
pnpm run test:lib
# Single file
cd packages/next-safe-action && npx vitest run ./src/__tests__/action-builder.test.tsDoes it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

