Next.js App Router: setting up Next.js App Router and testing React Server Components, streaming, Server Actions, and auth flows # Next.js > Using Scenarist with Next.js (App Router and Pages Router) ## Testing Next.js with Scenarist Scenarist provides first-class support for testing Next.js applications, addressing the challenges outlined in the [official Next.js testing documentation](https://nextjs.org/docs/app/building-your-application/testing). ### The Challenge Next.js recommends end-to-end testing for Server Components because *“async Server Components are new to the React ecosystem.”* Unit testing requires mocking Next.js internals (fetch, cookies, headers), creating distance from production execution. Traditional testing approaches face similar challenges across both routing paradigms: * Mocking framework internals creates distance from production behavior * Testing server-side logic requires complex setup * End-to-end tests are too slow for comprehensive scenario coverage ### How Scenarist Helps Scenarist enables testing Next.js applications through real HTTP requests: * Test server-side code without mocking framework internals * Verify different external API scenarios with runtime switching * Run parallel tests without interference * Switch scenarios per test against one running server, with no restarts * **Automatic singleton protection** - Handles Next.js module duplication for you (no `globalThis` boilerplate needed) ## Next.js Support Scenarist supports both Next.js routing patterns: * **[App Router](/frameworks/nextjs-app-router/)** - Server Components, Server Actions, Route Handlers * **[Pages Router](/frameworks/nextjs-pages-router/)** - API Routes, getServerSideProps, getStaticProps ## Choose Your Router ### App Router (Modern) The App Router introduces React Server Components, allowing server-side rendering with direct data fetching. Scenarist enables testing these components without mocking Next.js internals. [**Get started with App Router →**](/frameworks/nextjs-app-router/) **Best for:** * New Next.js projects (Scenarist supports Next.js 14, 15 and 16) * Server Components and streaming * Server Actions for mutations * Route Handlers for API endpoints ### Pages Router (Traditional) The Pages Router uses the traditional pages directory with API routes and data fetching methods. Scenarist enables testing API routes and server-side rendering without complex mocking. [**Get started with Pages Router →**](/frameworks/nextjs-pages-router/) **Best for:** * Existing Next.js projects * Traditional API routes * getServerSideProps and getStaticProps * Gradual migration strategies ## What Makes Next.js Testing Different **Server-Side Execution** - Both routers execute code on the server that needs testing with different external API scenarios. **Framework Internals** - Traditional unit testing requires mocking Next.js internals (fetch, cookies, headers), creating distance from production. **Parallel Testing** - Scenarist’s test ID isolation allows concurrent tests with different scenarios, maintaining fast test execution. ## Next Steps Choose your routing paradigm to get started: * [App Router Guide →](/frameworks/nextjs-app-router/) * [Pages Router Guide →](/frameworks/nextjs-pages-router/) # Next.js App Router > Using Scenarist with Next.js App Router (Server Components, Route Handlers, Server Actions) ## Testing Next.js App Router with Scenarist The Next.js App Router introduces React Server Components, enabling server-side rendering with direct data fetching. Scenarist provides first-class support for testing App Router applications, addressing the challenges outlined in the [official Next.js testing documentation](https://nextjs.org/docs/app/building-your-application/testing). ### The Challenge Next.js recommends end-to-end testing for Server Components because *“async Server Components are new to the React ecosystem.”* Unit testing requires mocking Next.js internals (fetch, cookies, headers), creating distance from production execution. **Specific App Router challenges:** * Server Components execute asynchronously with no standard testing approach * Route Handlers need testing with different external API scenarios * Server Actions require testing mutations without complex mocking * Framework internals (fetch, cookies, headers) must be mocked for unit tests ### How Scenarist Helps Scenarist enables HTTP-level testing for App Router applications: * **Test Server Components** without mocking Next.js internals * **Test Route Handlers** with different external API scenarios * **Test Server Actions** with runtime scenario switching * **Run parallel tests** without interference * **No restarts** - each test switches scenario at runtime against one running server * **Automatic singleton protection** - Handles Next.js module duplication for you (no `globalThis` boilerplate needed) ## App Router Features Supported ### Server Components Test async Server Components with real HTTP requests: app/products/page.tsx ```typescript import { headers } from 'next/headers'; import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app'; export default async function ProductsPage() { const response = await fetch('https://api.stripe.com/v1/products', { headers: getScenaristHeadersFromReadonlyHeaders(await headers()), cache: 'no-store', }); const { data: products } = await response.json(); return ; } // Test without mocking Next.js internals test('renders products from external API', async ({ page, switchScenario }) => { await switchScenario(page, 'premiumUser'); await page.goto('/products'); await expect(page.getByText('Premium Product')).toBeVisible(); }); ``` ### Route Handlers Test API routes with different scenarios: app/api/checkout/route.ts ```typescript import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app"; export async function POST(request: Request) { const body = await request.json(); const response = await fetch("https://api.stripe.com/v1/charges", { method: "POST", headers: getScenaristHeaders(request), body: JSON.stringify(body), cache: "no-store", }); return Response.json(await response.json()); } // Test with different payment scenarios test("processes successful payment", async ({ page, switchScenario }) => { const testId = await switchScenario(page, "paymentSuccess"); const response = await page.request.post("/api/checkout", { headers: { "x-scenarist-test-id": testId }, data: { amount: 5000, token: "tok_test" }, }); expect(response.ok()).toBe(true); }); ``` ### Server Actions Test mutations with state capture: app/cart/actions.ts ```typescript "use server"; import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { getScenaristHeadersFromReadonlyHeaders } from "@scenarist/nextjs-adapter/app"; export async function addToCart(productId: string) { await fetch("https://api.cart.example.com/add", { method: "POST", headers: getScenaristHeadersFromReadonlyHeaders(await headers()), body: JSON.stringify({ productId }), cache: "no-store", }); revalidatePath("/cart"); } // Test with stateful mocks test("cart maintains state across requests", async ({ page, switchScenario, }) => { await switchScenario(page, "cartWithState"); await page.goto("/products"); await page.getByRole("button", { name: "Add to Cart" }).click(); await page.goto("/cart"); await expect(page.getByText("Product A")).toBeVisible(); }); ``` ## Working Example See Scenarist in action with a complete Next.js App Router application: [**Explore the Next.js App Router Example →**](/frameworks/nextjs-app-router/example-app/) The example demonstrates: * Testing Server Components without mocking Next.js internals * Request matching for tier-based pricing * Sequences for polling scenarios * Stateful mocks for shopping cart functionality * Complete installation and usage instructions **[View source on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example)** ## Getting Started Ready to integrate Scenarist into your Next.js App Router application? [**Get started with App Router →**](/frameworks/nextjs-app-router/getting-started/) ## Key Benefits **Server Components Execute** - Your React Server Components render and run your application logic, not mocked stubs. **API Routes Run Normally** - Your validation, error handling, and business logic all execute. **Test Isolation** - Each test gets isolated scenario state. Run tests in parallel with zero interference. **No App Restart** - Switch scenarios instantly during test execution. **Real HTTP Requests** - Tests make actual HTTP requests to your Next.js app, exercising middleware and routing. # Next.js Example App > Working example demonstrating Scenarist with Next.js App Router ## Overview The Next.js App Router example demonstrates HTTP-level testing for Server Components, Client Components, API routes, and Server Actions using Scenarist. **GitHub:** [apps/nextjs-app-router-example](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example) ## What It Demonstrates This example app showcases all major Scenarist features: ### Core Features * **Server Components** - Test async Server Components without mocking Next.js internals * **Client Components** - Test client-side hydration with backend scenarios * **API Routes** - Test Route Handlers with different external API responses * **Runtime Scenario Switching** - Multiple scenarios running concurrently ### Dynamic Response Features * **Request Matching** - Different responses based on request content (tier-based pricing) * **Sequences** - Polling scenarios (pending → processing → complete) * **Stateful Mocks** - Shopping cart with state capture and injection ## Installation ### Prerequisites * Node.js 22+ * pnpm 11+ ### Clone and Install ```bash # Clone the repository git clone https://github.com/citypaul/scenarist.git cd scenarist # Install dependencies pnpm install # Navigate to Next.js example cd apps/nextjs-app-router-example ``` ## Running the Example ### Development Mode ```bash # Start the Next.js dev server pnpm dev ``` Visit to see the app. ### Run Tests ```bash # Run all tests pnpm test # Run tests in UI mode pnpm test:e2e:ui # Run specific test file pnpm test products-server-components ``` ## Key Files ### Scenarist Setup **`lib/scenarist.ts`** - Scenarist configuration ```typescript import { createScenarist } from '@scenarist/nextjs-adapter/app'; import { scenarios } from './scenarios'; export const scenarist = createScenarist({ enabled: true, scenarios, }); // Start MSW in Node.js environment if (typeof window === 'undefined' && scenarist) { scenarist.start(); } ``` **`app/api/%5F%5Fscenario%5F%5F/route.ts`** - Scenario control endpoint ```typescript import { scenarist } from '../../../lib/scenarist'; const handler = scenarist?.createScenarioEndpoint(); export const POST = handler; export const GET = handler; ``` This creates the `/api/__scenario__` endpoint used by tests to switch scenarios. ### Scenario Definitions **`lib/scenarios.ts`** - All scenario definitions ([view on GitHub](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/lib/scenarios.ts)) Key scenarios: **`default`** - Standard user, successful API responses **`premiumUser`** - Premium tier with request matching ```typescript premiumUser: { id: 'premiumUser', mocks: [{ method: 'GET', url: 'http://localhost:3001/products', match: { headers: { 'x-user-tier': 'premium' } }, response: { status: 200, body: { products: buildProducts('premium') } } }] } ``` **`githubPolling`** - Polling sequence (pending → processing → complete) ```typescript githubPolling: { id: 'githubPolling', mocks: [{ method: 'GET', url: 'http://localhost:3001/github/jobs/:id', sequence: { responses: [ { status: 200, body: { jobId: '123', status: 'pending', progress: 0 } }, { status: 200, body: { jobId: '123', status: 'processing', progress: 50 } }, { status: 200, body: { jobId: '123', status: 'complete', progress: 100 } } ], repeat: 'last' } }] } ``` **`cartWithState`** - Stateful shopping cart ```typescript cartWithState: { id: 'cartWithState', name: 'Shopping Cart with State', description: 'Stateful shopping cart that captures and injects cart items', mocks: [ { method: 'GET', url: 'http://localhost:3001/cart', response: { status: 200, body: { items: '{{state.cartItems}}' } } }, { method: 'PATCH', url: 'http://localhost:3001/cart', captureState: { cartItems: 'body.items' }, response: { status: 200, body: { items: '{{state.cartItems}}' } } } ] } ``` ### Test Examples **`tests/playwright/products-server-components.spec.ts`** - Server Components with request matching ```typescript test('should render products from server component with premium tier', async ({ page, switchScenario }) => { await switchScenario(page, 'premiumUser'); await page.goto('/products?tier=premium'); // Server Component forwards x-user-tier: premium to the products API // Scenarist returns mock matching { headers: { 'x-user-tier': 'premium' } } await expect(page.getByText('Current tier: premium')).toBeVisible(); await expect(page.getByText('£99.99')).toBeVisible(); }); ``` **`tests/playwright/polling-server-components.spec.ts`** - Polling with sequences ```typescript test('should show complete status on third request', async ({ page, switchScenario }) => { await switchScenario(page, 'githubPolling'); // First request: pending await page.goto('/polling?jobId=123'); // Second request: processing await page.reload(); // Third request: complete await page.reload(); await expect(page.getByText('COMPLETE', { exact: true })).toBeVisible(); await expect(page.getByText('100%', { exact: true })).toBeVisible(); }); ``` **`tests/playwright/cart-server-components.spec.ts`** - Stateful mocks with Server Components ```typescript test('should display cart item after adding product via state capture', async ({ page, switchScenario }) => { const testId = await switchScenario(page, 'cartWithState'); // Add product - state captured await page.request.post('http://localhost:3002/api/cart/add', { headers: { 'x-scenarist-test-id': testId }, data: { productId: 'prod-1' } }); await page.goto('/cart-server'); // Cart shows added product - state injected await expect(page.getByText('Product A')).toBeVisible(); }); ``` ## Architecture ### How It Works 1. **Setup** - Next.js app includes the Scenarist scenario endpoint route 2. **Test starts** - Calls `switchScenario()` to set active scenario 3. **HTTP request** - Test makes request to Next.js app 4. **Backend execution** - Server Components, API routes execute normally 5. **External API call** - Intercepted by MSW with scenario-defined response 6. **Test assertion** - Verifies rendered output or API response ### Test Isolation Each test gets a unique test ID: * Scenario switching: `POST /api/__scenario__` with `x-scenarist-test-id` header * All requests include `x-scenarist-test-id` header automatically (Playwright helper) * Server routes requests to correct scenario based on test ID * Parallel tests don’t interfere with each other ### File Structure ## Common Patterns ### Testing Server Components ```typescript // Server Component fetches external API, forwarding the test ID import { headers } from 'next/headers'; import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app'; export default async function ProductsPage() { const response = await fetch('https://api.products.example.com/list', { headers: getScenaristHeadersFromReadonlyHeaders(await headers()), cache: 'no-store', }); const products = await response.json(); return
{products.map(p => )}
; } // Test with different scenarios test('standard products', async ({ page, switchScenario }) => { await switchScenario(page, 'default'); await page.goto('/products'); await expect(page.getByText('Product A')).toBeVisible(); }); test('premium products', async ({ page, switchScenario }) => { await switchScenario(page, 'premiumUser'); await page.goto('/products'); await expect(page.getByText('Premium Product')).toBeVisible(); }); ``` ### Testing with Request Matching Use request content to determine response: ```typescript // Scenario with tier-based pricing mocks: [{ method: 'GET', url: 'https://api.products.example.com/pricing', match: { query: { tier: 'premium' } }, response: { status: 200, body: { price: 799, discount: 20 } } }, { method: 'GET', url: 'https://api.products.example.com/pricing', // No match criteria - fallback for standard tier response: { status: 200, body: { price: 999, discount: 0 } } }] ``` ### Testing Polling Scenarios Use sequences to simulate async operations: ```typescript // Scenario with polling sequence mocks: [{ method: 'GET', url: 'https://api.github.com/repos/user/repo/status', sequence: { responses: [ { status: 200, body: { status: 'pending' } }, { status: 200, body: { status: 'processing' } }, { status: 200, body: { status: 'complete' } } ], repeat: 'last' // After sequence exhausts, repeat last response } }] ``` ## Next Steps * [Next.js App Router Getting Started →](/frameworks/nextjs-app-router/getting-started/) - Integrate Scenarist into your Next.js app * [Request Matching →](/scenarios/request-matching/) - Learn about request content matching * [Sequences →](/scenarios/response-sequences/) - Learn about response sequences * [Stateful Mocks →](/scenarios/stateful-mocks/) - Learn about state capture and injection # Next.js App Router - Getting Started > Set up Scenarist with Next.js App Router in 5 minutes Test your Next.js App Router application with Server Components, Route Handlers, and Server Actions all executing. No mocking of Next.js internals required. ## Installation ```bash npm install @scenarist/nextjs-adapter msw npm install -D @playwright/test @scenarist/playwright-helpers ``` ## 1. Define Scenarios lib/scenarios.ts ```typescript import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/nextjs-adapter/app'; // ✅ RECOMMENDED - Default scenario with complete happy path const defaultScenario: ScenaristScenario = { id: 'default', name: 'Happy Path', description: 'All external APIs succeed with valid responses', mocks: [ // Stripe: Successful payment { method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { id: 'ch_123', status: 'succeeded', amount: 5000 }, }, }, // Auth0: Authenticated standard user { method: 'GET', url: 'https://api.auth0.com/userinfo', response: { status: 200, body: { sub: 'user_123', email: 'john@example.com', tier: 'standard' }, }, }, // SendGrid: Email sent successfully { method: 'POST', url: 'https://api.sendgrid.com/v3/mail/send', response: { status: 202, body: { message_id: 'msg_123' }, }, }, ], }; // Specialized scenario: Override ONLY Auth0 for premium user const premiumUserScenario: ScenaristScenario = { id: 'premiumUser', name: 'Premium User', description: 'Premium tier user, everything else succeeds', mocks: [ // Override: Auth0 returns premium tier { method: 'GET', url: 'https://api.auth0.com/userinfo', response: { status: 200, body: { sub: 'user_456', email: 'premium@example.com', tier: 'premium' }, }, }, // Stripe and SendGrid automatically fall back to default (happy path) ], }; export const scenarios = { default: defaultScenario, premiumUser: premiumUserScenario, } as const satisfies ScenaristScenarios; ``` ## 2. Set Up Scenarist lib/scenarist.ts ```typescript import { createScenarist } from '@scenarist/nextjs-adapter/app'; import { scenarios } from './scenarios'; export const scenarist = createScenarist({ enabled: true, scenarios, }); // Start MSW in Node.js environment if (typeof window === 'undefined' && scenarist) { scenarist.start(); } ``` Create One Instance Use the `export const scenarist` pattern shown above, and import `scenarist` wherever you need it. Call `createScenarist()` in one module only. **Why this matters:** Scenarist handles a Next.js-specific issue for you. It has a [well-documented singleton problem](https://github.com/vercel/next.js/discussions/68572) where webpack bundles the same module multiple times, breaking classic singleton patterns. This is compounded by [MSW's challenges with Next.js's process model](https://github.com/mswjs/msw/issues/1644)—Next.js keeps multiple Node.js processes that make global module patches difficult to maintain. **Scenarist solves this automatically.** The Next.js adapter includes built-in `globalThis` singleton guards that ensure only one MSW instance exists, regardless of how Next.js loads your modules. You don't need to understand Next.js internals or implement manual workarounds—just use `export const scenarist = createScenarist(...)` and Scenarist handles the complexity. Without protection, this causes: * `[MSW] Multiple handlers with the same URL` warnings * Intermittent 500 errors from MSW * Different tests getting wrong scenarios * Scenarios not switching properly **How Scenarist solves this:** The first `createScenarist()` call stores its instance in `global.__scenarist_instance`. Every later call returns that same instance, even when Next.js loads your module more than once, so only one instance and one MSW server ever exist. Later calls ignore the options you pass them, so a second `createScenarist()` call with different scenarios has no effect. The exception is `enabled: false`, which always returns `undefined`. ## 3. Create Scenario Control Endpoint app/api/%5F%5Fscenario%5F%5F/route.ts ```typescript import { scenarist } from '@/lib/scenarist'; // scenarist is undefined in production builds or when enabled is false, // so these handlers are undefined and the route answers 405 const handler = scenarist?.createScenarioEndpoint(); export const POST = handler; export const GET = handler; ``` To inspect captured state from your tests with `debugState` and `waitForDebugState`, add the debug state route the same way: app/api/%5F%5Fscenarist%5F%5F/state/route.ts ```typescript import { scenarist } from '@/lib/scenarist'; export const GET = scenarist?.createStateEndpoint(); ``` This route is served at `/api/__scenarist__/state`. ## 4. Configure Playwright playwright.config.ts ```typescript import { defineConfig } from '@playwright/test'; import type { ScenaristOptions } from '@scenarist/playwright-helpers'; export default defineConfig({ use: { baseURL: 'http://localhost:3000', // The Playwright helpers default to /__scenarist__/state, which is the Express path scenaristStateEndpoint: '/api/__scenarist__/state', }, // Playwright starts the app with next dev, where Scenarist is active webServer: { // Next.js 16; on Next.js 14 and 15, drop --webpack (webpack is already the default) command: 'npx next dev --webpack --port 3000', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120_000, }, }); ``` `scenaristEndpoint` defaults to `/api/__scenario__`, so you only need to set it if you moved the scenario route. ### Running your app for tests Run your scenario tests against `next dev`. The `webServer` block above starts it before the tests run and, outside CI, reuses one you already have running. Under `next dev`, `createScenarist()` returns the Scenarist instance, `scenarist.start()` starts MSW in the server process, and the scenario route switches scenarios. You don’t need to set `NODE_ENV`. The [example app](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example) starts its server the same way, with `next dev --webpack` on Next.js 16. Next.js 14 and 15 have no `--webpack` flag and use webpack by default, so run `next dev` there. **Can the tests run against `next build && next start`?** No. `next build` resolves the adapter’s `production` export condition, so `createScenarist()` returns `undefined` and MSW never starts. The scenario route then answers `405`, and `switchScenario` throws `Failed to switch scenario: 405`. Setting `NODE_ENV=test` does not change this. To test the production build itself, use a separate Playwright config that does not switch scenarios and points your app at real or stand-in backends. The example app does this with `playwright.production.config.ts`, which builds the app, runs `next start`, and asserts that the scenario route answers `405`. ## 5. Set Up Playwright Fixtures tests/fixtures.ts ```typescript import { withScenarios, expect } from '@scenarist/playwright-helpers'; import { scenarios } from '../lib/scenarios'; // Create type-safe test object with scenario IDs export const test = withScenarios(scenarios); export { expect }; ``` ## 6. Write Tests tests/products.spec.ts ```typescript import { test, expect } from './fixtures'; // ✅ Import from fixtures test('premium users see premium pricing', async ({ page, switchScenario }) => { await switchScenario(page, 'premiumUser'); // ✅ Type-safe! Autocomplete works await page.goto('/products'); // Your Server Component executes and renders // Auth0 API returns premium tier, Stripe/SendGrid fall back to default await expect(page.getByText('Premium Plan')).toBeVisible(); await expect(page.getByText('$50.00')).toBeVisible(); }); test('standard users see standard pricing', async ({ page, switchScenario }) => { await switchScenario(page, 'default'); // Default scenario (happy path) await page.goto('/products'); await expect(page.getByText('Standard Plan')).toBeVisible(); await expect(page.getByText('$25.00')).toBeVisible(); }); ``` **Example Server Component** that the tests above exercise: app/products/page.tsx ```typescript import { headers } from 'next/headers'; import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app'; const plans = { standard: { name: 'Standard Plan', price: '$25.00' }, premium: { name: 'Premium Plan', price: '$50.00' }, }; export default async function ProductsPage() { // Scenarist answers this call with the Auth0 mock from the test's scenario const response = await fetch('https://api.auth0.com/userinfo', { // Forward the test ID, or the call falls back to the default scenario headers: getScenaristHeadersFromReadonlyHeaders(await headers()), cache: 'no-store', }); const user = await response.json(); const plan = user.tier === 'premium' ? plans.premium : plans.standard; return (

{plan.name}

{plan.price}
); } ``` Every `fetch` your server code makes to a mocked API must forward the Scenarist headers and set `cache: 'no-store'`, so Next.js never answers it from its fetch cache instead of the mock for the active scenario ([why](/frameworks/nextjs-app-router/rsc/troubleshooting/#pitfall-3-nextjs-caching)). See [Forwarding Headers to External APIs](#forwarding-headers-to-external-apis) for Server Components and Route Handlers. ## Forwarding Headers to External APIs **Why header forwarding matters:** When your Server Components or Route Handlers call external APIs (that you’re mocking with Scenarist), you must forward the test ID header so MSW knows which scenario to use. ### Server Components (ReadonlyHeaders) Server Components use `headers()` from `next/headers`, which returns `ReadonlyHeaders` (not a `Request` object). Use the `getScenaristHeadersFromReadonlyHeaders` helper: app/products/page.tsx ```typescript import { headers } from 'next/headers'; import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app'; export default async function ProductsPage() { // Get headers from Next.js Server Component const headersList = await headers(); // Forward Scenarist headers to external API const response = await fetch('https://api.stripe.com/v1/products', { headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList), // ✅ For ReadonlyHeaders 'Authorization': `Bearer ${process.env.STRIPE_KEY}`, }, cache: 'no-store', // Never serve a cached response to a different test }); const { data: products } = await response.json(); return (
{products.map(product => (

{product.name}

${(product.price / 100).toFixed(2)}
))}
); } ``` ### Route Handlers (Request object) Route Handlers have access to the `Request` object. Use the `getScenaristHeaders` helper: app/api/products/route.ts ```typescript import { getScenaristHeaders } from '@scenarist/nextjs-adapter/app'; export async function GET(request: Request) { const response = await fetch('https://api.stripe.com/v1/products', { headers: { ...getScenaristHeaders(request), // ✅ For Request objects 'Authorization': `Bearer ${process.env.STRIPE_KEY}`, }, cache: 'no-store', // Never serve a cached response to a different test }); const data = await response.json(); return Response.json(data); } ``` ### When to use which helper | Context | Helper Function | Import | | ----------------- | ----------------------------------------------------- | ------------------------------- | | Server Components | `getScenaristHeadersFromReadonlyHeaders(headersList)` | `@scenarist/nextjs-adapter/app` | | Route Handlers | `getScenaristHeaders(request)` | `@scenarist/nextjs-adapter/app` | Both helpers extract the test ID header (`x-scenarist-test-id`) for forwarding to external APIs. ## What Makes App Router Setup Special **Server Components Actually Execute** - Unlike traditional mocking, your React Server Components render and run your application logic. **Route Handlers Run Normally** - Your validation, error handling, and business logic all execute. **Test Isolation** - Each test gets isolated scenario state. Run tests in parallel with zero interference. **No App Restart** - Switch scenarios instantly during test execution. ## Next Steps * **[Example App on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example)** - Clone and run the complete working example * **[Testing Database Apps →](/guides/testing-database-apps/)** - Learn how to test Next.js apps that use both databases and external APIs * **[Architecture →](/concepts/architecture/)** - Learn how Scenarist works under the hood # Testing React Server Components > Complete guide to testing React Server Components with Scenarist - data fetching, stateful mocks, sequences, streaming, authentication, server actions, and error handling React Server Components (RSC) represent a fundamental shift in how React applications render - but they also create new testing challenges. This guide shows you how to effectively test RSC using Scenarist and Playwright. ## Why Server Components Need Different Testing ### The Testing Gap Where do Server Components fit in the testing pyramid? They don’t fit neatly into traditional categories: * **Not isolated units** - They fetch data, read cookies, access headers, and depend on server infrastructure * **Not traditional integration tests** - They render UI, not just return data * **Full E2E is too slow** - Spinning up browsers for every scenario combination doesn’t scale ### The Jest Problem ```typescript // This FAILS in Jest: import { render } from '@testing-library/react'; import ProductsPage from './app/products/page'; test('renders products', async () => { render(); // Error: Objects are not valid as a React child (found: [object Promise]) }); ``` Jest and React Testing Library cannot render async Server Components because: 1. **Server Components return Promises** - RTL expects synchronous React elements 2. **Server-only APIs** - `headers()`, `cookies()` from `next/headers` throw outside Next.js 3. **No browser environment** - Server Components have no DOM to render into From the [Next.js Testing Documentation](https://nextjs.org/docs/app/building-your-application/testing): > “Since async Server Components are new to the React ecosystem, some tools do not fully support them. In the meantime, we recommend using End-to-End Testing over Unit Testing for async components.” ### The Scenarist Solution Scenarist + Playwright fills this gap by testing your server-side code **as it actually runs** - in a real Next.js environment with actual server-side rendering, middleware execution, and session handling: ```typescript // This WORKS with Scenarist + Playwright: import { test, expect } from './fixtures'; test('premium users see discounted pricing', async ({ page, switchScenario }) => { await switchScenario(page, 'premiumUser'); await page.goto('/products?tier=premium'); // Everything executes: middleware, session checks, RSC data fetching, rendering await expect(page.getByText('£99.99')).toBeVisible(); }); ``` ### What This Enables for RSC Testing Server Components with Scenarist gives you capabilities that unit tests cannot provide: | RSC Challenge | Unit Tests | Scenarist + Playwright | | ------------------------------ | ---------------------------- | ---------------------------------- | | Async component rendering | ❌ RTL can’t render Promises | ✅ Full server-side rendering | | `headers()` / `cookies()` APIs | ❌ Throw outside Next.js | ✅ Real Next.js execution | | Data fetching in components | ❌ Must mock fetch globally | ✅ Real fetch, mocked external APIs | | Error boundaries | ❌ Must mock error conditions | ✅ Real error propagation | **Why this matters for RSC specifically:** * **No mocking Next.js internals** - `headers()`, `cookies()`, and other server APIs work naturally * **Real server-side rendering** - HTML is generated exactly as in production * **Actual component composition** - Parent/child RSC relationships execute correctly * **Fast scenario switching** - Test many data fetching scenarios without server restarts ## Setup Requirements for App Router Before testing RSC patterns, ensure your setup includes header forwarding. This is critical because Server Components need to forward the test ID header to external APIs. ### Header Forwarding in Server Components Server Components use `headers()` from `next/headers`, which returns `ReadonlyHeaders`. Use the dedicated helper: app/products/page.tsx ```typescript import { headers } from 'next/headers'; import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app'; export default async function ProductsPage() { const headersList = await headers(); const response = await fetch('https://api.stripe.com/v1/products', { headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList), // Forward test ID 'Authorization': `Bearer ${process.env.STRIPE_KEY}`, }, cache: 'no-store', // Never serve a cached response to a different test }); const products = await response.json(); return ; } ``` For complete setup instructions, see [Getting Started with Next.js App Router](/frameworks/nextjs-app-router/getting-started/). *** ## RSC Testing Patterns This guide covers seven patterns for testing React Server Components. Choose based on your testing needs: [Data Fetching Patterns](/frameworks/nextjs-app-router/rsc/data-fetching/)Core patterns: fetching data, stateful mocks, and sequences [Streaming & Suspense](/frameworks/nextjs-app-router/rsc/streaming/)Testing Suspense boundaries and streaming content [User Interactions](/frameworks/nextjs-app-router/rsc/interactions/)Authentication, Server Actions, and error boundaries [Troubleshooting](/frameworks/nextjs-app-router/rsc/troubleshooting/)Common pitfalls and debugging tips ### Pattern Overview | Pattern | Page | Use Case | | -------------------- | ----------------------------------------------------------------- | ------------------------------------------------- | | **Data Fetching** | [Data Fetching](/frameworks/nextjs-app-router/rsc/data-fetching/) | Basic RSC data fetching with request matching | | **Stateful Mocks** | [Data Fetching](/frameworks/nextjs-app-router/rsc/data-fetching/) | Shopping carts, state that builds across requests | | **Sequences** | [Data Fetching](/frameworks/nextjs-app-router/rsc/data-fetching/) | Polling, retry logic, multi-step workflows | | **Streaming** | [Streaming](/frameworks/nextjs-app-router/rsc/streaming/) | Suspense boundaries, progressive loading | | **Authentication** | [Interactions](/frameworks/nextjs-app-router/rsc/interactions/) | Protected routes, session handling | | **Server Actions** | [Interactions](/frameworks/nextjs-app-router/rsc/interactions/) | Form submissions, mutations | | **Error Boundaries** | [Interactions](/frameworks/nextjs-app-router/rsc/interactions/) | Error handling and recovery | *** ## Next Steps * **[Data Fetching Patterns](/frameworks/nextjs-app-router/rsc/data-fetching/)** - Start here for core RSC testing patterns * **[Next.js App Router Getting Started](/frameworks/nextjs-app-router/getting-started/)** - Complete setup guide * **[Example App](/frameworks/nextjs-app-router/example-app/)** - Full working example with all patterns # Data Fetching Patterns > Core patterns for testing React Server Components - data fetching, stateful mocks, and sequences This page covers the foundational patterns for testing React Server Components with Scenarist. These patterns form the basis for all RSC testing. ## Pattern 1: Data Fetching in Server Components The most common RSC pattern is fetching data server-side. Scenarist makes this testable by intercepting the fetch calls and returning scenario-defined responses. ### Example: Products Page **Server Component:** [`app/products/page.tsx`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/products/page.tsx) app/products/page.tsx ```typescript import { headers } from 'next/headers'; import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app'; type ProductsPageProps = { searchParams: Promise<{ tier?: string }>; }; async function fetchProducts(tier: string = 'standard'): Promise { const headersList = await headers(); const response = await fetch('http://localhost:3001/products', { headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList), 'x-user-tier': tier, // Application context for API }, cache: 'no-store', }); return response.json(); } export default async function ProductsPage({ searchParams }: ProductsPageProps) { const { tier = 'standard' } = await searchParams; const data = await fetchProducts(tier); return (

Products

{data.products.map((product) => (

{product.name}

£{product.price.toFixed(2)}
))}
); } ``` ### Scenario Definition **Scenarios:** [`lib/scenarios.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/lib/scenarios.ts) lib/scenarios.ts ```typescript import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app'; export const premiumUserScenario: ScenaristScenario = { id: 'premiumUser', name: 'Premium User', description: 'Premium tier pricing', mocks: [ { method: 'GET', url: 'http://localhost:3001/products', match: { headers: { 'x-user-tier': 'premium' }, }, response: { status: 200, body: { products: [ { id: 1, name: 'Product A', price: 99.99, tier: 'premium' }, { id: 2, name: 'Product B', price: 199.99, tier: 'premium' }, ], }, }, }, ], }; export const standardUserScenario: ScenaristScenario = { id: 'standardUser', name: 'Standard User', description: 'Standard tier pricing', mocks: [ { method: 'GET', url: 'http://localhost:3001/products', match: { headers: { 'x-user-tier': 'standard' }, }, response: { status: 200, body: { products: [ { id: 1, name: 'Product A', price: 149.99, tier: 'standard' }, { id: 2, name: 'Product B', price: 249.99, tier: 'standard' }, ], }, }, }, ], }; ``` ### Test Implementation **Test:** [`tests/playwright/products-server-components.spec.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/products-server-components.spec.ts) tests/playwright/products-server-components.spec.ts ```typescript import { test, expect } from './fixtures'; test.describe('Products Page - React Server Components', () => { test('should render products with premium tier pricing', async ({ page, switchScenario, }) => { await switchScenario(page, 'premiumUser'); await page.goto('/products?tier=premium'); // Verify Server Component rendered await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible(); // Verify premium pricing from mocked API await expect(page.getByText('£99.99')).toBeVisible(); }); test('should render products with standard tier pricing', async ({ page, switchScenario, }) => { await switchScenario(page, 'standardUser'); await page.goto('/products?tier=standard'); // Verify standard pricing from mocked API await expect(page.getByText('£149.99')).toBeVisible(); }); test('should switch tiers at runtime without app restart', async ({ page, switchScenario, }) => { // Start with premium await switchScenario(page, 'premiumUser'); await page.goto('/products?tier=premium'); await expect(page.getByText('£99.99')).toBeVisible(); // Switch to standard - no restart needed! await switchScenario(page, 'standardUser'); await page.goto('/products?tier=standard'); await expect(page.getByText('£149.99')).toBeVisible(); }); }); ``` *** ## Pattern 2: Stateful Mocks with RSC Stateful mocks capture data from one request and inject it into later responses. This is essential for testing flows like shopping carts where state builds up across multiple requests. **State is isolated per test ID**, so parallel tests never conflict—each test maintains its own cart state. ### Example: Server-Side Cart **Server Component:** [`app/cart-server/page.tsx`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/cart-server/page.tsx) app/cart-server/page.tsx ```typescript import { headers } from 'next/headers'; import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app'; import { getAppBaseURL } from '@/lib/app-base-url'; type CartResponse = { // null until the first PATCH captures state readonly items?: ReadonlyArray | null; }; type CartItem = { readonly id: string; readonly name: string; readonly quantity: number; }; const PRODUCT_NAMES: Record = { 'prod-1': 'Product A', 'prod-2': 'Product B', 'prod-3': 'Product C', }; // Turn the raw productId array from state into items with quantities const aggregateCartItems = ( productIds: ReadonlyArray | null | undefined, ): ReadonlyArray => { const counts = (productIds ?? []).reduce>( (acc, id) => ({ ...acc, [id]: (acc[id] ?? 0) + 1 }), {}, ); return Object.entries(counts).map(([id, quantity]) => ({ id, name: PRODUCT_NAMES[id] ?? `Unknown Product (${id})`, quantity, })); }; async function fetchCart(): Promise { const headersList = await headers(); // Calls the app's own /api/cart route, which forwards the headers to // GET http://localhost:3001/cart const response = await fetch(new URL('/api/cart', getAppBaseURL()), { headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList), }, cache: 'no-store', }); return response.json(); } export default async function CartServerPage() { const cartData = await fetchCart(); const cartItems = aggregateCartItems(cartData.items); return (

Shopping Cart

{cartItems.length === 0 ? (

Your cart is empty

) : (
{cartItems.map((item) => (

{item.name}

Quantity: {item.quantity}

))}
)}
); } ``` ### Stateful Scenario Definition lib/scenarios.ts ```typescript import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app'; export const cartWithStateScenario: ScenaristScenario = { id: 'cartWithState', name: 'Shopping Cart with State', description: 'Stateful cart that captures and injects items', mocks: [ // GET /cart - Inject cartItems from state (null initially) { method: 'GET', url: 'http://localhost:3001/cart', response: { status: 200, body: { items: '{{state.cartItems}}', // Template injection from state }, }, }, // PATCH /cart - Capture full items array into state { method: 'PATCH', url: 'http://localhost:3001/cart', captureState: { cartItems: 'body.items', // Capture from request body }, response: { status: 200, body: { items: '{{state.cartItems}}', // Echo back the captured items }, }, }, ], }; ``` ### Test Implementation **Test:** [`tests/playwright/cart-server-components.spec.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/cart-server-components.spec.ts) tests/playwright/cart-server-components.spec.ts ```typescript import { test, expect } from './fixtures'; test.describe('Cart Server Page - Stateful Mocks', () => { test('should show empty cart initially', async ({ page, switchScenario }) => { await switchScenario(page, 'cartWithState'); await page.goto('/cart-server'); await expect(page.getByText('Your cart is empty')).toBeVisible(); }); test('should display cart item after adding product', async ({ page, switchScenario, }) => { const testId = await switchScenario(page, 'cartWithState'); // Add product through API route // Note: page.request uses a separate context, so include test ID header await page.request.post('http://localhost:3002/api/cart/add', { headers: { 'Content-Type': 'application/json', 'x-scenarist-test-id': testId, }, data: { productId: 'prod-1' }, }); // Navigate to cart - Server Component fetches with same test ID await page.goto('/cart-server'); // State was captured from POST and injected into GET response await expect(page.getByText('Product A')).toBeVisible(); await expect(page.getByText('Quantity: 1')).toBeVisible(); }); test('should aggregate quantities for same product', async ({ page, switchScenario, }) => { const testId = await switchScenario(page, 'cartWithState'); // Add same product 3 times for (let i = 0; i < 3; i++) { await page.request.post('http://localhost:3002/api/cart/add', { headers: { 'Content-Type': 'application/json', 'x-scenarist-test-id': testId, }, data: { productId: 'prod-1' }, }); } await page.goto('/cart-server'); // Should show aggregated quantity await expect(page.getByText('Quantity: 3')).toBeVisible(); }); }); ``` *** ## Pattern 3: Polling & Sequences in RSC Sequences return different responses on successive requests - perfect for testing polling scenarios, retry logic, or multi-step workflows. ### Example: Job Polling Page **Server Component:** [`app/polling/page.tsx`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/polling/page.tsx) app/polling/page.tsx ```typescript import { headers } from 'next/headers'; import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app'; type JobStatus = { readonly jobId: string; readonly status: 'pending' | 'processing' | 'complete'; readonly progress: number; }; async function fetchJobStatus(jobId: string): Promise { const headersList = await headers(); const response = await fetch(`http://localhost:3001/github/jobs/${jobId}`, { headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList), }, cache: 'no-store', }); return response.json(); } export default async function PollingPage({ searchParams }) { const { jobId = '123' } = await searchParams; const job = await fetchJobStatus(jobId); return (

Job Status

{job.status.toUpperCase()}
Progress: {job.progress}%
); } ``` ### Sequence Scenario Definition lib/scenarios.ts ```typescript import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app'; export const githubPollingScenario: ScenaristScenario = { id: 'githubPolling', name: 'GitHub Job Polling', description: 'Polling sequence: pending → processing → complete', mocks: [ { method: 'GET', url: 'http://localhost:3001/github/jobs/:id', sequence: { responses: [ { status: 200, body: { jobId: '123', status: 'pending', progress: 0 }, }, { status: 200, body: { jobId: '123', status: 'processing', progress: 50 }, }, { status: 200, body: { jobId: '123', status: 'complete', progress: 100 }, }, ], repeat: 'last', // After exhaustion, keep returning 'complete' }, }, ], }; ``` ### Test Implementation **Test:** [`tests/playwright/polling-server-components.spec.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/polling-server-components.spec.ts) tests/playwright/polling-server-components.spec.ts ```typescript import { test, expect } from './fixtures'; test.describe('Polling Page - Sequences with Server Components', () => { test('should show pending status on first request', async ({ page, switchScenario, }) => { await switchScenario(page, 'githubPolling'); await page.goto('/polling?jobId=123'); // First sequence position: pending await expect(page.getByText('PENDING')).toBeVisible(); await expect(page.getByText('0%')).toBeVisible(); }); test('should advance through sequence on page reloads', async ({ page, switchScenario, }) => { await switchScenario(page, 'githubPolling'); // First request: pending await page.goto('/polling?jobId=123'); await expect(page.getByText('PENDING')).toBeVisible(); // Second request: processing await page.reload(); await expect(page.getByText('PROCESSING')).toBeVisible(); await expect(page.getByText('50%')).toBeVisible(); // Third request: complete await page.reload(); await expect(page.getByText('COMPLETE')).toBeVisible(); await expect(page.getByText('100%')).toBeVisible(); }); test('should repeat last response after sequence exhaustion', async ({ page, switchScenario, }) => { await switchScenario(page, 'githubPolling'); // Advance through all sequence positions await page.goto('/polling?jobId=123'); // pending await page.reload(); // processing await page.reload(); // complete // Verify complete await expect(page.getByText('COMPLETE')).toBeVisible(); // Fourth request - should still be complete (repeat: 'last') await page.reload(); await expect(page.getByText('COMPLETE')).toBeVisible(); }); }); ``` ### Sequence Repeat Modes | Mode | Behavior | Use Case | | --------- | ----------------------------- | ------------------------------ | | `'last'` | Repeat final response forever | Polling until completion | | `'cycle'` | Loop back to first response | Cyclical patterns (weather) | | `'none'` | Fall through to next mock | Rate limiting after N attempts | *** ## Next Steps * **[Streaming & Suspense](/frameworks/nextjs-app-router/rsc/streaming/)** - Testing Suspense boundaries and streaming content * **[User Interactions](/frameworks/nextjs-app-router/rsc/interactions/)** - Authentication, Server Actions, and error boundaries * **[Troubleshooting](/frameworks/nextjs-app-router/rsc/troubleshooting/)** - Common pitfalls and debugging tips # User Interactions > Testing authentication flows, Server Actions, and error boundaries in React Server Components This page covers patterns for testing user interactions in React Server Components: authentication flows, form submissions via Server Actions, and error handling with error boundaries. ## Authentication Flows Authentication is a critical RSC pattern - protected routes must check auth status server-side before rendering. Scenarist makes this testable by letting you switch between authenticated and unauthenticated states. ### Example: Protected Route with Auth Check **Auth Helper:** [`lib/auth.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/lib/auth.ts) lib/auth.ts ```typescript import { z } from 'zod'; import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app'; import type { ReadonlyHeaders } from 'next/dist/server/web/spec-extension/adapters/headers'; const UserSchema = z.object({ id: z.string(), email: z.string(), name: z.string(), }); type User = z.infer; type AuthResult = | { readonly authenticated: true; readonly user: User } | { readonly authenticated: false; readonly error: string }; export const checkAuth = async ( headersList: ReadonlyHeaders, ): Promise => { const response = await fetch('http://localhost:3001/auth/me', { headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList), }, cache: 'no-store', // Don't cache auth checks }); if (!response.ok) { return { authenticated: false, error: 'Authentication required' }; } const data: unknown = await response.json(); const user = UserSchema.parse(data); return { authenticated: true, user }; }; ``` **Protected Layout:** [`app/protected/layout.tsx`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/protected/layout.tsx) app/protected/layout.tsx ```typescript import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { checkAuth } from '@/lib/auth'; type ProtectedLayoutProps = { children: React.ReactNode; }; export default async function ProtectedLayout({ children, }: ProtectedLayoutProps) { const headersList = await headers(); const auth = await checkAuth(headersList); if (!auth.authenticated) { // Redirect to login with the original URL redirect('/login?from=/protected'); } // User is authenticated - render with user context return (
Welcome, {auth.user.name} {auth.user.email}
{children}
); } ``` ### Authentication Scenarios **Scenarios:** [`lib/scenarios.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/lib/scenarios.ts) lib/scenarios.ts ```typescript import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app'; export const authenticatedUserScenario: ScenaristScenario = { id: 'authenticatedUser', name: 'Authenticated User', description: 'User is authenticated with valid session', mocks: [ { method: 'GET', url: 'http://localhost:3001/auth/me', response: { status: 200, body: { id: 'user-123', email: 'test@example.com', name: 'Test User', }, }, }, ], }; export const unauthenticatedUserScenario: ScenaristScenario = { id: 'unauthenticatedUser', name: 'Unauthenticated User', description: 'User is not authenticated', mocks: [ { method: 'GET', url: 'http://localhost:3001/auth/me', response: { status: 401, body: { error: 'Unauthorized', message: 'Authentication required', }, }, }, ], }; ``` ### Test Implementation **Test:** [`tests/playwright/auth-flows.spec.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/auth-flows.spec.ts) tests/playwright/auth-flows.spec.ts ```typescript import { test, expect } from './fixtures'; test.describe('Authentication Flow - Protected Routes', () => { test('should render protected content when authenticated', async ({ page, switchScenario, }) => { await switchScenario(page, 'authenticatedUser'); await page.goto('/protected'); // Verify protected content is visible await expect( page.getByRole('heading', { name: 'Protected Dashboard' }), ).toBeVisible(); // Verify user info is displayed await expect(page.getByText('test@example.com')).toBeVisible(); await expect(page.getByText('Welcome, Test User')).toBeVisible(); }); test('should redirect to login when not authenticated', async ({ page, switchScenario, }) => { await switchScenario(page, 'unauthenticatedUser'); await page.goto('/protected'); // Should be redirected to login page await expect(page).toHaveURL(/\/login\?from=\/protected/); // Verify login page content await expect( page.getByRole('heading', { name: 'Sign In' }), ).toBeVisible(); }); test('should switch between auth states at runtime', async ({ page, switchScenario, }) => { // Start as authenticated user await switchScenario(page, 'authenticatedUser'); await page.goto('/protected'); await expect( page.getByRole('heading', { name: 'Protected Dashboard' }), ).toBeVisible(); // Switch to unauthenticated (simulating session expiry) await switchScenario(page, 'unauthenticatedUser'); await page.reload(); // Now redirected to login await expect(page).toHaveURL(/\/login/); }); }); ``` *** ## Server Actions Server Actions are server-side functions that can be called directly from Client Components. They’re commonly used for form submissions, mutations, and any operation that requires server-side execution. Testing Server Actions with Scenarist requires the same header forwarding pattern used in Server Components. ### The Server Actions Testing Challenge Server Actions run on the server and make their own fetch requests. This creates a testing challenge: 1. **Isolated server execution** - Server Actions don’t inherit browser headers automatically 2. **Test ID routing** - Without the `x-scenarist-test-id` header, MSW can’t route to the correct scenario 3. **Concurrent test isolation** - Multiple tests running in parallel need separate mock responses **Solution:** Forward headers from the incoming request to outgoing fetch calls using `getScenaristHeadersFromReadonlyHeaders()`. ### Example: Contact Form with Server Action **Server Action:** [`app/actions/actions.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/actions/actions.ts) app/actions/actions.ts ```typescript "use server"; import { headers } from "next/headers"; import { getScenaristHeadersFromReadonlyHeaders } from "@scenarist/nextjs-adapter/app"; type FormState = { readonly success: boolean; readonly message: string; } | null; export async function submitContactForm( _prevState: FormState, formData: FormData ): Promise { const headersList = await headers(); const response = await fetch("http://localhost:3001/contact", { method: "POST", headers: { "Content-Type": "application/json", // Forward Scenarist headers for test isolation // Each test gets its own scenario based on x-scenarist-test-id ...getScenaristHeadersFromReadonlyHeaders(headersList), }, body: JSON.stringify({ name: formData.get("name"), email: formData.get("email"), message: formData.get("message"), }), cache: "no-store", }); const data: unknown = await response.json(); if (!response.ok) { const errorMessage = data !== null && typeof data === "object" && "error" in data && typeof data.error === "string" ? data.error : "Submission failed"; return { success: false, message: errorMessage }; } const successMessage = data !== null && typeof data === "object" && "message" in data && typeof data.message === "string" ? data.message : "Message sent!"; return { success: true, message: successMessage }; } ``` **Page Component:** [`app/actions/page.tsx`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/actions/page.tsx) app/actions/page.tsx ```typescript "use client"; import { useActionState } from "react"; import { submitContactForm } from "./actions"; export default function ActionsPage() { const [state, formAction, isPending] = useActionState(submitContactForm, null); return (

Server Actions Demo