Testcontainers + Scenarist Hybrid
This guide shows how to test Next.js apps that use direct database access by combining Testcontainers for real database testing with Scenarist for mocking external APIs. No code changes required.
What is Testcontainers?
Section titled “What is Testcontainers?”Testcontainers is a library that provides lightweight, throwaway Docker containers for testing. It spins up real database instances (PostgreSQL, MySQL, MongoDB) as Docker containers during your tests, pre-configured with your schema and seed data. Each test suite gets a fresh database instance that’s automatically destroyed after tests complete. This enables testing against actual database queries, migrations, transactions, and constraints without maintaining a shared test database or complex cleanup scripts.
Learn more: Testcontainers Documentation
Overview
Section titled “Overview”The hybrid approach uses two complementary tools:
- Testcontainers: Spins up real Docker database containers with seeded scenarios
- Scenarist: Mocks external API dependencies (Stripe, Auth0, SendGrid)
Together, you get:
- ✅ Real database queries and migrations tested
- ✅ External APIs mocked for different scenarios
- ✅ No code changes to your application
- ✅ Realistic integration testing
Best for: Teams that cannot/won’t refactor to add API routes
Architecture
Section titled “Architecture”import { headers } from 'next/headers';import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
// Server Component (unchanged - no API routes needed!)export default async function CheckoutPage() { // Database call - testcontainer provides real PostgreSQL with seeded data const user = await db.user.findUnique({ where: { id: userId } });
// External API - Scenarist intercepts and mocks const headersList = await headers(); const response = await fetch('https://api.stripe.com/v1/charges', { method: 'POST', headers: getScenaristHeadersFromReadonlyHeaders(headersList), body: JSON.stringify({ amount: user.cartTotal }), });
if (!response.ok) { return <PaymentError status={response.status} />; }
const payment = await response.json(); return <CheckoutForm user={user} payment={payment} />;}What each tool handles:
- Testcontainers: User data, products, cart items (real PostgreSQL)
- Scenarist: Stripe payment responses, Auth0 tokens, SendGrid emails (mocked)
Installation
Section titled “Installation”Install Testcontainers for your database:
# For PostgreSQLnpm install -D @testcontainers/postgresql
# For MySQLnpm install -D @testcontainers/mysql
# For MongoDBnpm install -D @testcontainers/mongodbScenarist is already installed from Getting Started.
Step-by-Step Implementation
Section titled “Step-by-Step Implementation”Step 1: Create Database Seeding Functions
Section titled “Step 1: Create Database Seeding Functions”Create functions to seed test data into database containers:
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';import { PrismaClient } from '@prisma/client';
export async function seedPremiumUser(container: StartedPostgreSqlContainer) { const connectionString = container.getConnectionUrl(); const prisma = new PrismaClient({ datasources: { db: { url: connectionString } }, });
await prisma.user.create({ data: { id: 'user-premium-123', email: 'premium@example.com', tier: 'premium', cartTotal: 500, }, });
await prisma.$disconnect();}
export async function seedStandardUser(container: StartedPostgreSqlContainer) { const connectionString = container.getConnectionUrl(); const prisma = new PrismaClient({ datasources: { db: { url: connectionString } }, });
await prisma.user.create({ data: { id: 'user-standard-456', email: 'standard@example.com', tier: 'standard', cartTotal: 200, }, });
await prisma.$disconnect();}Step 2: Set Up Container in Tests
Section titled “Step 2: Set Up Container in Tests”Create a container setup function and use it in tests:
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';import { execSync } from 'child_process';
export async function createTestContainer(): Promise<StartedPostgreSqlContainer> { // Start PostgreSQL container const container = await new PostgreSqlContainer('postgres:16').start();
// Set DATABASE_URL for Next.js app process.env.DATABASE_URL = container.getConnectionUrl();
// Run migrations execSync('npx prisma migrate deploy', { env: process.env });
return container;}
// tests/checkout.spec.tsimport { test, expect } from '@playwright/test';import { createTestContainer } from './helpers/setup-container';import { seedPremiumUser, seedStandardUser } from './helpers/seed-database';
test.describe('Checkout flow', () => { const setup = test.beforeAll(async () => { const container = await createTestContainer(); return { container }; });
test.afterAll(async () => { const { container } = await setup; await container.stop(); });
// Tests use container from setup});Step 3: Write Tests with Database + API Mocking
Section titled “Step 3: Write Tests with Database + API Mocking”Combine database seeding with Scenarist scenario switching:
test.describe('Checkout flow', () => { const setup = test.beforeAll(async () => { const container = await createTestContainer(); return { container }; });
test.afterAll(async () => { const { container } = await setup; await container.stop(); });
test('premium user with successful payment', async ({ page, switchScenario }) => { const { container } = await setup;
// Testcontainer: Seed premium user in real database await seedPremiumUser(container);
// Scenarist: Mock Stripe success await switchScenario(page, 'stripeSuccess');
await page.goto('/checkout');
// Test sees: // - Premium user from real database (tier='premium', cartTotal=500) // - Successful payment from Scenarist mock await expect(page.getByText('Payment successful')).toBeVisible(); await expect(page.getByText('£500.00')).toBeVisible(); });
test('standard user with declined payment', async ({ page, switchScenario }) => { const { container } = await setup;
// Testcontainer: Seed standard user in real database await seedStandardUser(container);
// Scenarist: Mock Stripe decline await switchScenario(page, 'stripeDeclined');
await page.goto('/checkout');
// Test sees: // - Standard user from real database (tier='standard', cartTotal=200) // - Declined payment from Scenarist mock await expect(page.getByText('Payment declined')).toBeVisible(); await expect(page.getByText('£200.00')).toBeVisible(); });});Step 4: Define Scenarist Scenarios
Section titled “Step 4: Define Scenarist Scenarios”Mock external APIs (Stripe, Auth0, SendGrid):
export const stripeSuccessScenario: ScenaristScenario = { id: 'stripeSuccess', name: 'Stripe Payment Success', mocks: [ { method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { id: 'ch_123', status: 'succeeded', amount: 50000, // $500.00 }, }, }, ],};
export const stripeDeclinedScenario: ScenaristScenario = { id: 'stripeDeclined', name: 'Stripe Payment Declined', mocks: [ { method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 402, body: { error: { code: 'card_declined', message: 'Your card was declined', }, }, }, }, ],};Complete Example
Section titled “Complete Example”Here’s a full test showing database setup, seeding, and API mocking using functional patterns:
import { test, expect } from '@playwright/test';import { PostgreSqlContainer } from '@testcontainers/postgresql';import { PrismaClient } from '@prisma/client';import { scenaristFixtures } from '@scenarist/playwright-helpers';import { execSync } from 'child_process';
const { switchScenario } = scenaristFixtures({ testIdHeader: 'x-scenarist-test-id', scenarioEndpoint: 'http://localhost:3000/__scenario__',});
test.use({ ...switchScenario });
test.describe('Checkout flow with database', () => { const setup = test.beforeAll(async () => { // Start database container const container = await new PostgreSqlContainer('postgres:16') .withDatabase('testdb') .withUsername('testuser') .withPassword('testpass') .start();
// Configure Next.js to use container database process.env.DATABASE_URL = container.getConnectionUrl();
// Initialize Prisma client const prisma = new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL } }, });
// Run migrations execSync('npx prisma migrate deploy', { env: process.env });
return { container, prisma }; });
test.afterAll(async () => { const { prisma, container } = await setup; await prisma.$disconnect(); await container.stop(); });
test.beforeEach(async () => { const { prisma } = await setup; // Clean database before each test await prisma.user.deleteMany(); await prisma.order.deleteMany(); });
test('premium user can checkout with valid payment', async ({ page, switchScenario }) => { const { prisma } = await setup;
// Seed database: premium user await prisma.user.create({ data: { id: 'user-123', email: 'premium@example.com', tier: 'premium', cart: { create: { items: [ { productId: 'prod-1', quantity: 2, price: 150 }, { productId: 'prod-2', quantity: 1, price: 200 }, ], }, }, }, });
// Mock external API: Stripe success await switchScenario(page, 'stripeSuccess');
// Test flow await page.goto('/cart'); await page.getByRole('button', { name: 'Checkout' }).click();
// Fill payment form await page.getByLabel('Card Number').fill('4242424242424242'); await page.getByLabel('Expiry').fill('12/25'); await page.getByLabel('CVC').fill('123'); await page.getByRole('button', { name: 'Pay £500' }).click();
// Verify success await expect(page.getByText('Payment successful')).toBeVisible(); await expect(page.getByText('Order #')).toBeVisible();
// Verify database updated const order = await prisma.order.findFirst({ where: { userId: 'user-123' }, }); expect(order?.status).toBe('completed'); });});When to Use This Approach
Section titled “When to Use This Approach”✅ Use when:
- You want to test actual database queries and migrations
- You have external API dependencies to mock
- You cannot/don’t want to add API routes
- Container startup overhead is acceptable for your workflow
- You need realistic integration testing
❌ Don’t use when:
- You don’t have Docker available (CI/CD constraint)
- Test speed is critical (hundreds of tests)
- Parallel test execution is important
- Your app only uses external HTTP APIs (use Scenarist directly)
Trade-offs
Section titled “Trade-offs”Advantages
Section titled “Advantages”✅ No code changes required
- Server Components call database directly (as designed)
- No API route layer needed
- Production code unchanged
✅ Test real database behavior
- Actual SQL queries executed
- Database constraints validated
- Migrations tested
- Transactions work correctly
✅ Realistic integration testing
- Database + external APIs together
- Closest to production environment
- Catches integration bugs
Disadvantages
Section titled “Disadvantages”⚠️ Slower tests
- Container startup overhead
- Database seeding per test
- Best suited for focused test suites rather than hundreds of tests
⚠️ Docker required
- CI/CD must support Docker
- Developers need Docker installed
- More complex local setup
⚠️ Database seeding complexity
- Must maintain seed data scripts
- Schema changes break seeds
- Cleanup between tests required
⚠️ Sequential test execution required
- Database state is shared (unlike HTTP mocks which are stateless)
- Scenarist isolates via test ID in HTTP headers, but databases have no equivalent mechanism
- Parallel tests would corrupt each other’s data without application code changes
- For parallelism, you’d need multiple containers (resource-intensive) or database-level isolation (schemas per test)
Performance Optimization Tips
Section titled “Performance Optimization Tips”Reuse containers across tests:
test.beforeAll(async () => { container = await new PostgreSqlContainer('postgres:16').start();});
test.beforeEach(async () => { // Only clean data, don't restart container await prisma.user.deleteMany();});
test.afterAll(async () => { // Stop container once at end await container.stop();});Use database transactions for cleanup:
test.beforeEach(async () => { await prisma.$transaction([ prisma.order.deleteMany(), prisma.user.deleteMany(), ]);});Cache container image:
# Pull image once before running testsdocker pull postgres:16Next Steps
Section titled “Next Steps”- Testcontainers Documentation - Learn more about Testcontainers
- Next.js Testing Overview - Back to database testing overview
- Next.js App Router Getting Started - Set up Scenarist for App Router