Database Testing
Why This Matters
Database testing in end-to-end (E2E) test suites presents critical challenges for production systems. Tests that share database state create flaky tests where one test’s data mutations affect subsequent tests, leading to false failures and unpredictable CI/CD pipelines. Production systems require data integrity guarantees: tests must verify application behavior without corrupting production-like data, maintain isolation between parallel test executions, and provide reproducible test environments for debugging failures.
The complexity multiplies when testing multi-user scenarios, transaction boundaries, and error handling paths. Manual database cleanup after each test is error-prone and incomplete, often leaving orphaned records that accumulate over time. Production-grade database testing requires systematic approaches to fixture management, transaction rollback strategies, and database state verification. Without these patterns, E2E tests become maintenance nightmares that teams eventually disable or ignore.
This guide demonstrates progressive approaches to database testing in Playwright tests: starting with Node.js standard library for direct SQL execution, identifying limitations for production scale, then introducing Prisma ORM for type-safe database fixtures, transaction management, and production-grade isolation strategies. You’ll learn patterns for database seeding, automatic cleanup, and parallel test execution with proper database state management.
Standard Library Approach: Direct SQL with node-postgres
TypeScript’s node:assert and pg (node-postgres) provide basic database testing capabilities through direct SQL execution.
// test/user-registration.spec.ts
import { test } from "@playwright/test";
import { strict as assert } from "node:assert";
import pg from "pg";
// => node:assert provides assertion utilities
// => pg provides PostgreSQL client for Node.js
// => Playwright test runner executes test functions
const client = new pg.Client({
// => Creates PostgreSQL client instance
// => Connection configured via environment or defaults
host: "localhost",
// => Database server hostname
database: "test_db",
// => Test database name (isolated from production)
user: "test_user",
// => Database user for test execution
password: "test_password",
// => User credentials (should use environment variables)
});
test.beforeAll(async () => {
// => Runs once before all tests in file
// => Establishes database connection for fixture operations
await client.connect();
// => Opens TCP connection to PostgreSQL server
// => Required before executing queries
});
test.afterAll(async () => {
// => Runs once after all tests complete
// => Cleanup database connection
await client.end();
// => Closes PostgreSQL connection
// => Releases connection pool resources
});
test("should register user with valid email", async ({ page }) => {
// => Playwright provides page fixture
// => Test verifies user registration flow
// Setup: Insert test data
await client.query(`
DELETE FROM users WHERE email = 'test@example.com';
`);
// => Removes existing test user (if present)
// => Ensures clean state before test execution
// => Prevents primary key conflicts
// Execute: Navigate and fill form
await page.goto("http://localhost:3000/register");
// => Loads registration page in browser
// => Application connects to database for operations
await page.fill('[name="email"]', "test@example.com");
// => Enters email into registration form input
await page.fill('[name="password"]', "SecurePass123!");
// => Enters password meeting validation requirements
await page.fill('[name="name"]', "Test User");
// => Enters full name for user profile
await page.click('button[type="submit"]');
// => Submits registration form
// => Triggers application to insert user record
await page.waitForURL("http://localhost:3000/dashboard");
// => Waits for redirect after successful registration
// => Indicates registration completed successfully
// Verify: Check database state
const result = await client.query(`
SELECT id, email, name FROM users WHERE email = 'test@example.com';
`);
// => Queries database for inserted user record
// => Verifies application actually persisted data
assert.equal(result.rows.length, 1, "User should be created");
// => Verifies exactly one user record exists
// => Throws AssertionError if count != 1
assert.equal(result.rows[0].email, "test@example.com");
// => Verifies email stored correctly
assert.equal(result.rows[0].name, "Test User");
// => Verifies name stored correctly
// Teardown: Cleanup test data
await client.query(`
DELETE FROM users WHERE email = 'test@example.com';
`);
// => Removes test user after verification
// => Manual cleanup required for test isolation
});Limitations for production:
- Manual cleanup fragility: If test fails before teardown, data persists causing future test failures
- No transaction rollback: Cannot automatically undo all database changes if test encounters errors
- Race conditions: Parallel test execution causes primary key conflicts and data corruption
- No type safety: SQL strings have no compile-time validation, runtime errors common
- Connection pooling complexity: Manual connection management doesn’t scale to hundreds of tests
- Foreign key cascading: Deleting test data in wrong order violates constraints, requires manual dependency tracking
- Migration synchronization: Database schema changes require manual SQL query updates
- No fixture reusability: Common test data patterns (admin user, sample products) require duplication
Production Framework: Prisma with Transaction Rollback
Prisma provides type-safe database access with automatic transaction management and fixture isolation for production-grade testing.
Installation and Setup
npm install --save-dev prisma @prisma/client
# => Installs Prisma CLI and client library
# => --save-dev: development dependency for testing
npx prisma init
# => Generates prisma/ directory with schema.prisma
# => Creates .env file with DATABASE_URL placeholder// prisma/schema.prisma
datasource db {
provider = "postgresql"
// => Specifies PostgreSQL database
url = env("DATABASE_URL")
// => Reads connection string from environment variable
}
generator client {
provider = "prisma-client-js"
// => Generates TypeScript client with types
// => Provides type-safe database queries
}
model User {
// => Defines User table schema
id Int @id @default(autoincrement())
// => Primary key with auto-increment
email String @unique
// => Unique constraint prevents duplicate emails
name String
// => User full name (required field)
password String
// => Hashed password (never plain text)
createdAt DateTime @default(now())
// => Automatic timestamp on record creation
}// test/fixtures/database.ts
import { PrismaClient } from "@prisma/client";
// => Prisma generated client with User type
// => Provides type-safe query methods
export class DatabaseFixture {
// => Encapsulates database fixture management
// => Provides transaction isolation for tests
private prisma: PrismaClient;
// => Prisma client instance for queries
private transactionClient: PrismaClient | null = null;
// => Active transaction client (null before setup)
constructor() {
this.prisma = new PrismaClient({
// => Creates Prisma client with configuration
datasources: {
db: {
url: process.env.TEST_DATABASE_URL,
// => Uses separate test database
// => Prevents production data corruption
},
},
log: ["error"],
// => Logs only errors for cleaner test output
// => Set to ['query'] for debugging SQL issues
});
}
async setup(): Promise<void> {
// => Starts transaction for test isolation
// => Called in test.beforeEach hook
await this.prisma.$transaction(
async (tx) => {
// => Begins database transaction
// => All operations within tx are atomic
this.transactionClient = tx as PrismaClient;
// => Stores transaction client for test use
// => Type assertion for Prisma transaction type
},
{
maxWait: 10000,
// => Maximum time (ms) to wait for transaction lock
// => Prevents deadlocks in parallel test execution
timeout: 60000,
// => Transaction timeout (ms)
// => Long timeout for slow test operations
},
);
}
async teardown(): Promise<void> {
// => Rolls back transaction after test
// => Automatic cleanup without manual DELETE queries
if (this.transactionClient) {
// => Checks transaction exists
await this.transactionClient.$rollback();
// => Discards all database changes in transaction
// => Restores database to pre-test state
this.transactionClient = null;
// => Clears transaction reference
}
}
async cleanup(): Promise<void> {
// => Closes database connection after all tests
// => Called in test.afterAll hook
await this.prisma.$disconnect();
// => Closes connection pool
// => Releases database resources
}
getClient(): PrismaClient {
// => Returns transaction-scoped client
// => All queries execute within isolated transaction
if (!this.transactionClient) {
throw new Error("Transaction not started. Call setup() first.");
// => Prevents queries outside transaction
// => Ensures test isolation guarantees
}
return this.transactionClient;
// => Returns client for type-safe queries
}
}// test/user-registration.spec.ts
import { test, expect } from "@playwright/test";
import { DatabaseFixture } from "./fixtures/database";
// => DatabaseFixture provides transaction isolation
// => Playwright test and expect for assertions
test.describe("User Registration", () => {
// => Groups related tests for database operations
// => Shares database fixture setup/teardown
let dbFixture: DatabaseFixture;
// => Database fixture instance for test suite
// => Provides transaction-isolated queries
test.beforeAll(async () => {
// => Runs once before all tests in describe block
dbFixture = new DatabaseFixture();
// => Creates database fixture instance
// => Establishes connection pool
});
test.beforeEach(async () => {
// => Runs before each test in describe block
await dbFixture.setup();
// => Starts new transaction for test isolation
// => Each test gets clean database state
});
test.afterEach(async () => {
// => Runs after each test completes
await dbFixture.teardown();
// => Rolls back transaction
// => Automatic cleanup without manual DELETE queries
});
test.afterAll(async () => {
// => Runs once after all tests complete
await dbFixture.cleanup();
// => Closes database connection pool
// => Releases resources
});
test("should register user with valid credentials", async ({ page }) => {
// => Playwright provides page fixture
// => Test verifies registration flow and database persistence
const client = dbFixture.getClient();
// => Gets transaction-scoped Prisma client
// => All queries isolated from other tests
// Navigate to registration page
await page.goto("http://localhost:3000/register");
// => Loads registration form in browser
await page.waitForLoadState("networkidle");
// => Waits for page assets to load completely
// Fill registration form
await page.fill('[data-testid="email-input"]', "test@example.com");
// => Enters email (data-testid for stable selectors)
await page.fill('[data-testid="password-input"]', "SecurePass123!");
// => Enters password meeting validation requirements
await page.fill('[data-testid="name-input"]', "Test User");
// => Enters full name for user profile
// Submit form and wait for redirect
await Promise.all([
page.waitForURL("http://localhost:3000/dashboard"),
// => Waits for successful registration redirect
page.click('[data-testid="submit-button"]'),
// => Clicks submit button to trigger registration
]);
// => Promise.all ensures both operations complete
// Verify database state with type-safe query
const user = await client.user.findUnique({
// => Type-safe query with User model
// => Prisma validates field names at compile time
where: { email: "test@example.com" },
// => Queries by unique email field
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
// => Selects specific fields (excludes password)
});
expect(user).toBeTruthy();
// => Verifies user record exists
expect(user?.email).toBe("test@example.com");
// => Verifies email matches input
expect(user?.name).toBe("Test User");
// => Verifies name matches input
expect(user?.createdAt).toBeInstanceOf(Date);
// => Verifies createdAt is valid Date object
// No manual cleanup needed - transaction rollback handles it
// => teardown() automatically removes all test data
});
test("should prevent duplicate email registration", async ({ page }) => {
// => Verifies unique constraint enforcement
// => Tests error handling for duplicate data
const client = dbFixture.getClient();
// => Gets transaction-scoped client
// Seed existing user
await client.user.create({
// => Type-safe create operation
data: {
email: "existing@example.com",
name: "Existing User",
password: "HashedPassword123",
// => In production, use bcrypt/argon2 hashing
},
});
// => Creates user record in transaction-isolated state
// Attempt registration with duplicate email
await page.goto("http://localhost:3000/register");
await page.fill('[data-testid="email-input"]', "existing@example.com");
// => Uses same email as seeded user
await page.fill('[data-testid="password-input"]', "NewPass456!");
await page.fill('[data-testid="name-input"]', "New User");
await page.click('[data-testid="submit-button"]');
// => Submits form with duplicate email
// => Application should reject with validation error
// Verify error message displayed
const errorMessage = page.locator('[data-testid="error-message"]');
// => Locates error message element
await expect(errorMessage).toContainText("Email already registered");
// => Verifies application displays user-friendly error
// Verify only one user exists with this email
const users = await client.user.findMany({
where: { email: "existing@example.com" },
});
// => Queries all users with this email
expect(users).toHaveLength(1);
// => Verifies no duplicate record created
expect(users[0].name).toBe("Existing User");
// => Verifies original user data unchanged
// Transaction rollback removes seeded user automatically
});
});Database State Progression Diagram
graph TD
A["Standard Library:<br/>Manual SQL Cleanup"] -->|"Race Conditions"| B["Failed Tests<br/>Data Corruption"]
A -->|"Manual DELETE"| C["Missed Cleanup<br/>Orphaned Data"]
D["Production Framework:<br/>Prisma Transactions"] -->|"Automatic Rollback"| E["Clean State<br/>Every Test"]
D -->|"Parallel Execution"| F["Isolated Transactions<br/>No Conflicts"]
G["Test 1 Transaction"] -.->|"Rollback"| H["Database State"]
I["Test 2 Transaction"] -.->|"Rollback"| H
J["Test 3 Transaction"] -.->|"Rollback"| H
style A fill:#CC78BC,stroke:#9D5F99,color:#000
style D fill:#0173B2,stroke:#015A8F,color:#fff
style B fill:#DE8F05,stroke:#B17304,color:#000
style C fill:#DE8F05,stroke:#B17304,color:#000
style E fill:#029E73,stroke:#017A59,color:#fff
style F fill:#029E73,stroke:#017A59,color:#fff
style H fill:#CA9161,stroke:#A1734E,color:#000
Production Patterns and Best Practices
Pattern 1: Database Seeding for Complex Fixtures
Production tests require realistic data with relationships (users with orders, products with categories). Seeding provides reusable fixtures.
// test/fixtures/seeds.ts
import { PrismaClient } from "@prisma/client";
// => Prisma client for type-safe seeding operations
export interface TestUser {
// => Type definition for test user data
// => Ensures consistent fixture structure
id: number;
email: string;
name: string;
role: "ADMIN" | "USER";
}
export interface TestProduct {
// => Type definition for test product data
id: number;
name: string;
price: number;
categoryId: number;
}
export class DatabaseSeeds {
// => Encapsulates reusable test data creation
// => Provides factory methods for common fixtures
constructor(private client: PrismaClient) {
// => Accepts Prisma client (transaction-scoped)
// => All seeds execute within test transaction
}
async seedUser(overrides?: Partial<Omit<TestUser, "id">>): Promise<TestUser> {
// => Factory method for user creation
// => overrides: Optional field overrides for customization
// => Returns created user with database-generated ID
const user = await this.client.user.create({
data: {
email: overrides?.email ?? "user@example.com",
// => Uses override or default email
// => ?? operator: null coalescing for defaults
name: overrides?.name ?? "Test User",
// => Uses override or default name
password: "$2b$10$...",
// => Placeholder bcrypt hash
// => Production: use actual hashing library
role: overrides?.role ?? "USER",
// => Default role is USER, can override to ADMIN
},
});
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
// => Returns serialized user data
// => Excludes password for security
}
async seedAdminUser(): Promise<TestUser> {
// => Convenience method for admin user creation
// => Common pattern in authorization tests
return this.seedUser({
email: "admin@example.com",
name: "Admin User",
role: "ADMIN",
});
// => Delegates to seedUser with admin overrides
}
async seedProduct(overrides?: Partial<Omit<TestProduct, "id">>): Promise<TestProduct> {
// => Factory method for product creation
// => Requires category (foreign key)
// Ensure category exists
let category = await this.client.category.findFirst();
// => Attempts to find existing category
// => Avoids creating duplicate categories
if (!category) {
category = await this.client.category.create({
data: { name: "Test Category" },
});
// => Creates category if none exists
// => Ensures foreign key constraint satisfied
}
const product = await this.client.product.create({
data: {
name: overrides?.name ?? "Test Product",
price: overrides?.price ?? 10.99,
categoryId: overrides?.categoryId ?? category.id,
// => Uses override category or default
stock: 100,
// => Default stock quantity
},
});
return {
id: product.id,
name: product.name,
price: product.price,
categoryId: product.categoryId,
};
// => Returns serialized product data
}
async seedOrderWithItems(userId: number, productIds: number[]): Promise<number> {
// => Creates order with multiple line items
// => Demonstrates nested transaction writes
const order = await this.client.order.create({
data: {
userId,
// => Associates order with user
status: "PENDING",
// => Initial order status
items: {
create: productIds.map((productId) => ({
// => Creates OrderItem records in single query
// => Nested create for relational data
productId,
quantity: 1,
// => Default quantity per item
})),
},
},
});
return order.id;
// => Returns order ID for verification
}
}// test/order-flow.spec.ts
import { test, expect } from "@playwright/test";
import { DatabaseFixture } from "./fixtures/database";
import { DatabaseSeeds } from "./fixtures/seeds";
// => Imports fixture and seeding utilities
test.describe("Order Flow", () => {
let dbFixture: DatabaseFixture;
let seeds: DatabaseSeeds;
// => Shared fixtures for test suite
test.beforeAll(async () => {
dbFixture = new DatabaseFixture();
});
test.beforeEach(async () => {
await dbFixture.setup();
seeds = new DatabaseSeeds(dbFixture.getClient());
// => Creates seeds with transaction-scoped client
// => All seeded data isolated to current test
});
test.afterEach(async () => {
await dbFixture.teardown();
});
test.afterAll(async () => {
await dbFixture.cleanup();
});
test("admin can view all orders", async ({ page }) => {
// => Verifies admin dashboard displays all orders
// => Tests role-based access control
const admin = await seeds.seedAdminUser();
// => Creates admin user with ADMIN role
const regularUser = await seeds.seedUser({
email: "customer@example.com",
name: "Customer",
});
// => Creates regular user with USER role
const product = await seeds.seedProduct({
name: "Laptop",
price: 999.99,
});
// => Creates product for order
const orderId = await seeds.seedOrderWithItems(regularUser.id, [product.id]);
// => Creates order for regular user
// => Admin should see this order in dashboard
// Login as admin
await page.goto("http://localhost:3000/login");
await page.fill('[data-testid="email"]', admin.email);
await page.fill('[data-testid="password"]', "password123");
// => In production, use actual credentials or auth tokens
await page.click('[data-testid="submit"]');
// Navigate to admin orders page
await page.goto("http://localhost:3000/admin/orders");
await page.waitForLoadState("networkidle");
// => Waits for order data to load
// Verify order appears in list
const orderRow = page.locator(`[data-testid="order-${orderId}"]`);
await expect(orderRow).toBeVisible();
// => Verifies admin can see order
await expect(orderRow).toContainText("Customer");
// => Verifies customer name displayed
await expect(orderRow).toContainText("$999.99");
// => Verifies order total displayed
// Transaction rollback removes all seeded data
});
});Pattern 2: Transaction Rollback with Savepoints
Advanced tests require partial rollback (undo specific operations while preserving others). Savepoints enable nested transaction control.
// test/fixtures/database-advanced.ts
import { PrismaClient } from "@prisma/client";
export class AdvancedDatabaseFixture {
// => Extends basic fixture with savepoint support
// => Enables fine-grained transaction control
private prisma: PrismaClient;
private savepoints: Map<string, string> = new Map();
// => Tracks savepoint names for rollback
// => Map key: savepoint name, value: SQL identifier
constructor() {
this.prisma = new PrismaClient({
datasources: {
db: {
url: process.env.TEST_DATABASE_URL,
},
},
});
}
async setup(): Promise<void> {
// => Starts transaction for test isolation
await this.prisma.$executeRaw`BEGIN`;
// => Explicit BEGIN for raw SQL transaction control
// => Required for savepoint operations
}
async createSavepoint(name: string): Promise<void> {
// => Creates named savepoint in current transaction
// => Allows rollback to this point later
const safeName = name.replace(/[^a-zA-Z0-9_]/g, "_");
// => Sanitizes savepoint name (alphanumeric + underscore only)
// => Prevents SQL injection in savepoint identifiers
await this.prisma.$executeRaw`SAVEPOINT ${safeName}`;
// => Creates savepoint with sanitized name
// => PostgreSQL syntax: SAVEPOINT identifier
this.savepoints.set(name, safeName);
// => Stores mapping for later rollback
}
async rollbackToSavepoint(name: string): Promise<void> {
// => Rolls back transaction to named savepoint
// => Undoes all operations after savepoint creation
const safeName = this.savepoints.get(name);
if (!safeName) {
throw new Error(`Savepoint ${name} not found`);
// => Prevents rollback to non-existent savepoint
}
await this.prisma.$executeRaw`ROLLBACK TO SAVEPOINT ${safeName}`;
// => Rolls back to savepoint
// => Preserves operations before savepoint
await this.prisma.$executeRaw`RELEASE SAVEPOINT ${safeName}`;
// => Releases savepoint (no longer needed)
// => Frees transaction resources
this.savepoints.delete(name);
// => Removes savepoint from tracking
}
async teardown(): Promise<void> {
// => Rolls back entire transaction
await this.prisma.$executeRaw`ROLLBACK`;
// => Discards all changes (including savepoints)
this.savepoints.clear();
// => Clears savepoint tracking
}
async cleanup(): Promise<void> {
await this.prisma.$disconnect();
}
getClient(): PrismaClient {
return this.prisma;
}
}// test/multi-step-workflow.spec.ts
import { test, expect } from "@playwright/test";
import { AdvancedDatabaseFixture } from "./fixtures/database-advanced";
// => Uses advanced fixture with savepoint support
test.describe("Multi-Step Workflow", () => {
let dbFixture: AdvancedDatabaseFixture;
test.beforeAll(async () => {
dbFixture = new AdvancedDatabaseFixture();
});
test.beforeEach(async () => {
await dbFixture.setup();
});
test.afterEach(async () => {
await dbFixture.teardown();
});
test.afterAll(async () => {
await dbFixture.cleanup();
});
test("should handle partial workflow rollback", async ({ page }) => {
// => Tests workflow with conditional rollback
// => Simulates error in middle of multi-step process
const client = dbFixture.getClient();
// Step 1: Create user account
await client.user.create({
data: {
email: "user@example.com",
name: "Test User",
password: "hashed",
},
});
// => Creates user account (should persist)
await dbFixture.createSavepoint("after_user_creation");
// => Marks point before order creation
// => Allows rollback of order without losing user
// Step 2: Attempt order creation (will fail)
try {
await client.order.create({
data: {
userId: 999,
// => Invalid user ID (doesn't exist)
// => Foreign key constraint will fail
status: "PENDING",
},
});
} catch (error) {
// => Catches foreign key constraint violation
await dbFixture.rollbackToSavepoint("after_user_creation");
// => Rolls back order creation
// => User creation remains committed in transaction
}
// Verify user exists but no order created
const user = await client.user.findUnique({
where: { email: "user@example.com" },
});
expect(user).toBeTruthy();
// => Verifies user creation persisted after savepoint rollback
const orders = await client.order.findMany();
expect(orders).toHaveLength(0);
// => Verifies order creation rolled back
// Full transaction rollback in teardown removes user
});
});Pattern 3: SQL Fixtures with Prisma Raw Queries
Complex database testing requires direct SQL control for performance-critical operations (bulk inserts, complex queries, database-specific features).
// test/fixtures/sql-fixtures.ts
import { PrismaClient } from "@prisma/client";
export class SQLFixtures {
// => Provides raw SQL fixture operations
// => Useful for bulk operations and database-specific features
constructor(private client: PrismaClient) {}
async bulkInsertUsers(count: number): Promise<void> {
// => Inserts multiple users efficiently with raw SQL
// => Much faster than individual Prisma create calls
const values = Array.from({ length: count }, (_, i) => {
// => Generates array of user data
// => _ unused iterator, i is index
return `('user${i}@example.com', 'User ${i}', 'hashed${i}', 'USER')`;
}).join(",");
// => Joins value tuples with commas
// => Example: ('email1', 'name1', 'pass1', 'USER'),('email2'...)
await this.client.$executeRaw`
INSERT INTO "User" (email, name, password, role)
VALUES ${values}
`;
// => Single INSERT with multiple value tuples
// => Executes in single database round-trip
// => Significantly faster than N individual INSERTs
}
async bulkInsertProducts(categoryId: number, count: number): Promise<void> {
// => Inserts multiple products for category
// => Useful for pagination/search testing
const values = Array.from({ length: count }, (_, i) => {
return `('Product ${i}', ${(i + 1) * 10.99}, ${categoryId}, ${100 + i})`;
// => Tuple: (name, price, categoryId, stock)
}).join(",");
await this.client.$executeRaw`
INSERT INTO "Product" (name, price, "categoryId", stock)
VALUES ${values}
`;
// => Bulk insert products in single query
}
async executeSQLFile(filePath: string): Promise<void> {
// => Executes SQL file for complex fixtures
// => Useful for realistic production-like data
const fs = await import("fs/promises");
const sql = await fs.readFile(filePath, "utf-8");
// => Reads SQL file contents
// => Supports multi-statement SQL scripts
const statements = sql.split(";").filter((s) => s.trim());
// => Splits into individual statements
// => Filters empty statements
for (const statement of statements) {
await this.client.$executeRawUnsafe(statement);
// => Executes each statement separately
// => $executeRawUnsafe: allows dynamic SQL strings
// => WARNING: Only use with trusted SQL files (SQL injection risk)
}
}
async getTableRowCount(tableName: string): Promise<number> {
// => Counts rows in table with raw SQL
// => Useful for bulk operation verification
const result = await this.client.$queryRaw<[{ count: bigint }]>`
SELECT COUNT(*) as count FROM ${tableName}
`;
// => Executes COUNT query
// => Returns array with single object containing count
return Number(result[0].count);
// => Converts bigint to number
// => bigint required for tables with >2B rows
}
}// test/performance/large-dataset.spec.ts
import { test, expect } from "@playwright/test";
import { DatabaseFixture } from "../fixtures/database";
import { SQLFixtures } from "../fixtures/sql-fixtures";
// => Imports database fixture and SQL utilities
test.describe("Large Dataset Performance", () => {
let dbFixture: DatabaseFixture;
let sqlFixtures: SQLFixtures;
test.beforeAll(async () => {
dbFixture = new DatabaseFixture();
});
test.beforeEach(async () => {
await dbFixture.setup();
sqlFixtures = new SQLFixtures(dbFixture.getClient());
});
test.afterEach(async () => {
await dbFixture.teardown();
});
test.afterAll(async () => {
await dbFixture.cleanup();
});
test("should paginate through 1000 products efficiently", async ({ page }) => {
// => Tests pagination with realistic data volume
// => Verifies performance with large dataset
const client = dbFixture.getClient();
// Create category for products
const category = await client.category.create({
data: { name: "Electronics" },
});
// Bulk insert 1000 products
await sqlFixtures.bulkInsertProducts(category.id, 1000);
// => Single query inserts 1000 products
// => Much faster than 1000 individual Prisma creates
// Verify count
const count = await sqlFixtures.getTableRowCount("Product");
expect(count).toBe(1000);
// => Confirms all products inserted
// Navigate to products page
await page.goto("http://localhost:3000/products");
await page.waitForLoadState("networkidle");
// Verify first page displays 20 products (default pagination)
const productItems = page.locator('[data-testid="product-item"]');
await expect(productItems).toHaveCount(20);
// => Verifies pagination limits results
// Navigate to page 5
await page.click('[data-testid="pagination-5"]');
await page.waitForLoadState("networkidle");
// Verify page 5 shows products 81-100
const firstProduct = productItems.first();
await expect(firstProduct).toContainText("Product 80");
// => Verifies correct offset (page 5 = skip 80)
// => Product 80 because we start from Product 0
// Transaction rollback removes all 1000 products automatically
});
});Trade-offs and When to Use
Standard Library Approach (node-postgres + manual SQL):
- Use when: Simple CRUD tests with ≤5 tables, no parallel execution, prototype/MVP projects
- Benefits: Zero external dependencies, full SQL control, no ORM learning curve, direct database interaction
- Costs: Manual cleanup prone to errors, no transaction rollback, type safety absent, race conditions in parallel tests, foreign key management complexity
Production Framework (Prisma with transactions):
- Use when: Production applications, parallel test execution, complex data relationships (>5 tables), team projects requiring type safety
- Benefits: Automatic transaction rollback (clean state guaranteed), type-safe queries (compile-time validation), no manual cleanup code, parallel test isolation, migration synchronization, reusable fixtures
- Costs: External dependency (~5MB), learning curve for Prisma schema/API, additional build step (prisma generate), query abstraction hides SQL details
Production recommendation: Use Prisma transactions for production systems with 2+ developers or >10 database tests. Automatic rollback eliminates entire class of flaky test bugs (orphaned data, race conditions) that plague manual cleanup approaches. The upfront investment in Prisma setup pays dividends through reliable CI/CD and zero test maintenance for database state management. For solo developer prototypes with <5 simple tables, manual SQL suffices until complexity justifies framework adoption.
Security Considerations
- Separate test database: NEVER run tests against production database. Use
TEST_DATABASE_URLenvironment variable pointing to isolated test database. Production data corruption from test execution is catastrophic. - Credential management: Store database credentials in
.env.testfile (gitignored). Never hardcode credentials in test code. Use environment variables:process.env.TEST_DATABASE_URL. - SQL injection prevention: Prisma parameterizes queries automatically (safe). Raw SQL with
$executeRawUnsaferequires manual sanitization - only use with trusted input or constant strings. - Test data visibility: Ensure test database credentials have minimal permissions (no DROP DATABASE, no access to production schemas). Use dedicated
test_userrole with only necessary grants (SELECT, INSERT, UPDATE, DELETE on test tables). - Secrets in fixtures: Never seed production API keys, tokens, or real user passwords in test fixtures. Use placeholder hashes (
$2b$10$...) or mock values. Test data should never resemble production secrets. - Database connection limits: Use connection pooling (
PrismaClientreuse) to avoid exhausting database connections during parallel tests. PostgreSQL default max_connections is 100 - ensure test suite respects this limit. - Transaction isolation level: Default READ COMMITTED isolation sufficient for most tests. SERIALIZABLE isolation prevents all concurrency anomalies but impacts parallel test performance - only use when testing race condition handling.
Common Pitfalls
Forgetting transaction rollback: Tests that create database state without rollback cause subsequent test failures. Always pair
setup()withteardown()inbeforeEach/afterEachhooks. Missing teardown = flaky tests.Sharing Prisma client between tests: Creating single global
PrismaClientinstance breaks test isolation. Each test needs transaction-scoped client fromDatabaseFixture.getClient(). Shared client = race conditions.Testing without migrations applied: Running tests before
prisma migrate devcauses schema mismatch errors (table doesn’t exist). Always apply migrations to test database before test execution. Pre-test setup should verify schema version.Foreign key constraint violations: Seeding data without respecting foreign key order causes errors. Create parent records (categories) before child records (products). Prisma nested creates handle this automatically.
Timeout errors with long transactions: Default transaction timeout (5s) insufficient for slow operations (bulk inserts, complex queries). Increase timeout in
$transactionoptions:{ timeout: 60000 }. Monitor transaction duration in CI logs.Comparing Date objects incorrectly: Database timestamps have millisecond precision, JavaScript Date objects may have microseconds. Use
expect(date).toBeInstanceOf(Date)instead of exact equality. Alternatively, compare timestamps:expect(date.getTime()).toBeCloseTo(expected.getTime(), -3).Parallel test database conflicts: Multiple test processes writing to same test database cause deadlocks. Use separate test database per worker:
test_db_${process.env.TEST_WORKER_INDEX}. Configure inplaywright.config.tswithworkers: 4and worker-specificDATABASE_URL.Not cleaning up connections: Forgetting
$disconnect()exhausts connection pool (PostgreSQL max_connections limit). Always callcleanup()inafterAllhook. Monitor active connections during test execution.Seeding non-deterministic data: Using random values (
Math.random(),Date.now()) in fixtures makes tests non-reproducible. Use fixed values or seeded random generator (seed: 42) for consistent test behavior. Debugging requires deterministic data.Ignoring database-specific features: Tests that assume generic SQL fail on specific databases (PostgreSQL arrays, MySQL JSON functions). Use Prisma’s database-agnostic query builder or document database-specific raw SQL in test descriptions.