Repository Pattern for Parallel Database Testing
Two Concepts Working Together
Section titled “Two Concepts Working Together”It’s important to distinguish between two separate concepts:
1. The Repository Pattern (Architectural)
Section titled “1. The Repository Pattern (Architectural)”The repository pattern is a well-established architectural practice that abstracts database access behind interfaces. It’s not specific to testing—it provides significant benefits including infrastructure flexibility, better separation of concerns, and alignment with SOLID principles.
2. Test ID Isolation (Testing Technique)
Section titled “2. Test ID Isolation (Testing Technique)”Test ID isolation partitions data by a unique identifier per test, enabling parallel execution without interference. This is the same technique Scenarist uses for HTTP mocking.
This guide combines both: We use the repository pattern as the mechanism to implement test ID isolation for databases.
Why Use the Repository Pattern for Test Isolation?
Section titled “Why Use the Repository Pattern for Test Isolation?”The repository pattern is particularly well-suited for test ID isolation because:
- Interface injection — Swap production (real DB) for test (in-memory with partitioning) implementations
- Clean boundaries — Database access is already abstracted, making partitioning natural
- No schema changes — Isolation happens in application code, not database
- Fast execution — In-memory stores are orders of magnitude faster than real databases
How It Works
Section titled “How It Works”The repository pattern separates what your code does from how data is persisted. Your business logic depends on an interface (the “what”), while concrete implementations handle the database details (the “how”).
The key insight: You can swap implementations based on environment:
- Production: Real implementation that executes actual database queries (Prisma, Drizzle, TypeORM)
- Tests: In-memory implementation that stores data in JavaScript Maps, partitioned by test ID
This gives you the same isolation model as Scenarist’s HTTP mocking:
| Scenarist (HTTP) | Repository Pattern (Database) |
|---|---|
x-scenarist-test-id header identifies test | x-scenarist-test-id header identifies test |
| MSW returns scenario-specific responses | Repository returns test-specific data |
| Each test gets isolated mock state | Each test gets isolated data store |
The flow:
- Test sends request with
x-scenarist-test-id: abc-123 - Middleware extracts test ID and stores it in
AsyncLocalStorage - Repository retrieves test ID and uses it as partition key
- Data operations only affect that test’s partition
- Parallel tests have completely isolated data stores
Because the interface is the same, your business logic doesn’t know or care which implementation is running—it just calls userRepository.findById(). The test isolation happens transparently.
Working Example: Next.js App Router
Section titled “Working Example: Next.js App Router”Example Implementation (Express)
Section titled “Example Implementation (Express)”Here’s a complete working example showing how test ID flows from Playwright through Express to isolated in-memory repositories:
1. Repository Interface
Section titled “1. Repository Interface”export type User = { id: string; email: string; name: string; createdAt: Date; updatedAt: Date;};
export type CreateUserInput = { email: string; name: string;};
export interface UserRepository { findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>; findAll(): Promise<User[]>; create(data: CreateUserInput): Promise<User>;}2. Production Implementation (Prisma)
Section titled “2. Production Implementation (Prisma)”import { PrismaClient } from '@prisma/client';import type { UserRepository, User, CreateUserInput } from './user-repository';
export class PrismaUserRepository implements UserRepository { constructor(private prisma: PrismaClient) {}
async findById(id: string): Promise<User | null> { return this.prisma.user.findUnique({ where: { id } }); }
async findByEmail(email: string): Promise<User | null> { return this.prisma.user.findFirst({ where: { email } }); }
async findAll(): Promise<User[]> { return this.prisma.user.findMany(); }
async create(data: CreateUserInput): Promise<User> { return this.prisma.user.create({ data }); }}3. Test Implementation with Test ID Isolation
Section titled “3. Test Implementation with Test ID Isolation”This is where the magic happens. The test implementation partitions data by test ID:
import type { UserRepository, User, CreateUserInput } from './user-repository';
export class InMemoryUserRepository implements UserRepository { // Map<testId, Map<userId, User>> private store = new Map<string, Map<string, User>>(); private idCounter = new Map<string, number>();
constructor(private getTestId: () => string) {}
private getTestStore(): Map<string, User> { const testId = this.getTestId(); if (!this.store.has(testId)) { this.store.set(testId, new Map()); this.idCounter.set(testId, 0); } return this.store.get(testId)!; }
private generateId(): string { const testId = this.getTestId(); const counter = (this.idCounter.get(testId) ?? 0) + 1; this.idCounter.set(testId, counter); return `user-${counter}`; }
async findById(id: string): Promise<User | null> { return this.getTestStore().get(id) ?? null; }
async findByEmail(email: string): Promise<User | null> { for (const user of this.getTestStore().values()) { if (user.email === email) return user; } return null; }
async findAll(): Promise<User[]> { return Array.from(this.getTestStore().values()); }
async create(data: CreateUserInput): Promise<User> { const user: User = { id: this.generateId(), ...data, createdAt: new Date(), updatedAt: new Date(), }; this.getTestStore().set(user.id, user); return user; }}4. Dependency Injection Container
Section titled “4. Dependency Injection Container”import { AsyncLocalStorage } from 'node:async_hooks';import { PrismaClient } from '@prisma/client';import type { UserRepository } from './repositories/user-repository';import { PrismaUserRepository } from './repositories/prisma-user-repository';import { InMemoryUserRepository } from './repositories/in-memory-user-repository';
// AsyncLocalStorage carries test ID through async request lifecycleconst testIdStorage = new AsyncLocalStorage<string>();
export const getTestId = (): string => { return testIdStorage.getStore() ?? 'default-test';};
export const runWithTestId = <T>(testId: string, fn: () => T): T => { return testIdStorage.run(testId, fn);};
// Create repositories based on environmentconst prisma = new PrismaClient();
export const createRepositories = (): { userRepository: UserRepository } => { const isTest = process.env.NODE_ENV === 'test';
const userRepository: UserRepository = isTest ? new InMemoryUserRepository(getTestId) : new PrismaUserRepository(prisma);
return { userRepository };};5. Express Middleware to Extract Test ID
Section titled “5. Express Middleware to Extract Test ID”import type { Request, Response, NextFunction } from 'express';import { runWithTestId } from '../container';
export const testIdMiddleware = ( req: Request, res: Response, next: NextFunction): void => { const testId = (req.headers['x-scenarist-test-id'] as string) ?? 'default-test';
runWithTestId(testId, () => { next(); });};6. Express App Setup
Section titled “6. Express App Setup”import express from 'express';import { testIdMiddleware } from './middleware/test-id-middleware';import { createRepositories } from './container';
const app = express();const { userRepository } = createRepositories();
app.use(express.json());app.use(testIdMiddleware);
app.get('/users', async (req, res) => { const users = await userRepository.findAll(); res.json({ users });});
app.post('/users', async (req, res) => { const { email, name } = req.body;
const existing = await userRepository.findByEmail(email); if (existing) { return res.status(400).json({ error: 'Email already registered' }); }
const user = await userRepository.create({ email, name }); res.status(201).json({ user });});
export { app };7. Playwright Tests
Section titled “7. Playwright Tests”import { test, expect } from '@playwright/test';
test.describe('User Registration', () => { test('should register new user', async ({ page, switchScenario }) => { // Scenarist mocks external email API await switchScenario(page, 'emailSuccess');
// Repository pattern isolates database by test ID // (test ID automatically flows from x-scenarist-test-id header set by Scenarist)
await page.goto('/register'); await page.fill('[name="email"]', 'test@example.com'); await page.fill('[name="name"]', 'Test User'); await page.click('button[type="submit"]');
await expect(page.getByText('Welcome, Test User')).toBeVisible(); });
test('should show error for duplicate email', async ({ page, switchScenario }) => { await switchScenario(page, 'emailSuccess');
// First registration await page.goto('/register'); await page.fill('[name="email"]', 'duplicate@example.com'); await page.fill('[name="name"]', 'First User'); await page.click('button[type="submit"]');
// Second registration with same email await page.goto('/register'); await page.fill('[name="email"]', 'duplicate@example.com'); await page.fill('[name="name"]', 'Second User'); await page.click('button[type="submit"]');
await expect(page.getByText('Email already registered')).toBeVisible(); });});
// These tests run in PARALLEL with full isolation:// - Test A (x-scenarist-test-id: abc-123) → store['abc-123']// - Test B (x-scenarist-test-id: def-456) → store['def-456']// - Same email in different tests → no conflictHow the Test ID Flows
Section titled “How the Test ID Flows”- Playwright sends request with
x-scenarist-test-idheader (set automatically by Scenarist) - Express middleware extracts the test ID and stores it in
AsyncLocalStorage - Repository calls
getTestId()to retrieve the test ID fromAsyncLocalStorage - Data partitioning ensures each test ID maps to its own isolated data store
This is the same pattern Scenarist uses internally—AsyncLocalStorage carries context through the async request lifecycle.
Why This Approach Excels
Section titled “Why This Approach Excels”True Parallelism with Full Isolation
Section titled “True Parallelism with Full Isolation”Each test gets its own isolated data store, keyed by test ID. Tests run concurrently without interference—the same isolation model as Scenarist’s HTTP mocking.
Fast Execution
Section titled “Fast Execution”In-memory repositories are orders of magnitude faster than real databases:
- No network round-trips
- No disk I/O
- No connection pool overhead
- No query parsing/planning
Infrastructure Flexibility
Section titled “Infrastructure Flexibility”The repository pattern decouples your application from specific persistence technologies. Today you’re using PostgreSQL with Prisma—tomorrow you might migrate to:
- A different database (MySQL, MongoDB)
- A different ORM (Drizzle, TypeORM)
- A different architecture (microservices, event sourcing)
Your business logic remains unchanged because it depends on interfaces, not implementations.
ORM Agnostic
Section titled “ORM Agnostic”The interface is your contract. Swap between Prisma, Drizzle, TypeORM, Knex, or raw SQL without changing tests:
// All these implement the same interfaceconst prismaRepo = new PrismaUserRepository(prisma);const drizzleRepo = new DrizzleUserRepository(db);const knexRepo = new KnexUserRepository(knex);const testRepo = new InMemoryUserRepository(getTestId);No Schema Changes
Section titled “No Schema Changes”Unlike PostgreSQL RLS or test ID columns, the repository pattern requires no database schema modifications. Your production database remains clean.
Trade-offs to Consider
Section titled “Trade-offs to Consider”Requires Abstracting All Database Access
Section titled “Requires Abstracting All Database Access”Every database call must go through a repository interface. For existing codebases, this can be significant refactoring:
// Before: Direct ORM calls scattered throughout codeconst user = await prisma.user.findUnique({ where: { id } });
// After: All access through repositoriesconst user = await userRepository.findById(id);Two Implementations to Maintain
Section titled “Two Implementations to Maintain”You must maintain both production and test implementations. When the interface changes, both must be updated:
// Add new method to interfaceinterface UserRepository { // ... existing methods findByRole(role: string): Promise<User[]>; // NEW}
// Must implement in BOTH:// - PrismaUserRepository// - InMemoryUserRepositoryDoesn’t Test Real Database Behavior
Section titled “Doesn’t Test Real Database Behavior”The in-memory implementation doesn’t execute actual SQL. Potential issues you might miss:
- Query performance problems
- Database constraints (unique, foreign keys)
- Transaction isolation issues
- ORM-specific edge cases
When to Choose This Approach
Section titled “When to Choose This Approach”The repository pattern is worth adopting if you value:
- Test parallelism - Run hundreds of database tests concurrently
- Infrastructure flexibility - Ability to change databases, ORMs, or architectures without rewriting business logic
- Fast feedback loops - In-memory tests complete in milliseconds
- Clean architecture - Better separation of concerns between domain logic and persistence
- Long-term maintainability - Codebase that’s easier to understand, test, and evolve
The investment required:
- Existing codebases: Refactoring direct ORM calls to use repositories takes time. Start with new features and gradually migrate existing code.
- Two implementations: You maintain production and test repositories. TypeScript ensures they stay in sync.
- Learning curve: If your team is new to dependency injection, there’s initial learning investment.
Consider simpler alternatives if:
- You have a small test suite where sequential execution is fast enough
- You need to test specific database behavior (query performance, constraints, transactions)
- The refactoring cost outweighs the parallelism benefit for your current project timeline
Beyond Testing: Why the Repository Pattern is Good Practice
Section titled “Beyond Testing: Why the Repository Pattern is Good Practice”The repository pattern is widely adopted because it follows fundamental software design principles:
Separation of Concerns
Section titled “Separation of Concerns”Your business logic focuses on what to do, not how to persist data. The UserService doesn’t know or care whether data comes from PostgreSQL, MongoDB, or an API—it just calls userRepository.findById().
Dependency Inversion
Section titled “Dependency Inversion”High-level business logic depends on abstractions (interfaces), not concrete implementations. This is the “D” in SOLID principles. Your domain code depends on UserRepository (interface), not PrismaUserRepository (implementation).
Single Responsibility
Section titled “Single Responsibility”Each class has one job:
PrismaUserRepository→ translates domain operations to Prisma queriesUserService→ implements business rulesUserController→ handles HTTP requests
Open/Closed Principle
Section titled “Open/Closed Principle”Add new persistence strategies without modifying existing code. Need to cache frequently accessed data? Create a CachedUserRepository that wraps the real one. Need to log all database access? Create a LoggingUserRepository decorator.
Type Safety
Section titled “Type Safety”TypeScript interfaces ensure your production and test implementations have matching signatures. If you add a method to the interface, both implementations must implement it.
Further Reading
Section titled “Further Reading”- Repository Pattern - Martin Fowler’s original definition
- Hexagonal Architecture - Alistair Cockburn’s ports and adapters
- Clean Architecture - Uncle Bob’s architectural principles
- Testing Without Mocks - James Shore on “Nullable Infrastructure”
- Domain-Driven Design Reference - Eric Evans on repositories in DDD
Next Steps
Section titled “Next Steps”- Parallelism Options Overview - Compare all approaches
- Testcontainers Hybrid - Test real database behavior when needed
- Scenarist Getting Started - Set up HTTP mocking