Database Testing
What it is
Section titled “What it is”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, constraintsdata → seed rows, transforms, referential integrity, business rules in SQLmigration → forward upgrade, backward rollback, zero-downtime safetyA 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.
When to use it / when to avoid it
Section titled “When to use it / when to avoid it”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
Frameworks & tools
Section titled “Frameworks & tools”| 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 |
Quick setup
Section titled “Quick setup”npm install -D vitest testcontainers pgimport { PostgreSqlContainer } from '@testcontainers/postgresql';const pg = await new PostgreSqlContainer('postgres:16').start();pip install pytest testcontainers psycopg[binary] dbt-corego get github.com/testcontainers/testcontainers-gogo get github.com/jackc/pgx/v5mvn test<dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <version>1.20.1</version> <scope>test</scope></dependency>dotnet add package Testcontainers.PostgreSqldotnet add package RespawnExample
Section titled “Example”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/);});def test_foreign_key_is_enforced(db): with db.cursor() as cur: try: cur.execute("insert into orders(user_id) values (999999)") except Exception as exc: assert "foreign key" in str(exc).lower() else: raise AssertionError("FK not enforced")models: - name: orders columns: - name: order_id tests: [unique, not_null] - name: user_id tests: - relationships: to: ref('users') field: idfunc TestMigrationRoundTrip(t *testing.T) { up(t) // apply migration 003 assertColumn(t, "orders", "currency") down(t) // roll back to 002 assertNoColumn(t, "orders", "currency")}@Testvoid detectsMissingDependency() { assertThatThrownBy(() -> jdbcTemplate.update("insert into orders(user_id) values (999999)") ).hasMessageContaining("foreign key");}[Fact]public async Task EnforcesUniqueEmail(){ await _conn.ExecuteAsync("insert into users(email) values ('a@x.com')"); await Assert.ThrowsAnyAsync<Exception>(() => _conn.ExecuteAsync("insert into users(email) values ('a@x.com')")); await _respawn.ResetAsync(_conn);}Anti-patterns & best practices
Section titled “Anti-patterns & best practices”- One database per run. Testcontainers or a throwaway schema beats a shared dev database.
- Test migrations both ways. Every
upneeds a testeddown; 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
Section titled “Self-assessment quiz”Self-assessment quiz
Pick one answer per question, then press Check answers.