Skip to main content
/tayyab/portfolio — zsh
tayyab
TA
// dispatch.read --classified=false --access-level: public

Playwright + Database Verification: Confirm Your Tests Actually Did Something

April 4, 2026 EST. READ: 11 MIN #Quality Assurance

One of the most consistent gaps I see in QA teams is testing the UI without testing the data. The success toast says "order created." Your test asserts the toast. Both pass. The order doesn't actually exist in the database — the API call timed out, the optimistic UI updated anyway, the user got a confirmation email about an order that won't ship. Real bug, real customer complaint, untestable from the UI alone.

Adding database verification to Playwright tests is the single highest-leverage change I make to QA suites I inherit. The setup takes a day. The bug-catching capability is permanent. Here's the exact pattern, including the security and performance tradeoffs I've worked through.

Table of Contents

Why UI-Only Tests Miss Real Bugs

Three real bugs from client projects, all undetectable without database verification:

  1. The fintech transaction. UI showed "transaction confirmed." Database had the transaction with status pending. The async confirmation job was failing silently. Bug: customers thought their money moved when it hadn't.
  2. The e-commerce coupon. UI applied the discount and updated the total. Database stored the original total. Bug: the order was charged the full amount despite the UI showing the discount.
  3. The healthcare appointment. UI showed "booked for Tuesday 3pm." Database had the appointment for the wrong timezone (UTC, not US/Pacific). Bug: patient showed up at the wrong time.

All three would have been caught by a one-line SQL check after the UI assertion. None would have been caught by any UI-only test, no matter how thorough.

Getting Database Access Without Becoming a Security Risk

The first objection from your security team will be: "You want what?"

The right framing:

  • Read-only access. Your test fixtures need SELECT, not INSERT/UPDATE/DELETE.
  • Non-production environments only. Staging and CI databases. Never prod.
  • Connection pooled and limited. Max 5 concurrent connections from the test runner. Won't take down the database.
  • Credentials in CI secrets. Never committed.

Most security teams agree to this when framed clearly. The harder fight is convincing your team it's worth doing.

The Database Fixture Pattern

One small file does most of the work. Postgres example:

// fixtures/db.ts
import pg from 'pg';
import { test as base } from '@playwright/test';

type DbFixture = {
  db: {
    query: <T = any>(sql: string, params?: any[]) => Promise<T[]>;
    one: <T = any>(sql: string, params?: any[]) => Promise<T>;
  };
};

let pool: pg.Pool | null = null;
function getPool() {
  if (!pool) {
    pool = new pg.Pool({
      connectionString: process.env.E2E_DB_URL,
      max: 5,
      idleTimeoutMillis: 30000,
    });
  }
  return pool;
}

export const test = base.extend<DbFixture>({
  db: async ({}, use) => {
    const pool = getPool();
    await use({
      query: async <T,>(sql: string, params: any[] = []): Promise<T[]> => {
        const r = await pool.query(sql, params);
        return r.rows as T[];
      },
      one: async <T,>(sql: string, params: any[] = []): Promise<T> => {
        const r = await pool.query(sql, params);
        if (r.rows.length === 0) {
          throw new Error(`Expected 1 row, got 0: ${sql}`);
        }
        return r.rows[0] as T;
      },
    });
  },
});

Use it in tests:

import { test } from './fixtures/db';
import { expect } from '@playwright/test';

test('order persists with correct total', async ({ page, db }) => {
  await page.goto('/checkout');
  await fillCheckoutForm(page, { coupon: 'FRIEND10' });
  await page.getByRole('button', { name: 'Pay' }).click();
  await expect(page).toHaveURL(/\/thank-you/);

  const orderId = await page.getByTestId('order-id').textContent();
  const order = await db.one<Order>(
    'SELECT * FROM orders WHERE id = $1',
    [orderId]
  );

  expect(order.status).toBe('paid');
  expect(order.total_cents).toBe(4499); // 10% off $49.99 = $44.99
  expect(order.coupon_code).toBe('FRIEND10');
});

That's it. UI assertion + database assertion. Real bug coverage.

Common Verification Patterns

Pattern 1: Verify a record was created

const user = await db.one('SELECT * FROM users WHERE email = $1', [email]);
expect(user.email_verified).toBe(false); // newly-created users start unverified

Pattern 2: Verify a record was updated

// Update via UI
await page.getByRole('button', { name: 'Save profile' }).click();
await expect(page.getByRole('alert')).toContainText('Saved');

const updated = await db.one('SELECT * FROM profiles WHERE user_id = $1', [userId]);
expect(updated.bio).toBe('Updated bio text');
expect(updated.updated_at).toBeInstanceOf(Date);

Pattern 3: Verify side effects (audit log, queue, etc.)

// Action triggers an audit log entry
await page.getByRole('button', { name: 'Delete account' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();

const auditEntries = await db.query(
  'SELECT * FROM audit_log WHERE action = $1 AND user_id = $2 ORDER BY created_at DESC LIMIT 1',
  ['account_deleted', userId]
);
expect(auditEntries).toHaveLength(1);
expect(auditEntries[0].metadata).toMatchObject({ initiator: 'self' });

Pattern 4: Verify async background work completed

Background jobs take time. Poll with a timeout:

async function waitForJobCompletion(
  db: any,
  jobId: string,
  timeoutMs = 30_000
) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const job = await db.one('SELECT status FROM jobs WHERE id = $1', [jobId]);
    if (job.status === 'completed') return;
    if (job.status === 'failed') throw new Error('Job failed');
    await new Promise((res) => setTimeout(res, 500));
  }
  throw new Error(`Job ${jobId} did not complete in ${timeoutMs}ms`);
}

test('export job completes and email sent', async ({ page, db }) => {
  await triggerExport(page);
  const jobId = await page.getByTestId('export-job-id').textContent();
  await waitForJobCompletion(db, jobId!);

  const emails = await db.query(
    'SELECT * FROM email_log WHERE job_id = $1',
    [jobId]
  );
  expect(emails).toHaveLength(1);
  expect(emails[0].subject).toContain('Your export is ready');
});

Cleanup: Who's Responsible?

Two schools:

Test cleans up after itself. Each test deletes the records it created. Pro: clean state. Con: tests need write access (you didn't actually want this), and cleanup code is half your test.

Backend provides a reset endpoint. Test calls POST /test/reset in beforeEach or before the suite. Pro: test stays read-only. Con: requires backend cooperation.

I always go with the second. The reset endpoint is a small piece of test infrastructure. Worth the one-time backend work.

// fixtures/db-reset.ts
import { test as base } from '@playwright/test';

export const test = base.extend({
  resetDb: [
    async ({}, use) => {
      await fetch(`${process.env.API_URL}/test/reset`, {
        method: 'POST',
        headers: { 'X-Test-Key': process.env.E2E_TOKEN! },
      });
      await use(undefined);
    },
    { auto: true }, // runs automatically before every test
  ],
});

NoSQL Variations (MongoDB, DynamoDB, Redis)

Same fixture pattern, different driver:

// MongoDB example
import { MongoClient } from 'mongodb';

const client = new MongoClient(process.env.E2E_MONGO_URL!);
await client.connect();
const db = client.db('app');

export const test = base.extend({
  mongo: async ({}, use) => {
    await use(db);
  },
});

// In test:
test('order persists in mongo', async ({ page, mongo }) => {
  // ...
  const order = await mongo.collection('orders').findOne({ _id: orderId });
  expect(order?.status).toBe('paid');
});

Redis is even simpler — usually you're checking key existence or cached values, not complex queries. The pattern adapts.

Performance Considerations

Three things to watch:

  • Connection pool size. Match to your test concurrency. 5 max for 4 workers is fine; 5 max for 12 workers will queue.
  • Index your test queries. A fixture that scans a million rows takes seconds. Make sure your WHERE clauses hit indexes.
  • Don't run unnecessary queries. If a test doesn't need DB verification, don't include the fixture. Per-test fixtures keep performance scoped.

FAQs

What if I can't get database access?

Use API access instead. GET /api/orders/:id from your test fixture is roughly equivalent to SELECT * FROM orders WHERE id = .... Less direct but achieves the verification.

Should I test against the staging database?

For CI, yes — staging is shared though. Per-test isolation gets harder. Most teams run a dedicated test database that's reset between runs.

What about transactions and rollback?

Playwright test contexts can't share transactions with the app. Use the reset-endpoint pattern instead.

How do I handle eventual consistency?

Poll with a timeout. The waitForJobCompletion pattern works for any eventually-consistent state.

What about ORM vs raw SQL?

Either works. Raw SQL is fastest. ORMs (TypeORM, Prisma, Drizzle) add type safety. Pick what your team can read.

Can I share a database fixture across all tests?

The pool is shared via the module-level singleton. The fixture itself runs once per test (via { auto: true }) but reuses the pool.

What about parallel tests writing to the same row?

Don't write from tests; only the app under test should write. If you need per-worker data, use the testInfo.parallelIndex pattern from my multi-user auth post.

Does this slow down my tests?

Each query adds 5–20ms. Significantly slower than UI-only assertions, but still fast. The bug-coverage gain is worth it.

How do I handle migrations between test runs?

Run migrations in CI before tests. Your test database should always match the schema your tests expect.

What if the database connection fails mid-test?

Catch and retry once, then fail loudly. Don't swallow the error — a failing DB connection is a real problem worth investigating.

Wrap-Up

Database verification turns Playwright from a UI testing tool into an end-to-end testing tool. The setup is a day; the bug-coverage improvement is permanent. Three categories of bugs (async writes, optimistic UI lying, eventual-consistency surprises) are simply not detectable without it.

If your team is debating whether to add DB access to the QA stack, that's a conversation I help facilitate during framework engagements. Or book a free call.

Related reading:

Tayyab Akmal
// author

Tayyab Akmal

AI & QA Automation Engineer

6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.

// feedback_channel

FOUND THIS USEFUL?

Share your thoughts or let's discuss automation testing strategies.

→ Start Conversation
Available for hire