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”| Responsibility | Tool | What It Does |
|---|---|---|
| HTTP API mocking | Scenarist | Intercepts fetch calls, returns mocks based on test ID |
| Database state isolation | Your code | Partitions database queries/data based on test ID |
Implementing Database Isolation
Section titled “Implementing Database Isolation”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
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 policyCREATE 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
Approach D: Database Snapshots/Migrations
Section titled “Approach D: Database Snapshots/Migrations”Pre-seed database with known state, reset between tests.
Best for: Testing specific SQL behavior; integration tests
Trade-offs: Slower; harder to parallelize
Our Recommendation: Repository Pattern
Section titled “Our Recommendation: Repository Pattern”For teams that want scalable parallel testing of real server-side code, we recommend the repository pattern because:
- Same isolation model as Scenarist — Test ID partitions both HTTP and database
- Fast execution — In-memory stores are orders of magnitude faster than real databases
- Clean architecture — Benefits extend beyond testing (infrastructure flexibility, SOLID principles)
- ORM agnostic — Works with Prisma, Drizzle, TypeORM, raw SQL
Full Repository Pattern Guide →
Why Database Testing Is Different
Section titled “Why Database Testing Is Different”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 requestexport 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.
Quick Decision Guide
Section titled “Quick Decision Guide”| Your Situation | Approach |
|---|---|
| Want scalable parallel tests + clean architecture | Repository Pattern |
| Tests have direct database access | Test fixtures with direct seeding |
| Can’t change application code | Row-level security or test ID columns |
| Need to test actual SQL queries, constraints | Testcontainers |
| Small test suite, prototyping | Sequential Execution |
Next Steps
Section titled “Next Steps”- Repository Pattern Guide — Full implementation walkthrough
- Testcontainers Hybrid — Testing real database behavior
- All Parallelism Options — Detailed comparison of approaches