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.
- β¨ Features
- π Quick Start
- π¦ Installation
- π Quick Start Guide
- π API Reference
- π‘ Usage Examples
- π Advanced Features
- βοΈ How It Works
- π Example Files
β οΈ Caveats & Considerations- π§ Troubleshooting
- π Migration Guide
- π€ Contributing
- π Related Projects
- β 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
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: 'john@example.com' });
expect(result.data).toEqual({ id: 'new-id', name: 'John', email: 'john@example.com', createdBy: 'test-user-id' });npm install --save-dev @jsonbored/safemocker
# or
pnpm add -D @jsonbored/safemocker
# or
yarn add -D @jsonbored/safemockerJest Integration
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: 'test@example.com',
testAuthToken: 'test-token',
},
});
export const DEFAULT_SERVER_ERROR_MESSAGE = 'Something went wrong';// 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();Jest will automatically use __mocks__/next-safe-action.ts when you import next-safe-action in your code. No additional configuration needed!
Vitest Integration
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: 'test@example.com',
testAuthToken: 'test-token',
},
}),
DEFAULT_SERVER_ERROR_MESSAGE: 'Something went wrong',
};
});// 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();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'],
},
});createMockSafeActionClient(config?)
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: 'test@example.com',
testAuthToken: 'test-token',
},
});createAuthedActionClient(config?)
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 };
});createOptionalAuthActionClient(config?)
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 };
});createRateLimitedActionClient(metadataSchema?, config?)
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: [] };
});createCompleteActionClient(metadataSchema, config?)
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 };
});MockSafeActionClientConfig
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: 'test@example.com'
testAuthToken?: string; // Default: 'test-token'
};
}Basic Action Testing
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: 'john@example.com',
});
expect(result.data).toEqual({
id: 'new-user-id',
name: 'John Doe',
email: 'john@example.com',
createdBy: 'test-user-id',
});Validation Error Testing
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();Error Handling Testing
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');Custom Middleware Testing
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',
});Complex Integration Testing
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: 'user@example.com',
},
});
// 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('user@example.com');
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();Discriminated Unions & Complex Validation
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();Partial Updates & Batch Operations
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);Nested Validation Errors
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();Discriminated Unions
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
}
});Array Validation
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 Limited Actions
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: [] };
});Method Chaining
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 handlerMiddleware Chain Execution
- Input Validation - Zod schema validation happens first
- Middleware Execution - Middleware runs in order, each can modify context
- Handler Execution - Action handler runs with validated input and context
- Result Wrapping - Handler result is wrapped in
SafeActionResultstructure - Error Handling - Any errors are caught and converted to
serverError
SafeActionResult Structure
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
}The safemocker package includes comprehensive examples with full test coverage:
examples/safe-action.ts
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)
examples/user-actions.ts
User management actions demonstrating common patterns:
createUser- Authentication required, input validation, context injectiongetUserProfile- Optional authentication, UUID validationupdateUserSettings- Partial updates, enum validationdeleteUser- Admin-only pattern, UUID validation
Test Coverage: __tests__/real-integration.test.ts
examples/content-actions.ts
Complex content management actions demonstrating advanced v8 features:
createContent- Discriminated unions (article, video, podcast), nested validation, array constraintsupdateContent- Partial updates with optional fields, UUID validationbatchUpdateContent- Array validation with complex items, batch operationssearchContent- Rate limiting, complex query validation, pagination, filteringgetContentWithRelations- 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)
Jest ESM Limitations
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.
Middleware Behavior Differences
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.
Type Safety
safemocker maintains full type safety:
- β Input schemas are type-checked
- β
Handler parameters are typed (
parsedInput,ctx) - β Return types are inferred
- β
SafeActionResultis 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.
Production vs Development Error Messages
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-actionbehavior
Recommendation: Use isProduction: false in tests to see actual error messages, but test both modes to ensure your error handling works correctly.
Authentication in Tests
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: falseto disable auth middleware - Set custom
testUserId,testUserEmail,testAuthTokenfor different test scenarios - Use different auth configs for different test suites
Metadata Validation
Real next-safe-action:
- Metadata validation happens in middleware
- Invalid metadata throws errors
safemocker:
- Metadata validation is replicated
- Use
createMetadataValidatedActionClient()orcreateCompleteActionClient()with metadata schema - Invalid metadata throws
'Invalid action metadata'error
Recommendation: Always provide metadata in tests to match real usage patterns.
Jest: "Cannot find module 'next-safe-action'"
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 loadedVitest: Mock not working
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.
Type Errors: "Module has no exported member"
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.
Context not available in handler
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',
},
});Validation errors not appearing
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 errorNested validation errors use dot notation
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?.emailFrom Manual Mocks
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
This package is designed to be standalone and extractable. Contributions welcome!
MIT
JSONbored
- 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