@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!
Maintainers
Readme
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
- 🚀 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
✨ 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- 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
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 injectiongetUserProfile- Optional authentication, UUID validationupdateUserSettings- Partial updates, enum validationdeleteUser- 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 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)
⚠️ 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
- ✅
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.
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.
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
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.
🔧 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 loadedProblem: 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 errorProblem: 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
