NoOpLogger
Default. Silent - no output. Zero overhead for production-like tests.
Scenarist provides a flexible logging infrastructure for debugging scenario matching, state management, and request handling. Logging is disabled by default (silent) and can be enabled when needed.
import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';// Or: import { createScenarist, createConsoleLogger } from '@scenarist/nextjs-adapter/app';
const scenarist = createScenarist({ enabled: true, scenarios,
// Enable logging with pretty format at info level logger: createConsoleLogger({ level: 'info' }),});Scenarist includes two logger implementations:
NoOpLogger
Default. Silent - no output. Zero overhead for production-like tests.
ConsoleLogger
Human-readable or JSON output. Great for debugging scenario matching issues.
The ConsoleLogger provides human-readable or JSON output to the console.
import { createConsoleLogger } from '@scenarist/express-adapter';
// Pretty format (default) - colored, human-readableconst logger = createConsoleLogger({ level: 'info' });
// JSON format - for log aggregation toolsconst logger = createConsoleLogger({ level: 'info', format: 'json' });
// Debug level with category filteringconst logger = createConsoleLogger({ level: 'debug', categories: ['matching', 'scenario'],});type ConsoleLoggerConfig = { /** Minimum log level to output */ level: 'error' | 'warn' | 'info' | 'debug' | 'trace';
/** Output format (default: 'pretty') */ format?: 'pretty' | 'json';
/** Filter to specific categories (default: all categories) */ categories?: LogCategory[];};Levels are hierarchical - each level includes all less verbose levels:
| Level | Description | Use Case |
|---|---|---|
error | Critical failures | Scenario not found, invalid config |
warn | Potential issues | No mock matched, sequence exhausted |
info | Key events | Scenario switched, mock selected |
debug | Decision logic | Match criteria evaluation, specificity scores |
trace | Verbose details | Request/response bodies, template replacement |
Filter logs to specific areas of concern:
| Category | Description | Example Messages |
|---|---|---|
lifecycle | Startup, shutdown | scenarist_started, scenarist_stopped |
scenario | Scenario switching | scenario_switched, scenario_not_found |
matching | Mock selection | mock_selected, mock_no_match, mock_candidates_found |
sequence | Sequence state | sequence_advanced, sequence_exhausted |
state | State capture | state_captured, state_injected |
template | Template replacement | template_replaced |
request | Request lifecycle | request_received, response_sent |
// Only show matching and scenario logsconst logger = createConsoleLogger({ level: 'debug', categories: ['matching', 'scenario'],});12:34:56.789 INF 🎬 [test-user-login] scenario | scenario_switched scenarioId=premium-user12:34:56.801 INF 🎯 [test-user-login] matching | mock_selected mockIndex=2 specificity=512:34:56.802 DBG 🎯 [test-user-login] matching | mock_match_evaluated mockIndex=0 matched=false12:34:56.803 DBG 🎯 [test-user-login] matching | mock_match_evaluated mockIndex=1 matched=true12:34:56.810 INF 💾 [test-user-login] state | state_captured key=userId value=user-12312:34:56.815 WRN 🎯 [test-user-login] matching | mock_no_match url=/api/unknown candidateCount=0Features:
ERR, WRN, INF, DBG, TRC{"level":"info","category":"scenario","message":"scenario_switched","testId":"test-user-login","scenarioId":"premium-user","timestamp":1732650896789}{"level":"info","category":"matching","message":"mock_selected","testId":"test-user-login","data":{"mockIndex":2,"specificity":5},"timestamp":1732650896801}{"level":"debug","category":"matching","message":"mock_match_evaluated","testId":"test-user-login","data":{"mockIndex":0,"matched":false},"timestamp":1732650896802}Use for: Log aggregation (Datadog, Splunk), structured queries, production debugging
When running tests in parallel, each test gets a unique test ID. The pretty format assigns persistent colors to each test ID, making it easy to trace logs from concurrent tests:
12:34:56.789 INF 🎯 [test-checkout-flow] matching | mock_selected12:34:56.790 INF 🎯 [test-payment-process] matching | mock_selected12:34:56.791 INF 🎯 [test-user-login] matching | mock_selected12:34:56.792 INF 💾 [test-checkout-flow] state | state_capturedEach test ID gets a unique color that remains consistent throughout the test run.
The default logger - does nothing. Use for clean test output:
import { noOpLogger, createNoOpLogger } from '@scenarist/express-adapter';
// Singleton instance (recommended)const logger = noOpLogger;
// Or create new instance (functionally identical)const logger = createNoOpLogger();When to use:
For easy toggling without code changes:
import { createScenarist, createConsoleLogger, noOpLogger, type LogLevel, type LogFormat,} from '@scenarist/express-adapter';
// Type-safe environment variable parsingconst LOG_LEVELS: ReadonlyArray<Exclude<LogLevel, 'silent'>> = [ 'error', 'warn', 'info', 'debug', 'trace',];const LOG_FORMATS: ReadonlyArray<LogFormat> = ['pretty', 'json'];
const parseLogLevel = (value: string | undefined): Exclude<LogLevel, 'silent'> => LOG_LEVELS.includes(value as Exclude<LogLevel, 'silent'>) ? (value as Exclude<LogLevel, 'silent'>) : 'info';
const parseLogFormat = (value: string | undefined): LogFormat => LOG_FORMATS.includes(value as LogFormat) ? (value as LogFormat) : 'pretty';
const scenarist = createScenarist({ enabled: process.env.NODE_ENV === 'test', scenarios,
// Enable logging via SCENARIST_LOG=1 logger: process.env.SCENARIST_LOG ? createConsoleLogger({ level: parseLogLevel(process.env.SCENARIST_LOG_LEVEL), format: parseLogFormat(process.env.SCENARIST_LOG_FORMAT), }) : noOpLogger,});Then run tests with logging:
# Enable info-level loggingSCENARIST_LOG=1 pnpm test
# Enable debug-level loggingSCENARIST_LOG=1 SCENARIST_LOG_LEVEL=debug pnpm test
# JSON format for CISCENARIST_LOG=1 SCENARIST_LOG_FORMAT=json pnpm testAdd disableConsoleIntercept: true to your vitest.config.ts:
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { // REQUIRED: Show Scenarist logging output for all tests // Without this, console output is only shown for failed tests disableConsoleIntercept: true,
// ... your other config },});Then run tests with logging enabled:
SCENARIST_LOG=1 pnpm testYou’ll see output like:
09:00:12.269 DBG [unknown] 🎬 scenario scenario_registered scenarioId="default" mockCount=409:00:12.270 DBG [unknown] 🎬 scenario scenario_registered scenarioId="success" mockCount=309:00:12.308 INF [my-test-id] 🎬 scenario scenario_switchedEnable debug logging for the matching category:
const logger = createConsoleLogger({ level: 'debug', categories: ['matching'],});You’ll see:
mock_candidates_found - How many mocks could potentially matchmock_match_evaluated - Each mock’s evaluation result and specificity scoremock_selected - Which mock was chosen and whymock_no_match - When no mock matched (with the URL that failed)Enable info logging for the scenario category:
const logger = createConsoleLogger({ level: 'info', categories: ['scenario'],});You’ll see:
scenario_switched - When a test switches scenariosscenario_not_found - When a requested scenario doesn’t existEnable debug logging for the state category:
const logger = createConsoleLogger({ level: 'debug', categories: ['state'],});You’ll see:
state_captured - When state is extracted from a responsestate_injected - When state is injected into a mockImplement the Logger interface to integrate with your logging library:
import type { Logger, LogCategory, LogContext } from '@scenarist/core';import pino from 'pino';
const pinoInstance = pino({ level: 'debug' });
const pinoLogger: Logger = { error: (category, message, context, data) => pinoInstance.error({ category, ...context, ...data }, message),
warn: (category, message, context, data) => pinoInstance.warn({ category, ...context, ...data }, message),
info: (category, message, context, data) => pinoInstance.info({ category, ...context, ...data }, message),
debug: (category, message, context, data) => pinoInstance.debug({ category, ...context, ...data }, message),
trace: (category, message, context, data) => pinoInstance.trace({ category, ...context, ...data }, message),
isEnabled: (level) => pinoInstance.isLevelEnabled(level),};
const scenarist = createScenarist({ enabled: true, scenarios, logger: pinoLogger,});createConsoleLogger(config)Create a console logger with the specified configuration.
function createConsoleLogger(config: ConsoleLoggerConfig): Logger;
type ConsoleLoggerConfig = { level: Exclude<LogLevel, 'silent'>; format?: 'pretty' | 'json'; categories?: ReadonlyArray<LogCategory>;};noOpLoggerSingleton logger that does nothing.
const noOpLogger: Logger;createNoOpLogger()Factory function to create a no-op logger.
function createNoOpLogger(): Logger;Logger InterfaceThe port interface all loggers implement:
interface Logger { error(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void; warn(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void; info(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void; debug(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void; trace(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void; isEnabled(level: Exclude<LogLevel, 'silent'>): boolean;}type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
type LogCategory = | 'lifecycle' | 'scenario' | 'matching' | 'sequence' | 'state' | 'template' | 'request';
type LogContext = { readonly testId?: string; readonly scenarioId?: string; readonly requestUrl?: string; readonly requestMethod?: string;};Scenarist’s logging is designed for minimal overhead:
When logging is disabled (the default), noOpLogger is used. Its methods are empty functions that V8’s JIT compiler can inline and potentially eliminate entirely:
// NoOpLogger implementation - effectively no-opexport const noOpLogger: Logger = { error: () => {}, warn: () => {}, info: () => {}, debug: () => {}, trace: () => {}, isEnabled: () => false,};For high-frequency code paths, use isEnabled() to skip expensive string formatting when logging is disabled:
// Check before formatting expensive contextif (logger.isEnabled('debug')) { logger.debug('matching', 'Details', context, { body: JSON.stringify(largeObject), // Only formatted if needed headers: Object.fromEntries(request.headers), });}This pattern ensures that expensive operations like:
…are only performed when the output will actually be used.
Simple function calls like logger.debug(category, message, context, data) are candidates for V8’s inlining optimization when hot. The straightforward signature and return type (void) help V8 make efficient inlining decisions.