npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jsonbored/safemocker

v0.2.0

Published

A type-safe, Jest & Vitest-compatible mock for next-safe-action. Replicates real middleware behavior and returns proper SafeActionResult structure. Fun name: 'safe' + 'mocker' = mocking tool for next-safe-action!

Readme

safemocker

npm version npm downloads License

CI TypeScript Node

safemocker solves the critical problem of testing next-safe-action v8 in Jest environments where ESM modules (.mjs files) cannot be directly imported. It provides a comprehensive mocking solution that's type-safe, robust, and extensive, allowing easy testing for all next-safe-action usage throughout your project.

📑 Table of Contents

✨ Features

  • Works with Jest - Solves ESM compatibility issues (primary use case)
  • Works with Vitest - Even with ESM support, mocking provides faster tests, easier control, consistent patterns, and better error scenario testing
  • Replicates real middleware behavior - Auth, validation, error handling work exactly like the real library
  • Returns proper SafeActionResult structure - Type-safe, matches real API exactly
  • Type-safe API - Full TypeScript integration with proper inference
  • Easy to use - Similar to Prismocker pattern, minimal setup required
  • Standalone package - Can be extracted to separate repo for OSS distribution

🚀 Quick Start

import { createAuthedActionClient } from '@jsonbored/safemocker/jest';
import { z } from 'zod';

// Create authenticated action client
const authedAction = createAuthedActionClient();

// Define your action
const createUser = authedAction
  .inputSchema(z.object({ name: z.string().min(1), email: z.string().email() }))
  .metadata({ actionName: 'createUser', category: 'user' })
  .action(async ({ parsedInput, ctx }) => {
    return { id: 'new-id', ...parsedInput, createdBy: ctx.userId };
  });

// Test it!
const result = await createUser({ name: 'John', email: '[email protected]' });
expect(result.data).toEqual({ id: 'new-id', name: 'John', email: '[email protected]', createdBy: 'test-user-id' });

📦 Installation

npm install --save-dev @jsonbored/safemocker
# or
pnpm add -D @jsonbored/safemocker
# or
yarn add -D @jsonbored/safemocker

📚 Quick Start Guide

Step 1: Create Mock File

Create __mocks__/next-safe-action.ts in your project root:

import { createMockSafeActionClient } from '@jsonbored/safemocker/jest';

export const createSafeActionClient = createMockSafeActionClient({
  defaultServerError: 'Something went wrong',
  isProduction: false,
  auth: {
    enabled: true,
    testUserId: 'test-user-id',
    testUserEmail: '[email protected]',
    testAuthToken: 'test-token',
  },
});

export const DEFAULT_SERVER_ERROR_MESSAGE = 'Something went wrong';

Step 2: Use in Tests

// Your test file
import { authedAction } from './safe-action'; // Your real safe-action.ts file
import { z } from 'zod';

// Create action using REAL safe-action.ts (which uses mocked next-safe-action)
const testAction = authedAction
  .inputSchema(z.object({ id: z.string() }))
  .metadata({ actionName: 'testAction', category: 'user' })
  .action(async ({ parsedInput, ctx }) => {
    return { id: parsedInput.id, userId: ctx.userId };
  });

// Test SafeActionResult structure
const result = await testAction({ id: '123' });
expect(result.data).toEqual({ id: '123', userId: 'test-user-id' });
expect(result.serverError).toBeUndefined();
expect(result.fieldErrors).toBeUndefined();

Step 3: Verify Jest Auto-Mock

Jest will automatically use __mocks__/next-safe-action.ts when you import next-safe-action in your code. No additional configuration needed!

Step 1: Create Mock Setup

Create vitest.setup.ts or add to your test file:

import { vi } from 'vitest';
import { createMockSafeActionClient } from '@jsonbored/safemocker/vitest';

vi.mock('next-safe-action', () => {
  return {
    createSafeActionClient: createMockSafeActionClient({
      defaultServerError: 'Something went wrong',
      isProduction: false,
      auth: {
        enabled: true,
        testUserId: 'test-user-id',
        testUserEmail: '[email protected]',
        testAuthToken: 'test-token',
      },
    }),
    DEFAULT_SERVER_ERROR_MESSAGE: 'Something went wrong',
  };
});

Step 2: Use in Tests

// Your test file
import { authedAction } from './safe-action'; // Your real safe-action.ts file
import { z } from 'zod';

// Create action using REAL safe-action.ts (which uses mocked next-safe-action)
const testAction = authedAction
  .inputSchema(z.object({ id: z.string() }))
  .metadata({ actionName: 'testAction', category: 'user' })
  .action(async ({ parsedInput, ctx }) => {
    return { id: parsedInput.id, userId: ctx.userId };
  });

// Test SafeActionResult structure
const result = await testAction({ id: '123' });
expect(result.data).toEqual({ id: '123', userId: 'test-user-id' });
expect(result.serverError).toBeUndefined();
expect(result.fieldErrors).toBeUndefined();

Step 3: Configure Vitest

If using vitest.setup.ts, add it to your vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    setupFiles: ['./vitest.setup.ts'],
  },
});

📖 API Reference

Factory Functions

Creates a basic mock safe action client.

import { createMockSafeActionClient } from '@jsonbored/safemocker/jest'; // or 'safemocker/vitest'

const client = createMockSafeActionClient({
  defaultServerError: 'Something went wrong',
  isProduction: false,
  auth: {
    enabled: true,
    testUserId: 'test-user-id',
    testUserEmail: '[email protected]',
    testAuthToken: 'test-token',
  },
});

Creates a mock client with authentication middleware pre-configured.

import { createAuthedActionClient } from '@jsonbored/safemocker/jest';

const authedAction = createAuthedActionClient({
  auth: {
    testUserId: 'custom-user-id',
  },
});

const action = authedAction
  .inputSchema(z.object({ id: z.string() }))
  .action(async ({ parsedInput, ctx }) => {
    // ctx.userId, ctx.userEmail, ctx.authToken are available
    return { id: parsedInput.id, userId: ctx.userId };
  });

Creates a mock client with optional authentication middleware.

import { createOptionalAuthActionClient } from '@jsonbored/safemocker/jest';

const optionalAuthAction = createOptionalAuthActionClient();

const action = optionalAuthAction
  .inputSchema(z.object({ query: z.string() }))
  .action(async ({ parsedInput, ctx }) => {
    // ctx.user may be null, ctx.userId may be undefined
    return { query: parsedInput.query, userId: ctx.userId };
  });

Creates a mock client with rate limiting middleware.

import { createRateLimitedActionClient } from '@jsonbored/safemocker/jest';
import { z } from 'zod';

const metadataSchema = z.object({
  actionName: z.string().min(1),
  category: z.enum(['user', 'admin']).optional(),
});

const rateLimitedAction = createRateLimitedActionClient(metadataSchema);

const action = rateLimitedAction
  .inputSchema(z.object({ query: z.string() }))
  .metadata({ actionName: 'search', category: 'content' })
  .action(async ({ parsedInput }) => {
    return { results: [] };
  });

Creates all action client variants matching your real safe-action.ts pattern.

import { createCompleteActionClient } from '@jsonbored/safemocker/jest';
import { z } from 'zod';

const metadataSchema = z.object({
  actionName: z.string().min(1),
  category: z.enum(['user', 'admin', 'content']).optional(),
});

const {
  actionClient,
  loggedAction,
  rateLimitedAction,
  authedAction,
  optionalAuthAction,
} = createCompleteActionClient(metadataSchema, {
  auth: {
    testUserId: 'test-user-id',
  },
});

// Use exactly like your real safe-action.ts
const action = authedAction
  .inputSchema(z.object({ id: z.string() }))
  .metadata({ actionName: 'test' })
  .action(async ({ parsedInput, ctx }) => {
    return { id: parsedInput.id, userId: ctx.userId };
  });

Configuration Options

interface MockSafeActionClientConfig {
  defaultServerError?: string;        // Default: 'Something went wrong'
  isProduction?: boolean;              // Default: false
  auth?: {
    enabled?: boolean;                 // Default: true
    testUserId?: string;               // Default: 'test-user-id'
    testUserEmail?: string;            // Default: '[email protected]'
    testAuthToken?: string;            // Default: 'test-token'
  };
}

💡 Usage Examples

import { createAuthedActionClient } from '@jsonbored/safemocker/jest';
import { z } from 'zod';

const authedAction = createAuthedActionClient();

const createUser = authedAction
  .inputSchema(
    z.object({
      name: z.string().min(1),
      email: z.string().email(),
    })
  )
  .metadata({ actionName: 'createUser', category: 'user' })
  .action(async ({ parsedInput, ctx }) => {
    return {
      id: 'new-user-id',
      name: parsedInput.name,
      email: parsedInput.email,
      createdBy: ctx.userId,
    };
  });

// Test
const result = await createUser({
  name: 'John Doe',
  email: '[email protected]',
});

expect(result.data).toEqual({
  id: 'new-user-id',
  name: 'John Doe',
  email: '[email protected]',
  createdBy: 'test-user-id',
});
import { createAuthedActionClient } from '@jsonbored/safemocker/jest';
import { z } from 'zod';

const authedAction = createAuthedActionClient();

const updateProfile = authedAction
  .inputSchema(
    z.object({
      name: z.string().min(1),
      email: z.string().email(),
    })
  )
  .action(async ({ parsedInput }) => {
    return { success: true };
  });

// Test validation errors
const result = await updateProfile({
  name: '', // Invalid: min length
  email: 'invalid-email', // Invalid: not an email
});

expect(result.fieldErrors).toBeDefined();
expect(result.fieldErrors?.name).toBeDefined();
expect(result.fieldErrors?.email).toBeDefined();
expect(result.data).toBeUndefined();
import { createAuthedActionClient } from '@jsonbored/safemocker/jest';
import { z } from 'zod';

const authedAction = createAuthedActionClient({
  defaultServerError: 'Something went wrong',
  isProduction: false, // Use error message in development
});

const deleteItem = authedAction
  .inputSchema(z.object({ id: z.string() }))
  .action(async () => {
    throw new Error('Item not found');
  });

// Test error handling
const result = await deleteItem({ id: 'test-id' });

expect(result.serverError).toBe('Item not found');
expect(result.data).toBeUndefined();

// Test production mode (hides error details)
const prodAction = createAuthedActionClient({
  defaultServerError: 'Something went wrong',
  isProduction: true,
});

const prodResult = await prodAction
  .inputSchema(z.object({ id: z.string() }))
  .action(async () => {
    throw new Error('Sensitive error details');
  })({ id: 'test' });

expect(prodResult.serverError).toBe('Something went wrong');
expect(prodResult.serverError).not.toBe('Sensitive error details');
import { createMockSafeActionClient } from '@jsonbored/safemocker/jest';
import { z } from 'zod';

const client = createMockSafeActionClient();

// Add custom middleware
client.use(async ({ next, ctx = {} }) => {
  // Add custom context (next-safe-action format: { ctx: newContext })
  return next({ ctx: { ...ctx, customValue: 'test' } });
});

const action = client
  .inputSchema(z.object({ id: z.string() }))
  .action(async ({ parsedInput, ctx }) => {
    return {
      id: parsedInput.id,
      customValue: ctx.customValue,
    };
  });

const result = await action({ id: 'test-id' });

expect(result.data).toEqual({
  id: 'test-id',
  customValue: 'test',
});
import { createCompleteActionClient } from '@jsonbored/safemocker/jest';
import { z } from 'zod';

const metadataSchema = z.object({
  actionName: z.string().min(1),
  category: z.enum(['user', 'admin']).optional(),
});

const { authedAction } = createCompleteActionClient(metadataSchema, {
  auth: {
    testUserId: 'user-123',
    testUserEmail: '[email protected]',
  },
});

// Replicate your real safe-action.ts pattern
const updateJob = authedAction
  .inputSchema(
    z.object({
      jobId: z.string().uuid(),
      title: z.string().min(1),
      status: z.enum(['draft', 'published', 'archived']),
    })
  )
  .metadata({ actionName: 'updateJob', category: 'user' })
  .action(async ({ parsedInput, ctx }) => {
    // Verify context is injected
    expect(ctx.userId).toBe('user-123');
    expect(ctx.userEmail).toBe('[email protected]');

    return {
      jobId: parsedInput.jobId,
      title: parsedInput.title,
      status: parsedInput.status,
      updatedBy: ctx.userId,
    };
  });

// Test success case
const successResult = await updateJob({
  jobId: '123e4567-e89b-12d3-a456-426614174000',
  title: 'Software Engineer',
  status: 'published',
});

expect(successResult.data).toEqual({
  jobId: '123e4567-e89b-12d3-a456-426614174000',
  title: 'Software Engineer',
  status: 'published',
  updatedBy: 'user-123',
});

// Test validation errors
const validationResult = await updateJob({
  jobId: 'invalid-uuid',
  title: '',
  status: 'invalid-status',
});

expect(validationResult.fieldErrors).toBeDefined();
expect(validationResult.fieldErrors?.jobId).toBeDefined();
expect(validationResult.fieldErrors?.title).toBeDefined();
expect(validationResult.fieldErrors?.status).toBeDefined();
import { createAuthedActionClient } from '@jsonbored/safemocker/jest';
import { z } from 'zod';

const authedAction = createAuthedActionClient();

// Discriminated union for content types
const articleSchema = z.object({
  type: z.literal('article'),
  title: z.string().min(1),
  content: z.string().min(1),
  author: z.string().min(1),
});

const videoSchema = z.object({
  type: z.literal('video'),
  title: z.string().min(1),
  videoUrl: z.string().url(),
  duration: z.number().int().positive(),
});

const contentSchema = z.discriminatedUnion('type', [articleSchema, videoSchema]);

const createContent = authedAction
  .inputSchema(
    z.object({
      content: contentSchema,
      category: z.enum(['tech', 'business']),
    })
  )
  .action(async ({ parsedInput, ctx }) => {
    return {
      id: 'content-1',
      ...parsedInput.content,
      category: parsedInput.category,
      createdBy: ctx.userId,
    };
  });

// Test article content
const articleResult = await createContent({
  content: {
    type: 'article',
    title: 'Test Article',
    content: 'Article content...',
    author: 'John Doe',
  },
  category: 'tech',
});

expect(articleResult.data?.type).toBe('article');

// Test video content
const videoResult = await createContent({
  content: {
    type: 'video',
    title: 'Test Video',
    videoUrl: 'https://example.com/video.mp4',
    duration: 300,
  },
  category: 'tech',
});

expect(videoResult.data?.type).toBe('video');

// Test validation errors (nested fields use dot notation)
const invalidResult = await createContent({
  content: {
    type: 'article',
    title: '', // Invalid
    content: '', // Invalid
    author: '', // Invalid
  } as any,
  category: 'tech',
});

expect(invalidResult.fieldErrors?.['content.title']).toBeDefined();
expect(invalidResult.fieldErrors?.['content.content']).toBeDefined();
expect(invalidResult.fieldErrors?.['content.author']).toBeDefined();
import { createAuthedActionClient } from '@jsonbored/safemocker/jest';
import { z } from 'zod';

const authedAction = createAuthedActionClient();

// Partial update action
const updateContent = authedAction
  .inputSchema(
    z.object({
      contentId: z.string().uuid(),
      updates: z.object({
        title: z.string().min(1).optional(),
        published: z.boolean().optional(),
        tags: z.array(z.string()).max(10).optional(),
      }),
    })
  )
  .action(async ({ parsedInput, ctx }) => {
    return {
      id: parsedInput.contentId,
      updatedFields: Object.keys(parsedInput.updates),
      updatedBy: ctx.userId,
    };
  });

// Test partial update
const result = await updateContent({
  contentId: '123e4567-e89b-12d3-a456-426614174000',
  updates: {
    title: 'Updated Title',
    published: true,
  },
});

expect(result.data?.updatedFields).toContain('title');
expect(result.data?.updatedFields).toContain('published');

// Batch update action
const batchUpdate = authedAction
  .inputSchema(
    z.object({
      updates: z.array(
        z.object({
          contentId: z.string().uuid(),
          updates: z.object({
            title: z.string().min(1).optional(),
          }),
        })
      ).min(1).max(50),
    })
  )
  .action(async ({ parsedInput }) => {
    return {
      totalUpdated: parsedInput.updates.length,
      updated: parsedInput.updates.map((u) => u.contentId),
    };
  });

// Test batch update
const batchResult = await batchUpdate({
  updates: [
    { contentId: 'id-1', updates: { title: 'Title 1' } },
    { contentId: 'id-2', updates: { title: 'Title 2' } },
  ],
});

expect(batchResult.data?.totalUpdated).toBe(2);

🚀 Advanced Features

When using nested objects in your schemas, validation errors use dot notation for field paths:

const schema = z.object({
  content: z.object({
    title: z.string().min(1),
    author: z.string().min(1),
  }),
});

// Invalid input
const result = await action({
  content: {
    title: '', // Invalid
    author: '', // Invalid
  },
});

// Field errors use dot notation
expect(result.fieldErrors?.['content.title']).toBeDefined();
expect(result.fieldErrors?.['content.author']).toBeDefined();

safemocker fully supports Zod discriminated unions:

const contentSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('article'), content: z.string() }),
  z.object({ type: z.literal('video'), videoUrl: z.string().url() }),
]);

const action = client
  .inputSchema(z.object({ content: contentSchema }))
  .action(async ({ parsedInput }) => {
    // TypeScript knows the discriminated union type
    if (parsedInput.content.type === 'article') {
      // parsedInput.content.content is available
    } else {
      // parsedInput.content.videoUrl is available
    }
  });

Complex array validation with nested items:

const schema = z.object({
  items: z.array(
    z.object({
      id: z.string().uuid(),
      name: z.string().min(1),
    })
  ).min(1).max(50),
});

// Validation errors for arrays
const result = await action({ items: [] }); // Invalid: min 1
expect(result.fieldErrors?.items).toBeDefined();

Rate limiting middleware is included in rateLimitedAction:

import { createRateLimitedActionClient } from '@jsonbored/safemocker/jest';
import { z } from 'zod';

const metadataSchema = z.object({
  actionName: z.string().min(1),
  category: z.enum(['content', 'user']).optional(),
});

const rateLimitedAction = createRateLimitedActionClient(metadataSchema);

const searchAction = rateLimitedAction
  .inputSchema(z.object({ query: z.string() }))
  .metadata({ actionName: 'search', category: 'content' })
  .action(async ({ parsedInput }) => {
    return { results: [] };
  });

⚙️ How It Works

safemocker replicates the exact method chaining pattern of next-safe-action:

client
  .inputSchema(zodSchema)      // Step 1: Define input validation
  .metadata(metadata)          // Step 2: Add metadata (optional)
  .action(handler)              // Step 3: Define action handler
  1. Input Validation - Zod schema validation happens first
  2. Middleware Execution - Middleware runs in order, each can modify context
  3. Handler Execution - Action handler runs with validated input and context
  4. Result Wrapping - Handler result is wrapped in SafeActionResult structure
  5. Error Handling - Any errors are caught and converted to serverError

All actions return a SafeActionResult<TData>:

interface SafeActionResult<TData> {
  data?: TData;                              // Success data
  serverError?: string;                       // Server error message
  fieldErrors?: Record<string, string[]>;     // Validation errors by field
  validationErrors?: Record<string, string[]>; // General validation errors
}

📁 Example Files

The safemocker package includes comprehensive examples with full test coverage:

Real-world safe-action.ts pattern using mocked next-safe-action. Demonstrates:

  • Base action client creation
  • Metadata schema definition
  • Error handling configuration
  • Middleware chaining
  • Complete action client factory pattern

Test Coverage: __tests__/real-integration.test.ts (comprehensive integration tests)

User management actions demonstrating common patterns:

  • createUser - Authentication required, input validation, context injection
  • getUserProfile - Optional authentication, UUID validation
  • updateUserSettings - Partial updates, enum validation
  • deleteUser - Admin-only pattern, UUID validation

Test Coverage: __tests__/real-integration.test.ts

Complex content management actions demonstrating advanced v8 features:

  • createContent - Discriminated unions (article, video, podcast), nested validation, array constraints
  • updateContent - Partial updates with optional fields, UUID validation
  • batchUpdateContent - Array validation with complex items, batch operations
  • searchContent - Rate limiting, complex query validation, pagination, filtering
  • getContentWithRelations - Optional authentication, nested relations, conditional data

Features Demonstrated:

  • ✅ Discriminated unions with type-specific validation
  • ✅ Nested object validation (dot notation for errors)
  • ✅ Array validation with min/max constraints
  • ✅ Partial updates with optional fields
  • ✅ Batch operations with array validation
  • ✅ Complex query parameters with defaults
  • ✅ Rate limiting middleware
  • ✅ Optional authentication with conditional logic
  • ✅ Nested relations and conditional data inclusion

Test Coverage: __tests__/content-actions.test.ts (30 comprehensive tests, all passing)

⚠️ Caveats & Considerations

Problem: Jest cannot directly import ESM modules (.mjs files) without experimental configuration. next-safe-action is ESM-only.

Solution: safemocker provides a CommonJS-compatible mock that Jest can import directly. Your real safe-action.ts file uses the mocked next-safe-action, so you test the real middleware logic with mocked dependencies.

Important: Always use your real safe-action.ts file in tests. Don't mock it - mock next-safe-action instead.

Real next-safe-action middleware:

  • Executes in actual Next.js server environment
  • Has access to headers(), cookies(), etc.
  • Performs real authentication checks
  • Makes real database calls

safemocker middleware:

  • Executes in test environment
  • Uses test configuration (test user IDs, etc.)
  • Skips real authentication (injects test context)
  • No real database calls

Key Point: The middleware logic is replicated, but the implementation uses test-friendly mocks. This allows you to test your action handlers with realistic middleware behavior without needing a full Next.js server environment.

safemocker maintains full type safety:

  • ✅ Input schemas are type-checked
  • ✅ Handler parameters are typed (parsedInput, ctx)
  • ✅ Return types are inferred
  • SafeActionResult is properly typed

Note: TypeScript may show errors in your IDE if next-safe-action types aren't available. This is expected - the runtime behavior is correct, and types are provided by safemocker.

Development Mode (isProduction: false):

  • Error messages include full details
  • Useful for debugging during development

Production Mode (isProduction: true):

  • Error messages use defaultServerError
  • Hides sensitive error details
  • Matches real next-safe-action behavior

Recommendation: Use isProduction: false in tests to see actual error messages, but test both modes to ensure your error handling works correctly.

Default Behavior:

  • Authentication is always successful in tests
  • Test user context is always injected
  • No real authentication checks are performed

Why: In tests, you want to focus on testing your action logic, not authentication infrastructure. Real authentication should be tested separately with integration tests.

Customization:

  • Set auth.enabled: false to disable auth middleware
  • Set custom testUserId, testUserEmail, testAuthToken for different test scenarios
  • Use different auth configs for different test suites

Real next-safe-action:

  • Metadata validation happens in middleware
  • Invalid metadata throws errors

safemocker:

  • Metadata validation is replicated
  • Use createMetadataValidatedActionClient() or createCompleteActionClient() with metadata schema
  • Invalid metadata throws 'Invalid action metadata' error

Recommendation: Always provide metadata in tests to match real usage patterns.

🔧 Troubleshooting

Problem: Jest cannot find the next-safe-action module.

Solution: Ensure your __mocks__/next-safe-action.ts file is in the correct location (project root or __mocks__ directory at package level).

Verify:

# Check mock file exists
ls __mocks__/next-safe-action.ts

# Check Jest is using the mock
# Add console.log in your mock file to verify it's being loaded

Problem: Vitest isn't using the mock.

Solution: Ensure vi.mock('next-safe-action', ...) is called before any imports that use next-safe-action.

Best Practice: Put mock setup in vitest.setup.ts or at the top of your test file before any imports.

Problem: TypeScript shows errors about missing exports from next-safe-action.

Solution: This is expected - safemocker provides runtime mocks, but TypeScript may not recognize them. The code will work correctly at runtime.

Workaround: Add type assertions if needed, but the runtime behavior is correct.

Problem: ctx.userId or other context values are undefined.

Solution: Ensure you're using authedAction or optionalAuthAction (not base actionClient), and that auth.enabled is true in config.

Check:

const client = createAuthedActionClient({
  auth: {
    enabled: true, // Must be true
    testUserId: 'test-user-id',
  },
});

Problem: Invalid input doesn't return fieldErrors.

Solution: Ensure you're using .inputSchema() with a Zod schema. Validation happens automatically.

Verify:

const action = client
  .inputSchema(z.object({ email: z.string().email() })) // Schema required
  .action(async ({ parsedInput }) => { ... });

const result = await action({ email: 'invalid' });
expect(result.fieldErrors).toBeDefined(); // Should have email error

Problem: Testing nested validation errors but not finding them.

Solution: Nested fields use dot notation in fieldErrors.

Example:

const schema = z.object({
  user: z.object({
    email: z.string().email(),
  }),
});

const result = await action({ user: { email: 'invalid' } });
// Use dot notation:
expect(result.fieldErrors?.['user.email']).toBeDefined();
// NOT: result.fieldErrors?.user?.email

🔄 Migration Guide

Before (Manual Mock):

vi.mock('./safe-action.ts', async () => {
  const createActionHandler = (inputSchema: any) => {
    return vi.fn((handler: any) => {
      return async (input: unknown) => {
        try {
          const parsed = inputSchema ? inputSchema.parse(input) : input;
          const result = await handler({
            parsedInput: parsed,
            ctx: { userId: 'test-user-id' },
          });
          return result;
        } catch (error) {
          throw error;
        }
      };
    });
  };
  // ... complex mock setup
});

After (safemocker):

// __mocks__/next-safe-action.ts
import { createMockSafeActionClient } from '@jsonbored/safemocker/jest';

export const createSafeActionClient = createMockSafeActionClient({
  auth: { testUserId: 'test-user-id' },
});

// Use REAL safe-action.ts in tests
import { authedAction } from './safe-action';

Benefits:

  • ✅ Less boilerplate
  • ✅ Consistent SafeActionResult structure
  • ✅ Real middleware behavior replication
  • ✅ Type-safe
  • ✅ Easier to maintain

🤝 Contributing

This package is designed to be standalone and extractable. Contributions welcome!

License

MIT

Author

JSONbored

🔗 Related Projects

  • next-safe-action - The real library being mocked
  • Prismocker - Similar type-safe mocking tool for Prisma Client (inspiration for this package)
  • Claude Pro Directory - The parent project where safemocker and prismocker were originally developed