Why Test Databases Directly?
API tests verify what the API returns, but they do not verify what is actually stored in the database. An API might return a successful response while the data written to the database is incorrect, incomplete, or violates constraints.
Database testing catches issues that API tests miss:
- Data written to the wrong table or column
- Missing or incorrect constraint enforcement
- Trigger side effects not reflected in API responses
- Index performance issues under realistic data volumes
- Transaction isolation violations causing data corruption
Essential SQL for Testers
Verifying Data After API Calls
After calling an API endpoint, query the database to confirm the operation:
-- After POST /api/users with name="Alice", email="alice@test.com"
SELECT id, name, email, created_at
FROM users
WHERE email = 'alice@test.com';
-- Verify: exactly 1 row, name matches, created_at is recent
Testing Constraints
NOT NULL constraint:
-- This should fail if name is NOT NULL
INSERT INTO users (name, email) VALUES (NULL, 'test@test.com');
-- Expected: ERROR: null value in column "name" violates not-null constraint
UNIQUE constraint:
-- Insert first user
INSERT INTO users (name, email) VALUES ('Alice', 'alice@test.com');
-- Insert duplicate email
INSERT INTO users (name, email) VALUES ('Bob', 'alice@test.com');
-- Expected: ERROR: duplicate key value violates unique constraint
Foreign key constraint:
-- Insert order for non-existent user
INSERT INTO orders (user_id, total) VALUES (99999, 50.00);
-- Expected: ERROR: insert or update violates foreign key constraint
CHECK constraint:
-- Insert negative price (if CHECK(price >= 0) exists)
INSERT INTO products (name, price) VALUES ('Widget', -10.00);
-- Expected: ERROR: new row violates check constraint
Testing Data Types and Precision
-- Monetary values should use DECIMAL, not FLOAT
-- FLOAT can cause rounding errors
SELECT CAST(0.1 + 0.2 AS FLOAT); -- Might return 0.30000000000000004
SELECT CAST(0.1 + 0.2 AS DECIMAL(10,2)); -- Returns 0.30
-- Verify monetary columns use appropriate precision
SELECT column_name, data_type, numeric_precision, numeric_scale
FROM information_schema.columns
WHERE table_name = 'orders' AND column_name = 'total';
Testing Stored Procedures
Stored procedures encapsulate business logic in the database. They must be tested like any other code.
-- Example: Stored procedure that transfers money between accounts
CALL transfer_funds(
@from_account := 'ACC001',
@to_account := 'ACC002',
@amount := 100.00
);
-- Verify: source account decreased by 100
SELECT balance FROM accounts WHERE account_id = 'ACC001';
-- Expected: original_balance - 100
-- Verify: destination account increased by 100
SELECT balance FROM accounts WHERE account_id = 'ACC002';
-- Expected: original_balance + 100
-- Verify: transaction log created
SELECT * FROM transaction_log
WHERE from_account = 'ACC001' AND to_account = 'ACC002'
ORDER BY created_at DESC LIMIT 1;
Edge cases to test:
- Transfer more than available balance (should fail)
- Transfer to the same account (should fail or be a no-op)
- Transfer zero or negative amount (should fail)
- Concurrent transfers that exceed the balance
Testing Transactions and Isolation
ACID Properties
Atomicity test: Start a transaction with multiple statements. Force a failure midway. Verify that none of the statements took effect.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Simulate failure (invalid operation)
UPDATE accounts SET balance = balance + 100 WHERE id = 99999;
ROLLBACK;
-- Verify account 1 balance is unchanged
SELECT balance FROM accounts WHERE id = 1;
Isolation test: Run two concurrent transactions that modify the same row. Verify the isolation level prevents dirty reads, non-repeatable reads, or phantom reads as expected.
Testing Indexes
Indexes speed up queries but have trade-offs. Test that:
- Queries on indexed columns use the index (check with EXPLAIN).
- Indexes do not break with NULL values.
- Composite indexes work for the expected query patterns.
-- Verify index is used
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'alice@test.com';
-- Look for "Index Scan" or "Index Only Scan" (not "Seq Scan")
-- Verify query performance with realistic data volume
-- Insert 100K rows, then benchmark the query
Test Data Management
Setup Strategies
1. Transaction rollback (fastest):
BEGIN TRANSACTION
-- Insert test data
-- Run tests
-- Verify results
ROLLBACK
-- Database is clean
2. Truncate after test:
-- After each test
TRUNCATE TABLE orders CASCADE;
TRUNCATE TABLE users CASCADE;
3. Dedicated test database: Create a fresh database for each test run, populated with seed data.
Exercise: SQL Database Testing Lab
Setup
Use PostgreSQL (Docker is easiest):
docker run -d --name postgres-test \
-e POSTGRES_PASSWORD=testpass \
-e POSTGRES_DB=testdb \
-p 5432:5432 \
postgres:16
Create the test schema:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('user', 'admin', 'moderator')),
balance DECIMAL(10, 2) DEFAULT 0.00 CHECK (balance >= 0),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE RESTRICT,
total DECIMAL(10, 2) NOT NULL CHECK (total > 0),
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
product_name VARCHAR(200) NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price DECIMAL(10, 2) NOT NULL CHECK (unit_price >= 0)
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
Task 1: Constraint Testing
Write SQL statements that test each constraint. For each, document the expected error:
- Insert a user with NULL name.
- Insert two users with the same email.
- Insert a user with role ‘superadmin’ (not in CHECK list).
- Insert a user with negative balance.
- Insert an order for a non-existent user_id.
- Insert an order with total = 0.
- Insert an order_item with quantity = 0.
- Delete a user who has orders (RESTRICT should prevent it).
- Delete an order (CASCADE should delete its order_items).
Task 2: Data Integrity Verification
Insert test data and verify integrity:
-- Insert users
INSERT INTO users (name, email, balance) VALUES
('Alice', 'alice@test.com', 500.00),
('Bob', 'bob@test.com', 200.00);
-- Insert orders and items
-- Then verify:
Write queries to verify:
- Every order_item.order_id references an existing order.
- Every order.user_id references an existing user.
- The sum of order_items (quantity * unit_price) matches the order total.
- No user has a negative balance.
Task 3: Transaction Testing
Test the atomicity of a “place order” operation:
- Begin a transaction.
- Insert an order for Alice.
- Insert 3 order items for that order.
- Deduct the total from Alice’s balance.
- Commit and verify all changes.
- Repeat, but this time force a failure at step 4 (insufficient balance).
- Verify the entire transaction rolled back — no order, no items, balance unchanged.
Task 4: Performance Testing
- Write a script to insert 100,000 users with random data.
- Query users by email — record execution time with EXPLAIN ANALYZE.
- Drop the email index and repeat the query — compare times.
- Recreate the index.
Deliverables
- SQL scripts for all constraint tests with expected vs actual results.
- Data integrity verification queries with results.
- Transaction test scripts showing both success and rollback scenarios.
- Performance comparison (with/without index) with timing data.