SQL Testing Strategies: A Complete Guide for Database Developers
SQL is often the least-tested part of an application stack. Business logic lives in stored procedures nobody touches, queries run against production data, and "it works in dev" becomes the only verification. Then a constraint violation or a missing index bites you in production at 2 AM.
This guide covers practical SQL testing strategies that work across PostgreSQL, MySQL, SQL Server, and SQLite.
What SQL Testing Actually Covers
SQL testing is broader than most developers realize. It includes:
- Query correctness: Does this SELECT return the right rows?
- Constraint validation: Do UNIQUE, NOT NULL, and FK constraints reject invalid data?
- Stored procedure logic: Do procedures produce correct output for all input cases?
- Index effectiveness: Do queries use the expected indexes?
- Transaction behavior: Do concurrent transactions produce correct results?
- Schema integrity: Are tables, columns, and relationships defined correctly?
Each of these requires different testing approaches.
Unit Testing SQL Queries
The foundation of SQL testing is testing queries in isolation against controlled data.
The Test Data Problem
SQL queries are hard to test because they depend on database state. The solution is to control that state explicitly:
-- Setup: insert only the data this test needs
BEGIN;
INSERT INTO customers (id, name, region, created_at) VALUES
(1, 'Acme Corp', 'US', '2024-01-01'),
(2, 'Beta Ltd', 'EU', '2024-01-15'),
(3, 'Gamma Inc', 'US', '2024-02-01');
INSERT INTO orders (id, customer_id, total, status) VALUES
(101, 1, 500.00, 'completed'),
(102, 1, 250.00, 'completed'),
(103, 2, 750.00, 'pending'),
(104, 3, 100.00, 'completed');
-- The query under test
SELECT c.region, SUM(o.total) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'completed'
GROUP BY c.region
ORDER BY revenue DESC;
-- Expected: US=850, EU=0 (pending doesn't count)
ROLLBACK; -- Clean up after testWrapping tests in transactions and rolling back keeps the database clean without truncating tables between every test.
pgTAP for PostgreSQL
pgTAP is a TAP-compliant testing framework that runs directly inside PostgreSQL:
-- Install pgTAP extension
CREATE EXTENSION IF NOT EXISTS pgtap;
BEGIN;
SELECT plan(4);
-- Test: query returns correct revenue by region
SELECT results_eq(
$$ SELECT region, revenue::numeric FROM get_revenue_by_region('completed') ORDER BY region $$,
$$ VALUES ('EU'::text, 0::numeric), ('US'::text, 850::numeric) $$,
'Revenue by region sums completed orders only'
);
-- Test: function rejects invalid status
SELECT throws_ok(
$$ SELECT * FROM get_revenue_by_region('invalid_status') $$,
'P0001',
'Invalid order status',
'Function raises error for unknown status'
);
-- Test: NULL handling
SELECT is(
(SELECT revenue FROM get_revenue_by_region('completed') WHERE region = 'APAC'),
NULL,
'Returns NULL for regions with no orders'
);
-- Test: empty result for no matching orders
SELECT is_empty(
$$ SELECT * FROM get_revenue_by_region('completed') WHERE revenue < 0 $$,
'No negative revenue values'
);
SELECT finish();
ROLLBACK;Run pgTAP tests with pg_prove:
pg_prove -d mydb tests/sql/*.sqlMySQL Unit Testing with MyTAP
MySQL has MyTAP, a similar framework:
-- Test a stored procedure
CALL tap.plan(3);
CALL setup_test_data();
-- Test correct count
CALL tap.eq(
(SELECT COUNT(*) FROM get_active_users()),
5,
'Active user count is correct'
);
-- Test specific user is included
CALL tap.ok(
EXISTS(SELECT 1 FROM get_active_users() WHERE email = 'test@example.com'),
'Known active user appears in results'
);
CALL tap.finish();Testing Stored Procedures and Functions
Stored procedures deserve the same rigor as application code: happy paths, edge cases, and error handling.
Structure Tests Around Behavior
Don't test implementation details — test what the procedure does:
-- Bad: tests internal behavior (fragile)
-- Good: tests observable outcome
CREATE OR REPLACE FUNCTION test_calculate_discount()
RETURNS void AS $$
DECLARE
v_result RECORD;
BEGIN
-- Happy path: standard customer gets 10% on orders > $100
SELECT * INTO v_result FROM calculate_discount(
p_customer_id => 1,
p_order_total => 150.00,
p_customer_tier => 'standard'
);
ASSERT v_result.discount_amount = 15.00,
'Standard customer: 10% on $150 = $15';
ASSERT v_result.discount_pct = 0.10,
'Standard customer discount rate is 10%';
-- Edge case: order just below threshold
SELECT * INTO v_result FROM calculate_discount(
p_customer_id => 1,
p_order_total => 99.99,
p_customer_tier => 'standard'
);
ASSERT v_result.discount_amount = 0.00,
'No discount below $100 threshold';
-- Edge case: VIP customer
SELECT * INTO v_result FROM calculate_discount(
p_customer_id => 1,
p_order_total => 50.00,
p_customer_tier => 'vip'
);
ASSERT v_result.discount_amount = 10.00,
'VIP customers get 20% regardless of amount';
RAISE NOTICE 'All discount tests passed';
END;
$$ LANGUAGE plpgsql;Test Error Handling Explicitly
-- Test that procedure raises the right exception
DO $$
BEGIN
BEGIN
PERFORM calculate_discount(
p_customer_id => -1,
p_order_total => 100.00,
p_customer_tier => 'standard'
);
RAISE EXCEPTION 'Expected exception was not raised';
EXCEPTION WHEN SQLSTATE 'P0001' THEN
RAISE NOTICE 'Correct: invalid customer ID rejected';
END;
END;
$$;Testing Constraints
Database constraints are your last line of defense against bad data. Test that they actually work.
NOT NULL Constraints
-- Test all NOT NULL constraints on a critical table
DO $$
DECLARE
constraint_count INTEGER;
BEGIN
-- Verify constraint exists
SELECT COUNT(*) INTO constraint_count
FROM information_schema.columns
WHERE table_name = 'orders'
AND column_name = 'customer_id'
AND is_nullable = 'NO';
ASSERT constraint_count = 1,
'orders.customer_id must be NOT NULL';
-- Test it actually rejects NULLs
BEGIN
INSERT INTO orders (customer_id, total, status)
VALUES (NULL, 100.00, 'pending');
RAISE EXCEPTION 'Expected constraint violation';
EXCEPTION WHEN not_null_violation THEN
RAISE NOTICE 'NOT NULL constraint works correctly';
END;
END;
$$;Foreign Key Constraints
-- Test FK constraint prevents orphaned records
DO $$
BEGIN
BEGIN
INSERT INTO orders (customer_id, total, status)
VALUES (99999, 100.00, 'pending'); -- nonexistent customer
RAISE EXCEPTION 'Expected FK violation';
EXCEPTION WHEN foreign_key_violation THEN
RAISE NOTICE 'FK constraint prevents orphaned orders';
END;
-- Test cascade delete behavior
INSERT INTO customers (id, name) VALUES (9001, 'Test Customer');
INSERT INTO orders (customer_id, total, status) VALUES (9001, 50.00, 'pending');
DELETE FROM customers WHERE id = 9001;
ASSERT NOT EXISTS (SELECT 1 FROM orders WHERE customer_id = 9001),
'Cascade delete removes child orders';
END;
$$;CHECK Constraints
DO $$
BEGIN
-- Test valid values pass
INSERT INTO orders (customer_id, total, status)
VALUES (1, 100.00, 'pending');
RAISE NOTICE 'Valid status accepted';
-- Test invalid values rejected
BEGIN
INSERT INTO orders (customer_id, total, status)
VALUES (1, 100.00, 'not_a_real_status');
RAISE EXCEPTION 'Expected check constraint violation';
EXCEPTION WHEN check_violation THEN
RAISE NOTICE 'CHECK constraint rejects invalid status';
END;
-- Test negative total rejected
BEGIN
INSERT INTO orders (customer_id, total, status)
VALUES (1, -1.00, 'pending');
RAISE EXCEPTION 'Expected check constraint violation';
EXCEPTION WHEN check_violation THEN
RAISE NOTICE 'CHECK constraint rejects negative totals';
END;
END;
$$;Testing Query Performance
Correctness isn't enough — queries must also perform at scale. Test query plans, not just results.
Assert Index Usage with EXPLAIN
import psycopg2
import json
def test_order_lookup_uses_index(db_conn):
"""Verify customer order lookup uses index, not seq scan."""
with db_conn.cursor() as cur:
cur.execute("""
EXPLAIN (FORMAT JSON, ANALYZE false)
SELECT * FROM orders
WHERE customer_id = 1
ORDER BY created_at DESC
LIMIT 10
""")
plan = cur.fetchone()[0][0]['Plan']
# Find the scan node
def find_scan(node):
if node.get('Node Type') in ('Index Scan', 'Index Only Scan', 'Bitmap Index Scan'):
return node
for child in node.get('Plans', []):
result = find_scan(child)
if result:
return result
return None
scan_node = find_scan(plan)
assert scan_node is not None, \
f"Expected index scan, got: {plan['Node Type']}"
assert 'customer_id' in scan_node.get('Index Name', ''), \
f"Expected customer_id index, got: {scan_node.get('Index Name')}"Benchmark Queries Against Data Volume
import time
import psycopg2
def test_revenue_report_performance(db_conn, large_dataset):
"""Revenue report must complete in under 2 seconds on 1M orders."""
with db_conn.cursor() as cur:
start = time.monotonic()
cur.execute("""
SELECT
DATE_TRUNC('month', created_at) AS month,
region,
SUM(total) AS revenue,
COUNT(*) AS order_count
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= NOW() - INTERVAL '12 months'
GROUP BY 1, 2
ORDER BY 1, 2
""")
results = cur.fetchall()
elapsed = time.monotonic() - start
assert len(results) > 0, "Report returned no data"
assert elapsed < 2.0, \
f"Revenue report too slow: {elapsed:.2f}s (limit: 2.0s)"Integration Testing with Test Databases
For integration tests, spin up a real database rather than mocking:
Using Testcontainers (Python)
import pytest
from testcontainers.postgres import PostgresContainer
import psycopg2
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:16-alpine") as pg:
yield pg
@pytest.fixture(scope="session")
def db_conn(postgres):
conn = psycopg2.connect(postgres.get_connection_url())
# Run migrations
with conn.cursor() as cur:
with open("schema.sql") as f:
cur.execute(f.read())
conn.commit()
yield conn
conn.close()
@pytest.fixture(autouse=True)
def transaction(db_conn):
"""Wrap each test in a transaction, roll back after."""
db_conn.autocommit = False
yield
db_conn.rollback()
def test_create_order(db_conn):
with db_conn.cursor() as cur:
cur.execute(
"INSERT INTO orders (customer_id, total, status) VALUES (%s, %s, %s) RETURNING id",
(1, 150.00, 'pending')
)
order_id = cur.fetchone()[0]
assert order_id > 0
with db_conn.cursor() as cur:
cur.execute("SELECT total, status FROM orders WHERE id = %s", (order_id,))
row = cur.fetchone()
assert row[0] == 150.00
assert row[1] == 'pending'CI/CD Integration
SQL tests should run on every pull request:
# .github/workflows/sql-tests.yml
name: SQL Tests
on: [push, pull_request]
jobs:
sql-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Apply schema
run: |
PGPASSWORD=test psql -h localhost -U test -d testdb -f schema.sql
- name: Run pgTAP tests
run: |
PGPASSWORD=test pg_prove -h localhost -U test -d testdb tests/sql/*.sql
- name: Run Python integration tests
run: |
pip install pytest psycopg2-binary testcontainers
pytest tests/integration/ -v
env:
DATABASE_URL: postgresql://test:test@localhost/testdbWhat to Test vs What to Skip
Test these:
- Complex queries with JOINs, aggregations, or window functions
- Stored procedures and functions with business logic
- All constraint types (NOT NULL, FK, UNIQUE, CHECK)
- Index usage for performance-critical queries
- Trigger behavior
- Concurrent access patterns for high-traffic tables
Skip these:
- Simple single-table SELECTs with no logic
- ORM-generated queries (test at the ORM layer instead)
- Queries against read-only reference data that never changes
Common SQL Testing Mistakes
Testing the ORM, not the SQL: If your application uses an ORM, test the application behavior, not the raw SQL the ORM generates. The ORM's tests cover its SQL generation.
No rollback: Tests that don't clean up after themselves pollute the database and cause test ordering dependencies. Always roll back or truncate.
Testing with production data: Production data contains PII and changes over time. Use generated test fixtures you control completely.
Ignoring NULLs: NULL behavior in SQL is surprising. Test NULL inputs and NULL handling explicitly — they're responsible for a disproportionate number of bugs.
Not testing at scale: A query that runs in 10ms on 1,000 rows may run in 30 seconds on 10 million rows. Load test critical queries against realistic data volumes.
SQL testing catches bugs that application-level tests miss — constraint violations, bad query plans, and concurrent access issues don't show up in mocked tests. The tooling exists (pgTAP, MyTAP, Testcontainers), the patterns are established, and the cost of not testing is a production incident.
Start with the queries your application relies on most heavily, add constraint tests for your critical tables, and run everything in CI. The setup takes a day. The incidents it prevents are worth far more.