Skip to content

Testing Apps with Database Access

The Pattern: Shared Identity for Parallel Test Isolation

Section titled “The Pattern: Shared Identity for Parallel Test Isolation”

When testing applications that access multiple data sources (HTTP APIs + databases), the key insight is simple:

Use a single identifier to partition ALL data sources.

┌─────────────────────────────────────────────────────────┐
│ Test Runner │
├─────────────────────────────────────────────────────────┤
│ Test A (x-scenarist-test-id: abc-123) │
│ │ │
│ ├─→ HTTP API ──→ Scenarist ──→ Mocks for "abc-123" │
│ │ │
│ └─→ Database ──→ Your Code ──→ Data for "abc-123" │
├─────────────────────────────────────────────────────────┤
│ Test B (x-scenarist-test-id: xyz-789) │
│ │ │
│ ├─→ HTTP API ──→ Scenarist ──→ Mocks for "xyz-789" │
│ │ │
│ └─→ Database ──→ Your Code ──→ Data for "xyz-789" │
└─────────────────────────────────────────────────────────┘
Both tests run in PARALLEL with completely isolated state.

This is the same pattern used in distributed systems for request tracing, multi-tenancy, and session management. The test ID is just another form of correlation ID.


What Scenarist Provides vs. What You Implement

Section titled “What Scenarist Provides vs. What You Implement”
ResponsibilityToolWhat It Does
HTTP API mockingScenaristIntercepts fetch calls, returns mocks based on test ID
Database state isolationYour codePartitions database queries/data based on test ID

There are multiple valid approaches to partition database state by test ID. Each has different trade-offs:

Approach A: Repository Pattern with In-Memory Store

Section titled “Approach A: Repository Pattern with In-Memory Store”

Abstract database access behind interfaces, inject test implementations that partition data by test ID.

Best for: Fast parallel tests, clean architecture, any ORM

Trade-offs: Requires refactoring database access; doesn’t test real SQL

Detailed Guide →

Approach B: Test Fixtures with Direct Seeding

Section titled “Approach B: Test Fixtures with Direct Seeding”

Seed database directly in test setup, using test ID to isolate data.

test('premium user sees discounts', async ({ page }) => {
const testId = generateTestId();
// Seed directly (if tests have DB access)
await db.users.create({
testId,
tier: 'premium',
// ...
});
// Or seed via HTTP endpoint
await page.request.post('/test/seed', {
headers: { 'x-scenarist-test-id': testId },
data: { scenarioId: 'premiumUser' }
});
await page.goto('/products');
// ...
});

Best for: When tests have direct database access (e.g., same process)

Trade-offs: Tests coupled to database schema; requires cleanup logic

Approach C: Row-Level Security or Test ID Columns

Section titled “Approach C: Row-Level Security or Test ID Columns”

Add test_id column to tables, filter all queries automatically.

-- PostgreSQL RLS policy
CREATE POLICY test_isolation ON users
USING (test_id = current_setting('app.test_id'));

Best for: When you can’t change application code

Trade-offs: Schema changes in production; RLS overhead

Pre-seed database with known state, reset between tests.

Best for: Testing specific SQL behavior; integration tests

Trade-offs: Slower; harder to parallelize


For teams that want scalable parallel testing of real server-side code, we recommend the repository pattern because:

  1. Same isolation model as Scenarist — Test ID partitions both HTTP and database
  2. Fast execution — In-memory stores are orders of magnitude faster than real databases
  3. Clean architecture — Benefits extend beyond testing (infrastructure flexibility, SOLID principles)
  4. ORM agnostic — Works with Prisma, Drizzle, TypeORM, raw SQL

Full Repository Pattern Guide →


Scenarist intercepts HTTP requests via MSW because every HTTP library eventually calls the same underlying APIs. Databases have no equivalent universal interception point.

Each ORM and driver is a different API surface:

  • Prisma: prisma.user.findMany()
  • Drizzle: db.select().from(users)
  • TypeORM: userRepository.find()
  • Raw SQL: pg.query('SELECT * FROM users')
// ❌ Scenarist CANNOT mock this - no HTTP request
export async function fetchProducts() {
return await db.products.findMany(); // Direct database call
}

That’s why you implement database isolation, using whatever approach fits your architecture. The examples show one way; adapt it to your needs.


Your SituationApproach
Want scalable parallel tests + clean architectureRepository Pattern
Tests have direct database accessTest fixtures with direct seeding
Can’t change application codeRow-level security or test ID columns
Need to test actual SQL queries, constraintsTestcontainers
Small test suite, prototypingSequential Execution