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
- Data integrity — the row was written with the correct values, foreign keys point to valid records, NOT NULL constraints are honoured
- Counts — exactly one row inserted, zero rows after delete
- State transitions — order goes from pending → paid → shipped
- Audit / history — every change writes to
audit_log; soft-deletes setdeleted_atinstead of removing - Cross-table consistency — JOIN integrity, no orphans
- Calculated fields — totals match line items × prices
21.2 Where DB checks fit in an E2E flow
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
- Test isolation — tests writing to shared tables collide. Solutions: a unique tenant per parallelIndex slot, per-test schema, or transactions you roll back.
- Connection limits — DBs have max connections. Use a small pool per worker, share via worker-scoped fixture.
- Data leftovers — clean up in teardown (DELETE WHERE created_by = 'qa') so the next run starts clean.
- Asserting against prod data — never. Always against a dedicated test DB with seeded data.
- Read replicas — if reads come from a replica, allow for replication lag; poll.
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.