Architecture
Scenarist uses hexagonal architecture (also called ports and adapters) to maintain complete framework independence. This means the core scenario management logic has zero framework dependencies, while thin adapters integrate with Express, Next.js, Fastify, and other frameworks.
Why This Architecture Matters
Section titled “Why This Architecture Matters”The Testing Gap Scenarist Fills:
Unit tests CAN test server-side logic, but require extensive code-level mocking (request/response objects, session state, auth context, middleware chains, database connections). These mocks create distance from production—bugs can hide in the gap between mocked and real execution.
Browser tests give production-like execution, but testing multiple scenarios requires either complex per-scenario mocking, server restarts (impractical in CI), or hitting real external APIs (slow, flaky, expensive). Result: browser tests typically cover only the happy path.
What developers actually need: Test backend logic (API routes, validation, middleware, business rules, Server Components) through real HTTP requests with multiple scenarios—without external API dependencies and without server restarts.
How Scenarist’s architecture solves this:
- Framework-agnostic core handles scenario switching, test isolation, and response selection
- Thin adapters integrate with any framework (Express, Next.js, Remix, etc.)
- Your entire backend executes normally (middleware chains, validation, business logic, Server Components)
- Only external APIs are mocked (Stripe, SendGrid, Auth0, etc.)
This architecture ensures your code runs in production-like conditions (real middleware, real auth, real routing) while still enabling comprehensive scenario testing.
The Hexagon: Framework-Agnostic Core
Section titled “The Hexagon: Framework-Agnostic Core”The internal core package contains all domain logic with zero dependencies on any web framework. Users interact with this through adapters - the core is not installed directly:
Core Responsibilities
Section titled “Core Responsibilities”1. Scenario Management
ScenarioRegistry: Stores scenario definitions (in-memory or external)ScenarioStore: Tracks active scenario per test IDScenarioManager: Coordinates registration, switching, and retrieval
2. Test ID Isolation
StateManager: Manages captured state per test IDSequenceTracker: Tracks sequence positions per test ID- Each test gets isolated state via unique test ID header
3. Dynamic Responses
ResponseSelector: Selects responses based on request content matching- Sequence management: Advances position, handles repeat modes
- State capture/injection: Template replacement with
{{state.key}}syntax
Ports: Behavior Contracts
Section titled “Ports: Behavior Contracts”The core defines interfaces (ports) that adapters must implement:
// Driving ports (called BY core)interface ScenarioManager { registerScenario(definition: ScenaristScenario): void; switchScenario(testId: string, scenarioId: string): ScenaristResult<void>; getActiveScenario(testId: string): ActiveScenario | undefined; listScenarios(): ReadonlyArray<ScenaristScenario>;}
// Driven ports (called by adapters)interface RequestContext { getTestId(): string; getBody(): unknown; getHeaders(): Record<string, string>; getQuery(): Record<string, string>;}
// Driven port for observabilityinterface Logger { info(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void; debug(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void; // ... error, warn, trace methods}
interface ScenarioRegistry { register(definition: ScenaristScenario): void; get(scenarioId: string): ScenaristScenario | undefined; list(): ReadonlyArray<ScenaristScenario>;}
interface ScenarioStore { set(testId: string, scenario: ActiveScenario): void; get(testId: string): ActiveScenario | undefined; delete(testId: string): void;}Why interfaces for ports?
- Signal implementation contracts clearly
- Better TypeScript errors when implementing
- Conventional in hexagonal architecture
- Class-friendly for adapter implementations
- (See “Why types for data?” below for complementary rationale)
Data Types: Immutable Structures
Section titled “Data Types: Immutable Structures”All data structures use type (not interface) with readonly:
type ScenaristScenario = { readonly id: string; readonly name: string; readonly description?: string; readonly mocks: ReadonlyArray<ScenaristMock>;};
type ScenaristMock = { readonly method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; readonly url: string; readonly response?: MockResponse; readonly sequence?: ResponseSequence; readonly match?: MatchCriteria; readonly captureState?: Record<string, StateCapture>;};Why types for data?
- Emphasizes immutability
- Better for unions, intersections, mapped types
- Functional programming alignment
- Prevents accidental mutations
Framework Adapters: Thin Integration Layers
Section titled “Framework Adapters: Thin Integration Layers”Adapters are small (~100 lines) framework-specific integration layers. They translate between framework concepts and core ports.
Adapter Responsibilities
Section titled “Adapter Responsibilities”Each adapter does three things only:
1. Extract Request Context (Framework-Specific)
// Expressclass ExpressRequestContext implements RequestContext { constructor(private req: Request) {}
getTestId(): string { return this.req.headers['x-scenarist-test-id'] as string || 'default-test'; }
getBody(): unknown { return this.req.body; }
getHeaders(): Record<string, string> { return this.req.headers as Record<string, string>; }}
// Next.js App Routerclass NextAppRequestContext implements RequestContext { constructor(private request: Request) {}
getTestId(): string { return this.request.headers.get('x-scenarist-test-id') || 'default-test'; }
getBody(): unknown { // Must be parsed before constructing context return this.parsedBody; }}2. Wire Up Scenario Endpoints (Framework-Specific)
// Expressapp.post('/__scenario__', async (req, res) => { const testId = extractTestId(req); const { scenario } = req.body;
const result = scenarist.scenarioManager.switchScenario(testId, scenario);
if (result.success) { res.json({ success: true }); } else { res.status(404).json({ success: false, error: result.error.message }); }});
// Next.js App Routerexport async function POST(request: Request) { const testId = request.headers.get('x-scenarist-test-id') || 'default-test'; const { scenario } = await request.json();
const result = scenarist.scenarioManager.switchScenario(testId, scenario);
return Response.json( result.success ? { success: true } : { success: false, error: result.error.message }, { status: result.success ? 200 : 404 } );}3. Delegate Everything Else to Core
All scenario management, response selection, state tracking, and sequence advancement happens in the framework-agnostic core. Adapters are translation layers only.
Why Adapters Stay Thin
Section titled “Why Adapters Stay Thin”Adapter anti-patterns (avoided):
- ❌ Duplicating scenario management logic
- ❌ Implementing response selection
- ❌ Managing test ID isolation
- ❌ Handling state capture/injection
Correct adapter pattern:
- ✅ Extract request context (framework-specific)
- ✅ Create endpoint routes (framework-specific)
- ✅ Delegate to core for all logic (framework-agnostic)
MSW Integration Layer
Section titled “MSW Integration Layer”The MSW adapter (@scenarist/msw-adapter) converts framework-agnostic scenario definitions into MSW handlers:
MSW Adapter Responsibilities
Section titled “MSW Adapter Responsibilities”1. Convert Mock Definitions to MSW Handlers
const mockDefinition: ScenaristMock = { method: 'GET', url: '/api/users/:id', response: { status: 200, body: { name: 'John Doe' } }};
// MSW Adapter converts to:http.get('/api/users/:id', async ({ request }) => { // Extract context, select response via core, return HttpResponse return HttpResponse.json({ name: 'John Doe' }, { status: 200 });});2. URL Pattern Matching
- Exact match:
/api/users(string literal) - Glob patterns:
/api/*or/api/users/* - Path parameters:
/api/users/:id(extracted and available in context)
3. Dynamic Handler with Fallback
For each HTTP request:
- Extract test ID from request headers
- Get active scenario for test ID from core
- If scenario exists, use scenario mocks
- If no scenario, fall back to default scenario
- Delegate response selection to core’s
ResponseSelector
Why MSW Integration is Separate
Section titled “Why MSW Integration is Separate”The MSW adapter is not part of core because:
- MSW is a framework for mocking HTTP (runtime dependency)
- Core must remain dependency-free (pure TypeScript)
- MSW adapter can be swapped for alternatives (Mirage, Nock, etc.)
Data Flow: Request to Response
Section titled “Data Flow: Request to Response”Here’s how a request flows through Scenarist:
Step-by-Step Breakdown
Section titled “Step-by-Step Breakdown”1. Scenario Switching
- Test calls
switchScenario(page, 'premiumUser') - Playwright helper sends POST to
/__scenario__ - Adapter extracts test ID, calls core’s
ScenarioManager - Core stores
{ testId: 'uuid-123', scenarioId: 'premiumUser' }
2. Page Navigation
- Browser navigates to
/dashboardwithx-scenarist-test-id: uuid-123header - Adapter extracts
RequestContextfrom framework request - Your route handler/Server Component executes normally
3. External API Call
- Your code calls
fetch('https://api.external.com/user') - MSW intercepts the external call
- MSW asks core for active scenario:
uuid-123→premiumUser - MSW delegates response selection to core’s
ResponseSelector
4. Response Selection
- Core checks match criteria (body, headers, query params)
- Core advances sequences if applicable
- Core injects state via templates if applicable
- Core returns selected response to MSW
- MSW returns response to your code
5. Your Code Continues
- Your route handler receives mocked response
- Your business logic executes with mocked data
- Your Server Component renders with real logic
- HTML sent to browser
Dependency Injection Pattern
Section titled “Dependency Injection Pattern”The core uses dependency injection for all port implementations:
// ❌ WRONG - Creating implementation internallyexport const createScenarioManager = () => { const registry = new Map<string, ScenaristScenario>(); // Hardcoded! // ...};
// ✅ CORRECT - Injecting portsexport const createScenarioManager = ({ registry, // Injected store, // Injected}: { registry: ScenarioRegistry; store: ScenarioStore;}): ScenarioManager => { return { registerScenario(definition) { registry.register(definition); // Delegate to injected port }, // ... };};Why dependency injection matters:
- ✅ Testable (inject mocks for unit testing)
- ✅ True hexagonal architecture
- ✅ Follows dependency inversion principle
- ✅ Clean separation between domain logic and infrastructure
Default Implementations
Section titled “Default Implementations”The core provides default in-memory implementations. Note: This is internal implementation detail - users interact through adapters, not directly with core:
// Internal implementation (adapters use this, users don't import directly)import { createScenarioManager, InMemoryScenarioRegistry, InMemoryScenarioStore, InMemoryStateManager, InMemorySequenceTracker, noOpLogger, // Silent by default} from '@scenarist/core'; // Internal package
const registry = new InMemoryScenarioRegistry();const store = new InMemoryScenarioStore();const stateManager = new InMemoryStateManager();const sequenceTracker = new InMemorySequenceTracker();
const scenarioManager = createScenarioManager({ registry, store, stateManager, sequenceTracker, logger: noOpLogger, // Injected dependency});For debugging, you can inject a ConsoleLogger instead. See Logging Reference for details.
Declarative Patterns: Why Scenarios Are Data, Not Functions
Section titled “Declarative Patterns: Why Scenarios Are Data, Not Functions”Scenarist enforces declarative patterns - scenarios describe WHAT to return, not HOW to decide. This is an intentional constraint that leads to better test scenarios.
// ✅ DECLARATIVE - Pure data describing intenttype ScenaristMock = { readonly method: 'GET' | 'POST'; readonly url: string | RegExp; // String or RegExp pattern readonly match?: { // Explicit match criteria body?: Record<string, unknown>; headers?: Record<string, string>; }; readonly response: { readonly status: number; readonly body?: unknown; };};
// ❌ IMPERATIVE - Hidden logic in functionsconst handler = (req: Request) => { if (req.headers.get('x-tier') === 'premium') { return premiumResponse; } return standardResponse; // Logic hidden in function body};Why declarative patterns matter:
| Imperative (Functions) | Declarative (Data) |
|---|---|
| Logic hidden in function bodies | Intent visible in match criteria |
| Can’t inspect without executing | Can validate statically |
| Manual if/else ordering | Automatic specificity-based selection |
| Closures capture external state | No hidden dependencies |
| Routing hacks possible | No routing hacks |
The constraint in action:
// ❌ IMPERATIVE - What we preventconst handler = (request) => { const tier = request.headers.get('x-tier'); const referer = request.headers.get('referer'); if (referer?.includes('/premium')) { return premiumResponse; // Hidden routing hack! } if (tier === 'premium') return premiumResponse; return standardResponse;};
// ✅ DECLARATIVE - What Scenarist enforcesconst mocks = [ { url: '/api/products', match: { headers: { 'x-tier': 'premium' } }, response: premiumResponse }, { url: '/api/products', response: standardResponse // Fallback (no match criteria) }];// Intent is visible: "premium tier header → premium response"This mirrors React’s design philosophy: React could allow imperative DOM manipulation, but enforces declarative JSX because it’s clearer, more composable, and easier to reason about. Scenarist makes the same choice for test scenarios.
Runtime conversion: MSW handlers are created at runtime from declarative definitions:
const toMSWHandler = (mock: ScenaristMock): HttpHandler => { return http[mock.method.toLowerCase()](mock.url, async () => { if (mock.response.delay) await delay(mock.response.delay); return HttpResponse.json(mock.response.body, { status: mock.response.status, headers: mock.response.headers, }); });};Benefits of Hexagonal Architecture
Section titled “Benefits of Hexagonal Architecture”1. Framework Independence
- Write scenarios once, use with Express, Next.js, Remix, Fastify
- Switching frameworks doesn’t require rewriting tests
- Core improvements benefit all frameworks immediately
2. Testability
- Core has zero dependencies (easy to test)
- Adapters are thin (easy to test)
- Ports can be mocked for unit testing
3. Extensibility
- Add new frameworks by writing ~100 line adapter
- Add new storage backends by implementing ports
- Add new features in core, all adapters benefit
4. Maintainability
- Clear separation of concerns
- Framework-specific code isolated to adapters
- Domain logic centralized in core
Next Steps
Section titled “Next Steps”- Scenario Format - How to define and structure scenarios
- Dynamic Responses - Request matching, sequences, and state
- Framework Guides - Integrating with your framework