Use when writing or reviewing PostgreSQL/PL-pgSQL, designing table schemas, writing functions and procedures, building migrations, defining domains, or architecting a Postgres application database. Also use when writing RAISE EXCEPTION patterns, BEFORE/AFTER triggers for cross-table constraints, base/subtype hierarchies, composite key designs, row-level security policies, or idempotent DDL scripts. If you are touching Postgres for an application database, use this skill. PostgreSQL-specific — examples will not run on other engines.
84
89%
Does it follow best practices?
Impact
100%
1.72xAverage score across 2 eval scenarios
Passed
No known issues
Database tests must run against a real Postgres, not a mock. The skill's user-instruction is explicit: "NEVER mock the database in these tests." This page covers how to do that affordably: transactional isolation, fixture management, pgTAP for unit tests, and how to test RLS policies.
A test that uses a mocked DB tests your mock. The first time you hit production with a real schema mismatch, RLS misconfiguration, or constraint violation, that test will tell you it passed.
Run tests against:
The cost — a few seconds of container spin-up, a few MB of disk — is trivial compared to the cost of a bug that bypassed your mocks.
The fastest way to keep tests independent: wrap each test in a transaction, roll back at the end:
-- Pseudo-code in test framework
beforeEach(async () => {
await db.query('BEGIN');
});
afterEach(async () => {
await db.query('ROLLBACK');
});Every test sees the same baseline schema and seed data; nothing one test inserts leaks to the next. Postgres can run thousands of these per second.
Limitations:
COMMIT — those must run in a separate connection or against a non-rolled-back schemaFor tests that must span transactions, use a different strategy: truncate-and-reseed between tests, or per-test schemas.
Three approaches, in order of preference:
1. Inline INSERTs in the test. Best for unit tests. The test owns its data and you can read what it depends on:
test('cannot transfer more than balance', async () => {
const acct = await db.query(`
INSERT INTO account(account_no, type, owner_id, balance)
VALUES (DEFAULT, 'checking', 1, 100) RETURNING account_no
`);
// ...
});2. Factory functions. When fixture setup is verbose, extract to typed helpers:
async function createAccount(overrides = {}) {
return db.query(`
INSERT INTO account(type, owner_id, balance)
VALUES ($1, $2, $3) RETURNING *
`, [overrides.type ?? 'checking', overrides.owner_id ?? 1, overrides.balance ?? 100]);
}3. Seed SQL files. For shared baseline data (reference tables, app_settings), run a seed script once before all tests. Don't use it for per-test data.
Avoid: large shared fixture files (fixtures.sql with 1000 rows). They couple tests and make failure context murky.
pgTAP is a TAP-emitting test framework that runs inside Postgres — write SQL tests, get TAP output:
CREATE EXTENSION IF NOT EXISTS pgtap;
BEGIN;
SELECT plan(3);
SELECT has_table('app', 'customer');
SELECT has_column('app', 'customer', 'email');
SELECT col_not_null('app', 'customer', 'email');
SELECT * FROM finish();
ROLLBACK;Run with pg_prove (a TAP runner):
pg_prove --dbname mydb tests/*.sqlUse pgTAP for:
For application-layer logic, your normal test framework is usually a better fit — pgTAP doesn't help you assert HTTP responses.
RLS policies depend on the current role. To test them, SET ROLE to assume a non-privileged identity:
-- Setup as superuser/admin
INSERT INTO customer(customer_no, owner_id) VALUES (1, 'alice'), (2, 'bob');
-- Test as Alice
SET LOCAL ROLE app_user;
SET LOCAL app.user_id = 'alice';
SELECT COUNT(*) FROM customer;
-- Should return 1 (only Alice's row)
RESET ROLE;In test framework code:
test('customer sees only own rows under RLS', async () => {
await db.query("SET LOCAL ROLE app_user");
await db.query("SET LOCAL app.user_id = 'alice'");
const res = await db.query('SELECT * FROM customer');
expect(res.rows).toHaveLength(1);
expect(res.rows[0].owner_id).toBe('alice');
});Crucial: if your test connection is a superuser or BYPASSRLS, policies don't apply. The SET LOCAL ROLE is what makes the test meaningful.
Triggers fire on DML, so test the DML and assert the outcome:
test('audit trigger captures customer update', async () => {
await db.query("INSERT INTO customer(customer_no, full_name) VALUES (1, 'Old')");
await db.query("UPDATE customer SET full_name = 'New' WHERE customer_no = 1");
const audit = await db.query(`
SELECT action, change_diff FROM customer_audit
WHERE customer_no = 1 ORDER BY changed_at DESC LIMIT 1
`);
expect(audit.rows[0].action).toBe('UPDATE');
expect(audit.rows[0].change_diff).toEqual({ full_name: 'New' });
});For triggers that raise on invalid state, assert the EXCEPTION:
test('savings account rejects non-savings parent', async () => {
await db.query("INSERT INTO account(account_no, type) VALUES (1, 'checking')");
await expect(
db.query("INSERT INTO savings_account(account_no, ...) VALUES (1, ...)")
).rejects.toMatchObject({ code: 'P0010' });
});Match on the SQLSTATE, not the message — messages can change, codes are the contract.
Procedures that own their transactions (COMMIT inside) cannot be called from within a test transaction. Two options:
A. Test against a clean DB and truncate between tests. Slower but accurate.
B. Refactor the procedure to expose its core as a _utx-style function that doesn't commit. Test that. Wrap with a thin procedure that adds the commit boundary in production code.
For pure functions (no DML, no COMMIT), call them directly inside the test transaction:
test('fn_next_order_no returns 1 for empty parent', async () => {
const res = await db.query('SELECT fn_next_order_no(1) AS next');
expect(res.rows[0].next).toBe(1);
});For queries on the critical path, add tests that fail if the plan changes shape:
test('customer search uses index, not seq scan', async () => {
const plan = await db.query(`
EXPLAIN (FORMAT JSON)
SELECT * FROM customer WHERE email = $1
`, ['alice@example.com']);
const planJson = plan.rows[0]['QUERY PLAN'][0];
expect(planJson.Plan['Node Type']).toBe('Index Scan');
});Don't assert on absolute timings — too flaky. Assert on plan shape.
Minimum CI requirements:
For monorepo speed, cache the post-migration Postgres data directory and skip migrations when the migration files haven't changed. Most CI systems support this.
evals
scenario-1
scenario-2
references