Database Testing

21Database Testing

What you will master here

  • Why and what to validate at the DB layer
  • Where DB checks fit in an E2E flow
  • Connecting to a DB from tests (Node, Java JDBC)
  • SQL essentials: SELECT, JOIN, COUNT, aggregations
  • Practical patterns: verifying data after UI/API actions, audit trails, soft-deletes
  • Pitfalls: transactions, isolation, eventual consistency

21.1 Why DB testing exists

An API can return 200 OK while the underlying DB silently corrupts data. UI can show "Saved" while the row never landed. DB tests verify the actual state of the world, not what the surface layer claims.

What to validate

21.2 Where DB checks fit in an E2E flow

UI action click "Buy" API request POST /orders DB write INSERT orders DB assertion SELECT WHERE id=... DB checks confirm the actual write happened — bypassing UI/API caching
Figure 7 — DB assertions sit at the end of an E2E flow, verifying the ground truth.

21.3 Connecting from a Node test (PostgreSQL example)

// pg connection pool as a worker-scoped fixture
import { test as base, expect } from '@playwright/test';
import { Pool } from 'pg';

type DBFixtures = { db: Pool };

export const test = base.extend<{}, DBFixtures>({
  db: [async ({}, use) => {
    const pool = new Pool({
      host: process.env.DB_HOST,
      port: 5432,
      user: process.env.DB_USER,
      password: process.env.DB_PASS,
      database: 'app_test',
      max: 5,
    });
    await use(pool);
    await pool.end();
  }, { scope: 'worker' }],
});
export { expect };
// Use it
test('order persists with correct total', async ({ page, db, request }) => {
  // 1. Trigger the write via UI
  await page.goto('/cart');
  await page.getByRole('button', { name: 'Place order' }).click();
  await expect(page.getByText('Order placed')).toBeVisible();

  // 2. Confirm the DB has it
  const { rows } = await db.query(
    `SELECT id, total, status FROM orders
     WHERE user_email = $1 ORDER BY created_at DESC LIMIT 1`,
    ['qa@test.com']
  );
  expect(rows).toHaveLength(1);
  expect(rows[0].status).toBe('pending');
  expect(Number(rows[0].total)).toBe(99.00);
});

21.4 JDBC example (Java)

import java.sql.*;

public class OrderDbCheck {
  private static final String URL = "jdbc:postgresql://localhost:5432/app_test";

  public Order findByEmail(String email) throws SQLException {
    try (Connection conn = DriverManager.getConnection(URL, "qa", "qa-pass");
         PreparedStatement ps = conn.prepareStatement(
           "SELECT id, total, status FROM orders WHERE user_email = ? " +
           "ORDER BY created_at DESC LIMIT 1")) {
      ps.setString(1, email);
      try (ResultSet rs = ps.executeQuery()) {
        if (!rs.next()) throw new AssertionError("No order for " + email);
        return new Order(rs.getString("id"),
                         rs.getBigDecimal("total"),
                         rs.getString("status"));
      }
    }
  }
}

// Test (TestNG)
@Test public void orderPersists() {
  // ... API call to create order ...
  Order o = new OrderDbCheck().findByEmail("qa@test.com");
  assertEquals(o.status, "pending");
  assertEquals(o.total, new BigDecimal("99.00"));
}

21.5 SQL essentials for SDETs

SELECT with WHERE

SELECT id, email, created_at
FROM users
WHERE email = 'alice@test.com';

COUNT

-- Did the test only insert one row?
SELECT COUNT(*) FROM orders WHERE user_email = 'qa@test.com';

-- Group by status
SELECT status, COUNT(*) FROM orders GROUP BY status;

JOIN — verify cross-table relationship

-- Every order has a valid user
SELECT o.id, u.email, o.total
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE o.created_at > NOW() - INTERVAL '1 minute';

-- Find orphans (orders pointing to deleted users)
SELECT o.id
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE u.id IS NULL;

Aggregations

-- Total spend per user
SELECT user_id, SUM(total) AS lifetime_value, COUNT(*) AS order_count
FROM orders
GROUP BY user_id
ORDER BY lifetime_value DESC
LIMIT 10;

Audit trail check

-- Every change to orders.status should write to audit_log
SELECT al.action, al.changed_at
FROM audit_log al
WHERE al.entity = 'order' AND al.entity_id = 'ORD-123'
ORDER BY al.changed_at;

Soft-delete verification

-- After delete, row still exists but deleted_at is set
SELECT id, deleted_at
FROM users
WHERE email = 'qa@test.com';
-- expected: deleted_at IS NOT NULL

21.6 Typical SDET DB assertion patterns

Pattern 1 — verify UI/API write

test('signup creates a user record', async ({ page, db }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill('new@test.com');
  await page.getByLabel('Password').fill('p4ssw0rd!');
  await page.getByRole('button', { name: 'Sign up' }).click();
  await expect(page.getByText('Welcome')).toBeVisible();

  const { rows } = await db.query(
    `SELECT email, email_verified FROM users WHERE email = $1`,
    ['new@test.com']
  );
  expect(rows[0].email).toBe('new@test.com');
  expect(rows[0].email_verified).toBe(false);   // initial state
});

Pattern 2 — state-machine validation

test('order moves pending → paid', async ({ page, db, request }) => {
  // create order via API
  const created = await (await request.post('/api/orders', { data: { items: [{ sku: 'A', qty: 1 }] } })).json();

  // verify initial state
  let r = await db.query(`SELECT status FROM orders WHERE id = $1`, [created.id]);
  expect(r.rows[0].status).toBe('pending');

  // simulate webhook
  await request.post('/api/webhooks/stripe', { data: { type: 'payment_succeeded', orderId: created.id }});

  // verify transitioned state (poll for eventual consistency)
  await expect.poll(async () => {
    const x = await db.query(`SELECT status FROM orders WHERE id = $1`, [created.id]);
    return x.rows[0].status;
  }, { timeout: 5_000 }).toBe('paid');
});
Eventual consistencyIf a write goes through a queue/async worker, the DB may not reflect it immediately. Use expect.poll(...) to retry the query for up to N seconds — never setTimeout.

Pattern 3 — count / no duplicates

test('repeated webhook does not double-insert', async ({ db, request }) => {
  const payload = { id: 'evt_123', type: 'charge.refunded', orderId: 'ORD-1' };
  await request.post('/api/webhooks/stripe', { data: payload });
  await request.post('/api/webhooks/stripe', { data: payload });   // replay

  const { rows } = await db.query(
    `SELECT COUNT(*)::int AS n FROM refunds WHERE order_id = 'ORD-1'`
  );
  expect(rows[0].n).toBe(1);   // idempotent
});

21.7 Pitfalls

Module 7 — Interview Q&A bank

Why test the database directly instead of trusting the API?
Because the API may return 200 OK while the DB silently writes incorrect or partial data — wrong column, wrong type, missing foreign key. DB asserts confirm the ground truth, including writes done via async queues, triggers, or services not exposed via the API.
What kinds of things do you verify at the DB level?
Data integrity (right values, types, FKs), row counts (no duplicates, no missing rows), state transitions (status fields), audit trail rows, soft-delete markers, calculated fields (totals = sum of lines), and cross-table consistency via JOINs.
Where does a DB check fit in an E2E flow?
At the end, after the UI/API action has completed and any visible confirmation has shown. The DB query confirms the write actually landed and is correct, independent of the UI's "Saved" message.
How do you handle async writes (queues, webhooks)?
Use expect.poll(...) in Playwright, or a retry loop in Java — re-run the DB query for up to a few seconds until the expected row/state appears. Never use setTimeout with a guess.
How do you keep DB tests isolated when running in parallel?
Several options: (1) use a unique tenant/user-id per parallelIndex slot; (2) wrap each test in a transaction and roll back at end (works for single-DB workloads); (3) create a per-worker schema or database. Whichever pattern, tests must not touch shared rows.
Write a SQL query that finds orders with no matching user.
SELECT o.id FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE u.id IS NULL; — LEFT JOIN preserves all orders; rows where users.id is NULL are orphans.
Difference between INNER, LEFT, RIGHT and FULL OUTER JOIN?
INNER: rows that match in both tables. LEFT: all from left + matching right (NULL if none). RIGHT: all from right + matching left. FULL OUTER: all rows from both, NULLs where the other side is missing.
How do you count rows grouped by a column?
SELECT status, COUNT(*) FROM orders GROUP BY status; — GROUP BY collapses rows sharing a value into one row per value, and aggregate functions like COUNT/SUM/AVG operate over each group.
How do you assert a row was soft-deleted, not hard-deleted?
Query the row including deleted ones (most ORMs hide soft-deleted rows by default — you may need a raw query or a flag): SELECT deleted_at FROM users WHERE email = 'x', assert deleted_at IS NOT NULL.
How do you verify idempotency of a webhook?
Call the webhook twice with the same payload, then SELECT COUNT(*) from the table that should be written. The count should be 1, not 2. Confirms the handler is using an idempotency key.
How do you avoid leaving test data in the DB?
Either tag created rows (e.g. created_by = 'qa-suite') and bulk-delete them in global teardown, or use per-test cleanup (delete by id at end). Transactions that rollback are cleanest when feasible.
What's the difference between PreparedStatement and Statement in JDBC?
PreparedStatement uses parameter placeholders (?) and is precompiled — safer (no SQL injection) and faster on repeated execution. Use it for any test query that takes input. Plain Statement only for static, no-input queries.
How do you handle the DB password in tests?
Read from environment variables (DB_PASSWORD), populated from a secrets manager / CI secret. Never commit credentials. For local dev, a .env file ignored by git is fine.
Should you assert calculated totals from the DB or recompute in the test?
Both, depending on what you want to catch. Asserting the stored total against the expected value (e.g. 99.00) catches incorrect computation in the application. Recomputing as SUM(line.qty * line.price) and comparing to orders.total catches drift between fields and lines.
When would you NOT add a DB test?
When the equivalent assertion is faithfully covered by the API contract (e.g. GET /orders/123 returns total=99). Adding a DB check on top duplicates effort and couples the test to schema. Reach for DB only when API can't reveal what you need to verify.