Skip to content

Error Handling

Scenarist provides comprehensive error handling with rich context, actionable hints, and configurable behaviors. All errors extend ScenaristError and include machine-readable codes for programmatic handling.

CodeDescriptionWhen Thrown
SCENARIO_NOT_FOUNDAttempted to switch to a scenario that hasn’t been registeredswitchScenario() with unknown ID (returned as result.error, not thrown)
DUPLICATE_SCENARIOAttempted to register a scenario with an ID that already existsregisterScenario() with duplicate ID
NO_MOCK_FOUNDNo mock matched the incoming requestUnmatched request with errorBehaviors.onNoMockFound: 'throw'
SEQUENCE_EXHAUSTEDSequence with repeat: 'none' has no more responses and no fallback mockRequest to exhausted sequence with errorBehaviors.onSequenceExhausted: 'throw'
MISSING_TEST_IDNo test ID could be resolved for the requestRequest without a test ID header, with defaultTestId: '' and errorBehaviors.onMissingTestId: 'throw'
VALIDATION_ERRORScenario definition failed schema validationregisterScenario() with invalid definition

All Scenarist errors extend ScenaristError, which provides:

class ScenaristError extends Error {
readonly code: string; // Machine-readable error code
readonly context: ErrorContext; // Rich context information
}
type ErrorContext = {
readonly testId?: string; // Test that triggered the error
readonly scenarioId?: string; // Scenario involved
readonly requestInfo?: { // Request details
method: string;
url: string;
};
readonly hint?: string; // Actionable guidance
};

switchScenario() returns a result instead of throwing. The error is typed as Error; at runtime it is a ScenaristError:

import { ScenaristError } from '@scenarist/core';
const result = scenarist.switchScenario('test-123', scenarioId);
if (!result.success && result.error instanceof ScenaristError) {
console.log(result.error.code); // 'SCENARIO_NOT_FOUND'
console.log(result.error.message); // "Scenario '...' not found..."
console.log(result.error.context);
// {
// testId: 'test-123',
// scenarioId: '...',
// hint: 'Make sure to register the scenario before switching...'
// }
}

The Express adapter type-checks scenario IDs against your scenarios object; the Next.js adapters accept any string. Over HTTP, POST /__scenario__ with an unknown scenario responds 400 with the error message.

Control how Scenarist handles request-time error conditions using errorBehaviors:

import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test',
scenarios,
logger: createConsoleLogger({ level: 'warn' }),
errorBehaviors: {
onNoMockFound: 'warn', // Log a warning, then strictMode decides
onSequenceExhausted: 'throw', // Respond 500 with SEQUENCE_EXHAUSTED
},
});

ignore

Default. Continue silently. strictMode decides what happens next.

warn

Log a warning through the configured logger, then let strictMode decide. Without a logger, this behaves like ignore.

throw

Respond with HTTP 500 and a JSON body containing the error code and message. The request your application made receives that response.

A throw response looks like this:

{
"error": "Internal mock server error",
"message": "No mock matched for GET https://api.example.com/users",
"code": "NO_MOCK_FOUND"
}

All behaviors default to ignore, so an unmatched request behaves exactly as strictMode says:

const DEFAULT_ERROR_BEHAVIORS = {
onNoMockFound: 'ignore',
onSequenceExhausted: 'ignore',
onMissingTestId: 'ignore',
};

Adapters fall back to defaultTestId ('default-test') when a request has no x-scenarist-test-id header, so by default a test ID is always resolved and onMissingTestId never fires. To make a missing header an error, disable the fallback:

const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test', // Express; in Next.js use enabled: true
scenarios,
defaultTestId: '',
errorBehaviors: { onMissingTestId: 'throw' },
});

When several instances are started in one process (for example, App Router and Pages Router adapters in the same Next.js app), each request is offered to every instance. A throw from one instance is used only if no other instance has a mock for the request.

Scenario definitions are validated at registration time using Zod schemas. Validation errors include the path to the invalid field:

try {
manager.registerScenario({
id: 'test',
name: '', // Empty name - validation error
description: 'Test scenario',
mocks: [
{
method: 'GET',
url: '', // Empty URL - validation error
response: { status: 600 }, // Invalid status - validation error
},
],
});
} catch (error) {
console.log(error.code); // 'VALIDATION_ERROR'
console.log(error.message);
// 'Invalid scenario definition for 'test': name: Too small: expected string to have >=1 characters, mocks.0.url: Too small: expected string to have >=1 characters, mocks.0.response.status: Too big: expected number to be <=599'
}
FieldRule
idNon-empty string
nameNon-empty string
urlNon-empty string or valid RegExp
statusInteger between 100-599
sequence.responsesAt least one response
stateResponse.conditions[].whenAt least one key
afterResponse.setStateAt least one key

Validate your test setup by catching registration errors:

describe('API tests', () => {
const scenarios = {
default: createDefaultScenario(),
error: createErrorScenario(),
};
// Validate all scenarios at test suite startup
beforeAll(() => {
for (const [id, scenario] of Object.entries(scenarios)) {
try {
manager.registerScenario(scenario);
} catch (error) {
if (error instanceof ScenaristError) {
throw new Error(
`Invalid scenario '${id}': ${error.message}\nHint: ${error.context.hint}`
);
}
throw error;
}
}
});
});

Use warn locally to surface issues without blocking, and throw in CI:

const scenarist = createScenarist({
enabled: true,
scenarios,
logger: createConsoleLogger({ level: 'warn' }),
errorBehaviors: {
onNoMockFound: process.env.CI ? 'throw' : 'warn',
onSequenceExhausted: process.env.CI ? 'throw' : 'warn',
},
});

Request-time errors (NO_MOCK_FOUND, SEQUENCE_EXHAUSTED, MISSING_TEST_ID) are not thrown into your application code. With throw, the external request your app made receives a 500 response whose body carries the code, so your app’s own error handling runs:

import { ErrorCodes } from '@scenarist/core';
// Errors thrown while handling a mocked request do not propagate to your code.
// The request receives a 500 JSON response: { error, message, code }
const response = await fetch('https://api.example.com/users');
if (response.status === 500) {
const body = await response.json();
switch (body.code) {
case ErrorCodes.NO_MOCK_FOUND:
console.log('No mock matched:', body.message);
break;
case ErrorCodes.SEQUENCE_EXHAUSTED:
console.log('Sequence exhausted for mock');
break;
default:
console.log('Scenarist error:', body.message);
}
}

Switching errors are returned, not thrown: check result.success (see Example Error).

Registration errors (DUPLICATE_SCENARIO, VALIDATION_ERROR) are thrown as ScenaristError when scenarios are registered, which adapters do inside createScenarist(). See Validation Errors.

export const ErrorCodes = {
SCENARIO_NOT_FOUND: 'SCENARIO_NOT_FOUND',
DUPLICATE_SCENARIO: 'DUPLICATE_SCENARIO',
NO_MOCK_FOUND: 'NO_MOCK_FOUND',
SEQUENCE_EXHAUSTED: 'SEQUENCE_EXHAUSTED',
MISSING_TEST_ID: 'MISSING_TEST_ID',
VALIDATION_ERROR: 'VALIDATION_ERROR',
} as const;
type ErrorBehavior = 'throw' | 'warn' | 'ignore';
type ErrorBehaviors = {
readonly onNoMockFound: ErrorBehavior;
readonly onSequenceExhausted: ErrorBehavior;
readonly onMissingTestId: ErrorBehavior;
};
class ScenaristError extends Error {
constructor(
message: string,
options: {
code: string;
context: ErrorContext;
}
);
readonly code: string;
readonly context: ErrorContext;
}