Skip to content

Database Testing

Database testing verifies the persistence layer directly: that the schema matches expectations, constraints actually constrain, queries return correct results, and migrations upgrade and roll back without data loss.

It has three distinct layers that are often conflated:

schema → tables, columns, types, indexes, constraints
data → seed rows, transforms, referential integrity, business rules in SQL
migration → forward upgrade, backward rollback, zero-downtime safety

A test suite should treat each layer on its own: a schema test proves structure, a data test proves correctness of the rows, and a migration test proves the transition between versions.

Use it for

  • Constraints and invariants (uniqueness, foreign keys, check rules, cascade behaviour)
  • Analytical transforms and models (dbt, stored procedures, views)
  • Migrations: forward, rollback, and behaviour on existing data
  • Query correctness for reports where a silent wrong answer is costly

Avoid it when

  • The concern is business logic already covered by a unit or integration test
  • You only need to verify an ORM mapping — a repository integration test is cheaper
  • You want to assert on vendor internals rather than observable data behaviour
Tool Ecosystem Notes
Testcontainers Multi (Java, Go, Node, Python, .NET) Throwaway real database per run
dbt tests SQL unique, not_null, relationships, custom generic tests
pgTAP PostgreSQL Unit tests for schema, functions and RLS inside the DB
Flyway / Liquibase Java / Multi Versioned migrations with validation
Alembic Python Migration framework for SQLAlchemy
tSQLt SQL Server Unit testing framework for T-SQL
DbUnit / Respawn .NET / Multi Seed datasets, reset database state fast
Great Expectations Python Data-quality expectations across pipelines
Terminal window
npm install -D vitest testcontainers pg
import { PostgreSqlContainer } from '@testcontainers/postgresql';
const pg = await new PostgreSqlContainer('postgres:16').start();
it('enforces unique email', async () => {
await db.query("insert into users(email) values ('a@x.com')");
await expect(
db.query("insert into users(email) values ('a@x.com')"),
).rejects.toThrow(/duplicate key/);
});
  • One database per run. Testcontainers or a throwaway schema beats a shared dev database.
  • Test migrations both ways. Every up needs a tested down; a rollback that fails is an outage.
  • Never mutate a shared fixture file blindly. Use builders or per-test seeds so the suite is order-independent.
  • Assert on observable data, not vendor internals. Query results and constraints are the contract, not page layout.
  • Cover the empty and boundary cases. Zero rows, nulls, maximum lengths and duplicate inserts catch real bugs.
  • Populate the suite with realistic volumes for query tests where performance or index usage matters.

Self-assessment quiz

Pick one answer per question, then press Check answers.

  1. What are the three layers database testing usually distinguishes?
  2. Which tool unit-tests schema, functions and row-level security inside PostgreSQL?
  3. What do dbt tests such as unique or relationships express?
  4. Why must a migration test cover the down path?