db-integration-test
$npx skills add blunotech-dev/agents --skill db-integration-testWrite tests against a real test database to verify CRUD operations, queries, constraints, transactions, and migrations. Use when validating DB logic, ORM/repository layers, or data integrity without mocks.
| name | description | category |
|---|---|---|
| db-integration-test | Write tests against a real test database to verify CRUD operations, queries, constraints, transactions, and migrations. Use when validating DB logic, ORM/repository layers, or data integrity without mocks. | Testing |
DB Integration Test Skill
Discovery
Before writing tests, extract:
- DB engine — Postgres, MySQL, SQLite, MongoDB, etc. (affects transaction syntax, constraint behavior, JSON support)
- Access layer — raw SQL, query builder (Knex, Drizzle), ORM (Prisma, TypeORM, Sequelize)
- Schema — tables, columns, constraints (NOT NULL, UNIQUE, FK, CHECK), indexes
- Test DB strategy — is there an existing test DB, Docker Compose setup, or in-process option (SQLite,
pg-mem)? - Seeding approach — fixtures, factories, or raw inserts? Already established or needs creating?
Test DB Setup — Non-Obvious Decisions
1. Isolate tests with transactions, not table truncation
Transaction rollback is faster and cleaner than truncating between tests:
beforeEach(async () => {
await db.query('BEGIN');
});
afterEach(async () => {
await db.query('ROLLBACK');
});
Truncation is only necessary when the code under test explicitly commits (e.g. testing COMMIT behavior itself, or cross-connection visibility).
2. Never share connection state between tests
Each test should get a fresh connection or a connection scoped to a transaction. Pooled connections that carry session state (temp tables, SET variables, advisory locks) will cause flaky cross-test contamination.
// Correct — scoped connection per test
const client = await pool.connect();
await client.query('BEGIN');
// ... test
await client.query('ROLLBACK');
client.release();
3. Schema setup: migrations over raw DDL
Run the real migration files in test setup, not hand-rolled CREATE TABLE. This ensures tests break when migrations change — which is the point:
beforeAll(async () => {
await runMigrations(testDb); // same migration runner used in production
});
What to Test
CRUD — assert the round-trip, not just absence of error
// WEAK
await repo.create({ name: 'Alice' });
// no assertion
// STRONG — assert the persisted state, not the in-memory return value
await repo.create({ name: 'Alice' });
const row = await db.query('SELECT * FROM users WHERE name = $1', ['Alice']);
expect(row.rows[0]).toMatchObject({ name: 'Alice', active: true });
Always read back from the DB directly for write tests — ORM return values can diverge from actual persisted state.
Constraints — trigger them deliberately
Test each constraint type with a case that violates it:
// UNIQUE constraint
await repo.create({ email: 'a@b.com' });
await expect(repo.create({ email: 'a@b.com' })).rejects.toThrow(/unique/i);
// NOT NULL
await expect(repo.create({ email: null })).rejects.toThrow(/null/i);
// FK constraint
await expect(
repo.createPost({ userId: 99999 }) // non-existent FK
).rejects.toThrow(/foreign key/i);
// CHECK constraint
await expect(
repo.create({ age: -1 }) // violates CHECK age > 0
).rejects.toThrow(/check/i);
Match on error message pattern, not exact string — DB engines phrase these differently across versions.
Transactions — test the rollback, not just the commit
// Verify atomicity: partial failure rolls back all changes
await expect(async () => {
await db.transaction(async (trx) => {
await trx.insert(users).values({ id: 1, name: 'Alice' });
await trx.insert(users).values({ id: 1, name: 'Bob' }); // duplicate PK — throws
});
}).rejects.toThrow();
// Assert Alice was NOT persisted (rollback succeeded)
const result = await db.select().from(users).where(eq(users.id, 1));
expect(result).toHaveLength(0);
This is the only test that proves your transaction boundary is real. Without it, you're trusting the ORM.
Query correctness — test data shape and filtering, not just row count
// WEAK — only verifies something came back
const results = await repo.findActive();
expect(results.length).toBeGreaterThan(0);
// STRONG — seed known data, assert exact filtering behavior
await seedUsers([
{ name: 'Alice', active: true },
{ name: 'Bob', active: false },
]);
const results = await repo.findActive();
expect(results).toHaveLength(1);
expect(results[0].name).toBe('Alice');
Always seed the exact data you need — never rely on pre-existing DB state.
Soft deletes — assert the record is hidden, not gone
await repo.delete(userId);
// The record must be invisible to normal queries
const visible = await repo.findById(userId);
expect(visible).toBeNull();
// But still present in the DB with deleted_at set
const raw = await db.query(
'SELECT deleted_at FROM users WHERE id = $1', [userId]
);
expect(raw.rows[0].deleted_at).not.toBeNull();
Pagination and ordering — test the edges
// Seed 5 records, request page 2 of size 2
await seedUsers(5);
const page2 = await repo.list({ page: 2, size: 2 });
expect(page2).toHaveLength(2);
// Last page — partial results
const page3 = await repo.list({ page: 3, size: 2 });
expect(page3).toHaveLength(1);
// Empty page beyond range
const page4 = await repo.list({ page: 4, size: 2 });
expect(page4).toHaveLength(0);
// Ordering is stable — same seed, same order on repeated calls
const a = await repo.list({ sort: 'created_at' });
const b = await repo.list({ sort: 'created_at' });
expect(a.map(r => r.id)).toEqual(b.map(r => r.id));
Concurrent writes — test optimistic locking or last-write-wins
If the schema uses a version or updated_at for optimistic locking:
const record = await repo.findById(id);
// Simulate concurrent update elsewhere
await db.query('UPDATE items SET version = version + 1 WHERE id = $1', [id]);
// This write should fail — stale version
await expect(
repo.update({ ...record, name: 'new' })
).rejects.toThrow(/conflict|stale|version/i);
Seeding Patterns
Minimal seed — only insert what the test needs
// Bad — seeding 20 users when only 1 is needed makes assertions fragile
await seedFixture('users-large.json');
// Good — seed the exact shape you assert against
const user = await factory.create('user', { email: 'test@example.com', active: true });
Factory pattern over static fixtures
Static fixture files drift from schema changes silently. A factory function that calls repo.create(defaults) with overrides will break loudly when the schema changes:
const createUser = (overrides = {}) =>
db.insert(users).values({ name: 'Default', active: true, ...overrides }).returning();
What Not to Do
- Don't mock the DB in integration tests — that's a unit test; the entire point here is hitting real SQL
- Don't assert ORM return values as proof of persistence — read back from the DB directly for write assertions
- Don't use
TRUNCATEbetween tests if transaction rollback works — truncation resets sequences and is 10-100x slower - Don't share seed data across unrelated tests — each test owns its data; shared state causes ordering dependencies
- Don't catch DB errors in test setup — let setup failures surface loudly; silent setup failures produce false passes
Output Format
Group tests by operation type: setup/teardown at the top, then CRUD, constraints, transactions, and query correctness. Each it asserts one DB contract. Include a comment on non-obvious cases explaining the invariant being verified, not the mechanics.