Database Testing: Complete Guide to Data Integrity and Validation

Parul Dhingra - Senior Quality Analyst
Parul Dhingra13+ Years ExperienceHire Me

Senior Quality Analyst

Updated: 1/29/2026

Database Testing Complete GuideDatabase Testing Complete Guide

Every application relies on its database to store, retrieve, and process data correctly. A broken query, a missing constraint, or a flawed migration can corrupt data silently, sometimes going undetected for weeks until a report produces wrong numbers or a customer sees incorrect balances.

Database testing verifies that your data layer works correctly: the schema enforces the right rules, operations produce expected results, and the system performs reliably under real-world conditions.

This guide covers the full scope of database testing, from structural validation and CRUD operations to stored procedures, triggers, transactions, migrations, security, ETL pipelines, and NoSQL-specific concerns. You'll learn practical techniques, see real SQL examples, and understand which tools fit different testing needs.

AspectDetails
WhatSystematic validation of database schema, operations, integrity, performance, and security
Key AreasSchema testing, CRUD operations, stored procedures, triggers, transactions, migrations, ETL
Why It MattersData corruption is silent and expensive; database bugs often cascade into application-wide failures
Who Performs ItQA engineers, DBAs, backend developers, data engineers
Common ToolsDbUnit, tSQLt, pgTAP, utPLSQL, Testcontainers, JMeter (JDBC), sqlmap

What Is Database Testing?

Database testing is the process of verifying that a database correctly stores, processes, retrieves, and protects data according to application requirements and design specifications.

Unlike UI testing that validates what users see, or API testing that validates request-response behavior, database testing operates at the data layer itself. It confirms that your data is accurate, consistent, and complete regardless of how it was entered or which application interface triggered the operation.

Database testing covers three fundamental aspects:

Data validity — Does the database accept only correct data and reject invalid data? Do constraints, data types, and validation rules work as specified?

Data integrity — Are relationships between tables maintained correctly? Do foreign keys, cascading operations, and referential integrity rules prevent orphaned or inconsistent data?

Data operations — Do queries, stored procedures, triggers, and transactions produce correct results? Do they handle edge cases, NULL values, and concurrent access properly?

The reason database testing gets its own discipline is that data layer bugs behave differently from other bugs. A UI bug is immediately visible. An API bug returns wrong responses that are detectable in integration tests. But a database bug might insert slightly wrong data that goes unnoticed until someone runs a quarterly financial report and the numbers don't add up.

When Database Testing Is Critical

Database testing is especially important when:

  • Your application handles financial transactions, medical records, or other data where accuracy is legally required
  • Multiple applications or services read from and write to the same database
  • You perform database migrations during deployments
  • Your system processes ETL (Extract, Transform, Load) pipelines
  • You use stored procedures or triggers that contain business logic
  • Your database schema changes frequently as the product evolves

Database Testing vs. Application Testing

A common misconception is that if your application tests pass, the database is fine. This isn't true for several reasons:

  • Application tests typically operate through an ORM layer that may mask database-level issues
  • Tests usually run against small datasets that don't reveal performance problems
  • Application logic may bypass database constraints if the ORM handles validation differently
  • Tests rarely verify that database-level triggers, computed columns, and check constraints work independently

Database testing validates the data layer on its own terms, ensuring it enforces correctness regardless of which application or service accesses it.

Types of Database Testing

Database testing divides into several distinct categories, each targeting different aspects of the database system.

Structural Testing

Validates the database schema: tables, columns, data types, indexes, views, constraints, and relationships. Ensures the physical design matches specifications.

Functional Testing

Verifies that CRUD operations, stored procedures, functions, and triggers produce correct results for valid inputs and handle invalid inputs properly.

Non-Functional Testing

Tests performance characteristics: query execution times, throughput under load, connection pooling behavior, index effectiveness, and resource utilization.

Security Testing

Validates access controls, authentication, SQL injection resistance, encryption, audit logging, and privilege management.

Data Integrity Testing

Confirms referential integrity, constraint enforcement, and data consistency across related tables and operations.

Migration Testing

Verifies that schema changes and data transformations execute correctly, preserve existing data, and can be rolled back safely.

Each category requires different techniques and tools. The following sections cover each area in detail with practical examples.

Structural (Schema) Testing

Structural testing validates that the database schema is implemented correctly. This is the foundation: if your tables, columns, and constraints are wrong, everything built on top will be unreliable.

Table and Column Validation

Verify that all specified tables exist with the correct columns, data types, and default values:

-- Verify table exists with expected columns
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public'
  AND table_name = 'orders'
ORDER BY ordinal_position;

Check against your schema specification:

ColumnExpected TypeNullableDefault
idintegerNOauto-increment
customer_idintegerNOnone
total_amountdecimal(10,2)NO0.00
statusvarchar(20)NO'pending'
created_attimestampNOCURRENT_TIMESTAMP

Constraint Verification

Verify that primary keys, foreign keys, unique constraints, and check constraints are defined correctly:

-- List all constraints on a table
SELECT constraint_name, constraint_type
FROM information_schema.table_constraints
WHERE table_name = 'orders';
 
-- Verify foreign key relationships
SELECT
    tc.constraint_name,
    kcu.column_name,
    ccu.table_name AS referenced_table,
    ccu.column_name AS referenced_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
    ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
    ON tc.constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
    AND tc.table_name = 'orders';

Index Verification

Confirm that indexes exist for frequently queried columns and foreign key columns:

-- List all indexes on a table (PostgreSQL)
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'orders';

Missing indexes don't cause functional failures but lead to severe performance degradation as data volumes grow. Verify that indexes exist on:

  • Primary key columns (usually created automatically)
  • Foreign key columns (not always automatic)
  • Columns used in WHERE, JOIN, and ORDER BY clauses frequently
  • Columns with unique constraints

View Validation

If your schema includes views, verify they return the expected columns and data:

-- Check view definition
SELECT view_definition
FROM information_schema.views
WHERE table_name = 'active_orders_summary';
 
-- Verify view returns expected columns
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'active_orders_summary';

CRUD Operations Testing

CRUD (Create, Read, Update, Delete) testing verifies the fundamental data operations that every application performs.

Create (INSERT) Testing

Test insertions with valid data, boundary values, and invalid data:

-- Valid insert should succeed
INSERT INTO customers (name, email, phone)
VALUES ('Jane Smith', 'jane@example.com', '+1-555-0123');
 
-- Verify the insert
SELECT * FROM customers WHERE email = 'jane@example.com';
 
-- Duplicate email should fail (assuming UNIQUE constraint)
INSERT INTO customers (name, email, phone)
VALUES ('Another Jane', 'jane@example.com', '+1-555-0456');
-- Expected: ERROR unique constraint violation
 
-- NULL in required field should fail
INSERT INTO customers (name, email, phone)
VALUES (NULL, 'test@example.com', '+1-555-0789');
-- Expected: ERROR not-null constraint violation

Read (SELECT) Testing

Verify that queries return correct results, especially with filters, joins, and aggregations:

-- Test basic retrieval
SELECT * FROM orders WHERE customer_id = 42;
 
-- Test join correctness
SELECT o.id, c.name, o.total_amount
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'completed';
 
-- Test aggregation accuracy
SELECT customer_id, COUNT(*) as order_count, SUM(total_amount) as total_spent
FROM orders
GROUP BY customer_id
HAVING SUM(total_amount) > 1000;
 
-- Test with NULL handling
SELECT * FROM customers WHERE phone IS NULL;
SELECT * FROM orders WHERE discount_code IS NOT NULL;

Update Testing

Verify that updates modify the correct records and maintain data consistency:

-- Update a single record
UPDATE orders SET status = 'shipped' WHERE id = 1001;
SELECT status FROM orders WHERE id = 1001;
-- Expected: 'shipped'
 
-- Verify other records are unaffected
SELECT COUNT(*) FROM orders WHERE status = 'shipped' AND id != 1001;
 
-- Test constraint enforcement on update
UPDATE orders SET total_amount = -50 WHERE id = 1001;
-- Expected: ERROR check constraint violation (if CHECK total_amount >= 0 exists)

Delete Testing

Verify deletion behavior including referential integrity enforcement:

-- Delete a record with no dependencies
DELETE FROM customers WHERE id = 999;
SELECT COUNT(*) FROM customers WHERE id = 999;
-- Expected: 0
 
-- Attempt to delete a record with foreign key references
DELETE FROM customers WHERE id = 42;
-- Expected behavior depends on foreign key configuration:
-- RESTRICT: ERROR, deletion blocked
-- CASCADE: Customer and all their orders deleted
-- SET NULL: Customer deleted, orders.customer_id set to NULL

Bulk Operations Testing

Test behavior with large batch operations:

-- Bulk insert
INSERT INTO orders (customer_id, total_amount, status)
SELECT id, 0.00, 'draft'
FROM customers
WHERE created_at > '2026-01-01';
 
-- Verify count matches
SELECT COUNT(*) FROM orders WHERE status = 'draft';

Data Integrity and Constraint Testing

Data integrity testing ensures the database enforces business rules and prevents invalid data states.

Referential Integrity

Test that foreign key relationships are maintained correctly:

-- Insert order with non-existent customer should fail
INSERT INTO orders (customer_id, total_amount, status)
VALUES (999999, 100.00, 'pending');
-- Expected: ERROR foreign key constraint violation
 
-- Verify cascading behavior
-- If ON DELETE CASCADE is configured:
DELETE FROM customers WHERE id = 42;
SELECT COUNT(*) FROM orders WHERE customer_id = 42;
-- Expected: 0 (orders were cascaded)

Check Constraints

Verify that CHECK constraints reject invalid values:

-- Negative amount should fail
INSERT INTO orders (customer_id, total_amount, status)
VALUES (1, -50.00, 'pending');
-- Expected: ERROR check constraint violation
 
-- Invalid status should fail
INSERT INTO orders (customer_id, total_amount, status)
VALUES (1, 100.00, 'invalid_status');
-- Expected: ERROR check constraint violation (if CHECK constraint exists)

Unique Constraints

Test that duplicate values are rejected where uniqueness is required:

-- Duplicate email should fail
INSERT INTO customers (name, email) VALUES ('Test User', 'existing@example.com');
-- Expected: ERROR unique constraint violation
 
-- NULL handling in unique columns
-- Most databases allow multiple NULLs in a UNIQUE column
INSERT INTO customers (name, email) VALUES ('User A', NULL);
INSERT INTO customers (name, email) VALUES ('User B', NULL);
-- Expected: Both succeed (NULLs are not considered equal)

Cross-Table Consistency

Verify that data across related tables remains consistent:

-- Verify order totals match line items
SELECT o.id, o.total_amount, SUM(li.quantity * li.unit_price) as calculated_total
FROM orders o
JOIN line_items li ON o.id = li.order_id
GROUP BY o.id, o.total_amount
HAVING o.total_amount != SUM(li.quantity * li.unit_price);
-- Expected: Empty result set (no mismatches)

Stored Procedure Testing

Stored procedures encapsulate business logic at the database level. They require systematic testing because bugs here affect every application that calls them.

Basic Input/Output Testing

Test procedures with valid inputs, boundary values, and invalid inputs:

-- Example stored procedure
CREATE OR REPLACE PROCEDURE process_refund(
    p_order_id INTEGER,
    p_refund_amount DECIMAL(10,2),
    OUT p_status VARCHAR(20)
)
LANGUAGE plpgsql AS $$
BEGIN
    -- Business logic here
    IF p_refund_amount <= 0 THEN
        p_status := 'INVALID_AMOUNT';
        RETURN;
    END IF;
 
    UPDATE orders
    SET total_amount = total_amount - p_refund_amount,
        status = 'refunded'
    WHERE id = p_order_id AND total_amount >= p_refund_amount;
 
    IF FOUND THEN
        p_status := 'SUCCESS';
    ELSE
        p_status := 'FAILED';
    END IF;
END;
$$;
 
-- Test: Valid refund
CALL process_refund(1001, 25.00, NULL);
-- Expected: p_status = 'SUCCESS'
 
-- Test: Refund exceeds order total
CALL process_refund(1001, 999999.00, NULL);
-- Expected: p_status = 'FAILED'
 
-- Test: Negative refund amount
CALL process_refund(1001, -10.00, NULL);
-- Expected: p_status = 'INVALID_AMOUNT'
 
-- Test: Non-existent order
CALL process_refund(999999, 25.00, NULL);
-- Expected: p_status = 'FAILED'

Edge Cases

Test stored procedures with boundary conditions:

  • NULL parameters
  • Empty string parameters
  • Maximum and minimum numeric values
  • Zero values
  • Very large result sets
  • Concurrent execution of the same procedure

Error Handling

Verify that procedures handle errors gracefully:

-- Test that error in procedure doesn't leave partial changes
-- (verify transaction rollback behavior)
BEGIN;
CALL process_order(1001);
-- If procedure fails mid-execution, verify no partial data changes exist
ROLLBACK;
 
SELECT * FROM orders WHERE id = 1001;
-- Should be unchanged from before the failed call

Performance Testing of Procedures

Measure execution time for stored procedures with representative data volumes:

-- PostgreSQL: Measure execution time
EXPLAIN ANALYZE
SELECT * FROM get_customer_orders(42);
 
-- Verify execution plan is using indexes
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM get_customer_orders(42);

Trigger Testing

Triggers execute automatically in response to data changes. They're particularly prone to subtle bugs because they run implicitly.

Basic Trigger Behavior

Verify triggers fire on the correct events and produce expected results:

-- Example: Audit trigger that logs changes
CREATE OR REPLACE FUNCTION log_order_changes()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO order_audit_log (order_id, old_status, new_status, changed_at)
    VALUES (NEW.id, OLD.status, NEW.status, NOW());
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Test: Update should create audit log entry
UPDATE orders SET status = 'shipped' WHERE id = 1001;
 
-- Verify audit entry was created
SELECT * FROM order_audit_log WHERE order_id = 1001;
-- Expected: One row with old_status and new_status recorded

Trigger Edge Cases

Test scenarios that commonly cause trigger problems:

Bulk operations:

-- Update multiple rows and verify trigger fires for each
UPDATE orders SET status = 'cancelled' WHERE created_at < '2025-01-01';
 
-- Verify one audit entry per updated row
SELECT COUNT(*) FROM order_audit_log WHERE new_status = 'cancelled';

Recursive triggers: If trigger A modifies table B, and table B has a trigger that modifies table A, you get infinite recursion. Test that your trigger design prevents this:

-- Test that updates from triggers don't cause cascading trigger loops
UPDATE orders SET status = 'processing' WHERE id = 1001;
-- Should complete without timeout or recursion error

NULL handling:

-- Test trigger behavior when OLD or NEW values contain NULLs
INSERT INTO orders (customer_id, total_amount, status, discount_code)
VALUES (1, 100.00, 'pending', NULL);
-- Verify trigger handled NULL discount_code correctly

BEFORE vs. AFTER Triggers

Test that BEFORE triggers can modify the incoming data and AFTER triggers see the final state:

-- BEFORE INSERT trigger that validates/transforms data
-- Test: Insert with lowercase email should be stored as lowercase
INSERT INTO customers (name, email) VALUES ('Test', 'TEST@EXAMPLE.COM');
SELECT email FROM customers WHERE name = 'Test';
-- Expected: 'test@example.com' (if BEFORE trigger normalizes email)

Transaction Testing

Transaction testing verifies ACID properties: Atomicity, Consistency, Isolation, and Durability. This is critical for any application that modifies multiple related records.

Atomicity Testing

Verify that transactions complete fully or not at all:

-- Test: Partial failure should roll back all changes
BEGIN;
UPDATE accounts SET balance = balance - 500 WHERE id = 1;  -- Debit
UPDATE accounts SET balance = balance + 500 WHERE id = 999; -- Credit (non-existent account)
COMMIT;
 
-- Verify: First account balance should be unchanged (whole transaction rolled back)
SELECT balance FROM accounts WHERE id = 1;

Isolation Testing

Test behavior when concurrent transactions access the same data:

-- Session 1: Start transaction, read balance
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- Returns 1000
 
-- Session 2: Update the same account
UPDATE accounts SET balance = balance - 200 WHERE id = 1;
 
-- Session 1: Read again (behavior depends on isolation level)
SELECT balance FROM accounts WHERE id = 1;
-- READ COMMITTED: Returns 800 (sees committed change)
-- REPEATABLE READ: Returns 1000 (sees snapshot)
-- SERIALIZABLE: Returns 1000 (sees snapshot)
COMMIT;

Test for common concurrency issues:

  • Dirty reads: Can one transaction see uncommitted changes from another?
  • Non-repeatable reads: Does the same query return different results within a transaction?
  • Phantom reads: Do new rows appear in a repeated query within a transaction?
  • Lost updates: Can two concurrent updates overwrite each other?
  • Deadlocks: Can two transactions block each other waiting for locks?

Durability Testing

Verify that committed data survives system failures:

-- Insert and commit data
INSERT INTO critical_data (id, value) VALUES (1, 'important');
COMMIT;
 
-- Simulate restart (in test environment)
-- After restart, verify data persists
SELECT * FROM critical_data WHERE id = 1;
-- Expected: Row exists with value = 'important'

Deadlock Testing

Deliberately create deadlock scenarios to verify detection and recovery:

-- Session 1:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Wait...
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
 
-- Session 2 (simultaneously):
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;
-- Wait...
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
 
-- Expected: Database detects deadlock and aborts one transaction
-- Verify: Both accounts have consistent balances after resolution

Database Migration Testing

Database migrations modify schema and data during deployments. Migration bugs are among the most dangerous because they can corrupt production data irreversibly.

Forward Migration Testing

Test that each migration executes correctly:

-- Before migration: Verify starting state
SELECT COUNT(*) FROM orders WHERE discount_percent IS NULL;
 
-- Run migration: Add column with default value
ALTER TABLE orders ADD COLUMN discount_percent DECIMAL(5,2) DEFAULT 0.00;
 
-- After migration: Verify schema change
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_name = 'orders' AND column_name = 'discount_percent';
 
-- Verify existing data was handled correctly
SELECT COUNT(*) FROM orders WHERE discount_percent = 0.00;
-- Expected: Same count as before (default applied to all existing rows)

Rollback Testing

Verify that every migration can be reversed:

-- Run forward migration
-- Run rollback migration
-- Verify schema matches pre-migration state
-- Verify no data was lost or corrupted during round-trip

Data Migration Testing

When migrations transform existing data, verify accuracy:

-- Migration: Split 'full_name' into 'first_name' and 'last_name'
 
-- Before migration: Record sample data
SELECT id, full_name FROM customers WHERE id IN (1, 2, 3);
 
-- After migration: Verify transformation
SELECT id, first_name, last_name FROM customers WHERE id IN (1, 2, 3);
-- Verify: 'Jane Smith' -> first_name='Jane', last_name='Smith'

Migration Performance Testing

Run migrations against production-sized datasets to estimate execution time:

-- Time the migration on a copy of production data
\timing on
ALTER TABLE large_table ADD COLUMN new_field VARCHAR(255);
-- If this takes minutes on 10M rows, it will lock the table for minutes in production

For large tables, consider online migration strategies:

  1. Add column as nullable (fast, no table lock)
  2. Backfill data in batches (no table lock)
  3. Add NOT NULL constraint (requires all rows populated)

Migration Chain Testing

Run the complete migration chain from an empty database to verify there are no ordering or dependency issues:

# Reset database and run all migrations from scratch
# Verify final schema matches expected state
# This catches issues where migrations depend on data
# created by other migrations

Database Performance Testing

Database performance testing identifies slow queries, missing indexes, and scalability bottlenecks before they affect production users.

For detailed guidance on broader performance testing techniques and tools, see our performance testing guide.

Query Performance Testing

Identify slow queries and verify they use appropriate execution plans:

-- PostgreSQL: Analyze query execution plan
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT c.name, COUNT(o.id) as order_count, SUM(o.total_amount) as total
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE c.created_at > '2025-01-01'
GROUP BY c.name
ORDER BY total DESC
LIMIT 100;

Key things to check in execution plans:

  • Sequential scans on large tables: Indicates missing index
  • Nested loop joins on large tables: May need index on join column
  • High estimated rows vs. actual rows: Statistics may be outdated
  • Sort operations on large datasets: May need index for ORDER BY

Index Effectiveness Testing

Verify that indexes improve query performance:

-- Test query without index
DROP INDEX IF EXISTS idx_orders_customer_id;
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
-- Note: Sequential Scan, execution time
 
-- Create index
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
-- Expected: Index Scan, faster execution time

Load Testing

Test database behavior under concurrent load using tools like JMeter with JDBC samplers or custom load scripts. Learn more about JMeter for this type of testing in our JMeter complete guide.

Key metrics to measure:

  • Query response time under increasing concurrent connections
  • Throughput (transactions per second) at various load levels
  • Connection pool behavior at capacity
  • Lock contention during concurrent write operations
  • Resource utilization (CPU, memory, disk I/O) under load

Stress Testing Queries

Test individual queries with larger-than-expected data volumes:

-- Generate test data at scale
INSERT INTO orders (customer_id, total_amount, status, created_at)
SELECT
    (random() * 10000)::int,
    (random() * 1000)::decimal(10,2),
    (ARRAY['pending','shipped','delivered','cancelled'])[floor(random()*4+1)],
    NOW() - (random() * 365 || ' days')::interval
FROM generate_series(1, 1000000);
 
-- Now test your critical queries against this data volume
EXPLAIN ANALYZE
SELECT customer_id, COUNT(*), SUM(total_amount)
FROM orders
WHERE created_at > '2025-06-01'
GROUP BY customer_id
ORDER BY SUM(total_amount) DESC;

Database Security Testing

Database security testing protects against unauthorized access, data breaches, and injection attacks. This is one of the most critical categories of database testing.

For broader security testing methodology, see our security testing guide.

SQL Injection Testing

Test every application input that reaches the database for injection vulnerabilities:

-- Common injection payloads to test (in application inputs):
' OR '1'='1
'; DROP TABLE users; --
' UNION SELECT username, password FROM users --
1; EXEC xp_cmdshell('dir')

Verify that your application uses parameterized queries:

# VULNERABLE: String concatenation
cursor.execute(f"SELECT * FROM users WHERE id = {user_input}")
 
# SAFE: Parameterized query
cursor.execute("SELECT * FROM users WHERE id = %s", (user_input,))

Test stored procedures for injection resistance:

-- If a stored procedure builds dynamic SQL, test it:
CALL search_customers('Smith'' OR ''1''=''1');
-- Should return only customers named "Smith' OR '1'='1"
-- Not all customers in the database

Access Control Testing

Verify that database roles and permissions follow least-privilege principles:

-- Test: Read-only user cannot modify data
SET ROLE readonly_user;
INSERT INTO customers (name, email) VALUES ('Test', 'test@test.com');
-- Expected: ERROR permission denied
 
-- Test: Application user cannot access admin tables
SET ROLE app_user;
SELECT * FROM admin_settings;
-- Expected: ERROR permission denied
 
-- Test: Schema changes require elevated privileges
SET ROLE app_user;
DROP TABLE customers;
-- Expected: ERROR permission denied

Encryption Verification

Verify that sensitive data is encrypted at rest:

-- Check if sensitive columns are encrypted
-- (implementation varies by database and encryption method)
 
-- Verify password hashing
SELECT password FROM users WHERE id = 1;
-- Expected: Hashed value (e.g., $2b$12$...), NOT plaintext
 
-- Verify encrypted fields can be decrypted with correct key
SELECT pgp_sym_decrypt(ssn_encrypted, 'correct_key') FROM customers WHERE id = 1;
-- Expected: Returns decrypted SSN
 
-- Verify encrypted fields cannot be decrypted with wrong key
SELECT pgp_sym_decrypt(ssn_encrypted, 'wrong_key') FROM customers WHERE id = 1;
-- Expected: ERROR or NULL

Audit Logging Verification

Confirm that database access and modifications are tracked:

-- Perform a sensitive operation
UPDATE customers SET email = 'new@example.com' WHERE id = 1;
 
-- Verify audit log captured the change
SELECT * FROM audit_log
WHERE table_name = 'customers'
  AND record_id = 1
  AND operation = 'UPDATE'
ORDER BY timestamp DESC
LIMIT 1;
-- Expected: Log entry with old value, new value, user, timestamp

Error Message Testing

Verify that database errors don't expose schema information:

-- Application should show generic error, not:
-- "ERROR: relation 'users' has no column named 'ssn_encrypted'"
-- This reveals table and column names to potential attackers

ETL Testing

ETL (Extract, Transform, Load) testing validates data pipelines that move and transform data between systems. ETL bugs can cause data loss, duplication, or corruption at scale.

Completeness Testing

Verify all records are transferred from source to target:

-- Count records in source
SELECT COUNT(*) FROM source_db.raw_transactions;
-- Result: 1,000,000
 
-- Count records in target after ETL
SELECT COUNT(*) FROM target_db.processed_transactions;
-- Expected: 1,000,000 (or documented exclusion count)
 
-- If counts differ, check rejection log
SELECT COUNT(*), rejection_reason
FROM etl_rejections
GROUP BY rejection_reason;

Transformation Accuracy

Verify that business rules are applied correctly during transformation:

-- Source data: amounts in various currencies
SELECT id, amount, currency FROM source_db.transactions WHERE id = 12345;
-- Returns: 12345, 100.00, 'EUR'
 
-- Target data: amounts converted to USD
SELECT id, original_amount, converted_amount_usd, exchange_rate
FROM target_db.transactions WHERE source_id = 12345;
-- Expected: Correct USD conversion using the specified exchange rate

Deduplication Testing

Verify that duplicate records in the source are handled correctly:

-- Source has duplicate customer records
SELECT email, COUNT(*) FROM source_db.customers GROUP BY email HAVING COUNT(*) > 1;
 
-- After ETL, duplicates should be merged or flagged
SELECT email, COUNT(*) FROM target_db.customers GROUP BY email HAVING COUNT(*) > 1;
-- Expected: 0 duplicates (if dedup is part of the ETL spec)

Incremental Load Testing

Test that incremental ETL correctly processes only new and changed records:

-- After initial full load
SELECT MAX(last_processed_timestamp) FROM etl_watermarks;
-- Returns: 2026-01-28 10:00:00
 
-- Add new records to source after the watermark
INSERT INTO source_db.transactions VALUES (...);
 
-- Run incremental ETL
-- Verify only new records were processed
SELECT COUNT(*) FROM target_db.transactions WHERE loaded_at > '2026-01-28 10:00:00';

Data Quality Checks

Validate data quality rules after ETL:

-- Check for NULL values in required fields
SELECT COUNT(*) FROM target_db.transactions WHERE customer_id IS NULL;
-- Expected: 0
 
-- Check for out-of-range values
SELECT COUNT(*) FROM target_db.transactions WHERE amount < 0;
-- Expected: 0 (or known count of valid negative amounts like refunds)
 
-- Check date consistency
SELECT COUNT(*) FROM target_db.transactions
WHERE transaction_date > CURRENT_DATE;
-- Expected: 0 (no future dates)

NoSQL Database Testing

NoSQL databases (MongoDB, Cassandra, Redis, DynamoDB, Neo4j) require different testing approaches because they differ fundamentally from relational databases in schema enforcement, consistency models, and query patterns.

Document Database Testing (MongoDB, CouchDB)

Test document structure consistency at the application level:

// MongoDB: Verify document schema consistency
db.orders.find({
  $or: [
    { customer_id: { $exists: false } },
    { total_amount: { $exists: false } },
    { status: { $exists: false } }
  ]
}).count();
// Expected: 0 (all documents have required fields)
 
// Test nested document operations
db.orders.updateOne(
  { _id: ObjectId("...") },
  { $push: { items: { product_id: "P001", quantity: 2, price: 29.99 } } }
);
 
// Verify nested document update
db.orders.findOne({ _id: ObjectId("...") });
// Expected: items array includes new entry

Eventual Consistency Testing

Many NoSQL databases offer eventual consistency. Test read-after-write behavior:

// Write data
db.collection.insertOne({ key: "test", value: "written" });
 
// Immediate read (may not see write depending on consistency level)
const result = db.collection.findOne({ key: "test" });
// Test: Does the read return the written value?
// With eventual consistency, a retry loop may be needed
 
// Test with read preference settings
const result = db.collection
  .withReadPreference("primary")
  .findOne({ key: "test" });
// Reading from primary should always return latest write

Key-Value Store Testing (Redis)

Test TTL expiration, data types, and atomic operations:

# Set with TTL
SET session:user123 "active" EX 3600

# Verify TTL is set
TTL session:user123
# Expected: ~3600 seconds

# Verify expiration (after TTL elapses)
GET session:user123
# Expected: nil

# Test atomic increment
SET counter 0
INCR counter
INCR counter
GET counter
# Expected: 2

Graph Database Testing (Neo4j)

Test relationship traversals and graph queries:

// Verify relationship integrity
MATCH (c:Customer)-[:PLACED]->(o:Order)
WHERE o.id = 1001
RETURN c.name, o.total_amount;
 
// Test for orphaned nodes
MATCH (o:Order)
WHERE NOT (o)<-[:PLACED]-(:Customer)
RETURN COUNT(o);
// Expected: 0 (no orphaned orders)
 
// Test traversal depth
MATCH path = (start:Customer {id: 1})-[:KNOWS*1..5]-(connected)
RETURN LENGTH(path), connected.name;

Test Data Management

Effective test data management is essential for reliable, repeatable database testing.

Principles

  1. Isolation: Each test should run against a known data state
  2. Repeatability: Tests should produce the same results every run
  3. Independence: Tests should not depend on other tests' data changes
  4. Realism: Test data should represent real-world patterns and edge cases
  5. Privacy: Never use actual production PII in test environments

Container-Based Test Databases

Use Docker containers for disposable test databases:

# docker-compose.test.yml
services:
  test-db:
    image: postgres:16
    environment:
      POSTGRES_DB: test_db
      POSTGRES_USER: test_user
      POSTGRES_PASSWORD: test_pass
    ports:
      - "5433:5432"
    volumes:
      - ./migrations:/docker-entrypoint-initdb.d
// Using Testcontainers (Java)
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
    .withDatabaseName("test_db")
    .withInitScript("schema.sql");
 
@BeforeEach
void setUp() {
    // Run data setup script
    ScriptUtils.executeSqlScript(connection, new ClassPathResource("test-data.sql"));
}
 
@AfterEach
void tearDown() {
    // Clean up or rollback
    connection.rollback();
}

Transaction-Based Isolation

Wrap each test in a transaction that rolls back:

# Python example with pytest
@pytest.fixture
def db_session():
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    yield session
    transaction.rollback()
    connection.close()
 
def test_create_order(db_session):
    order = Order(customer_id=1, total_amount=100.00)
    db_session.add(order)
    db_session.flush()
    assert order.id is not None
    # Transaction automatically rolls back after test

Data Generation

Use libraries to generate realistic test data:

from faker import Faker
fake = Faker()
 
# Generate realistic customer data
for _ in range(10000):
    cursor.execute(
        "INSERT INTO customers (name, email, phone, address) VALUES (%s, %s, %s, %s)",
        (fake.name(), fake.email(), fake.phone_number(), fake.address())
    )

Data Masking

When production data is needed for testing, anonymize sensitive fields:

-- Mask PII in a test copy of production data
UPDATE customers_test SET
    name = 'Customer_' || id,
    email = 'user' || id || '@test.example.com',
    phone = '+1-555-' || LPAD(id::text, 4, '0'),
    ssn = '***-**-' || RIGHT(ssn, 4);

Database Testing Tools

Unit Test Frameworks

ToolDatabaseLanguageKey Feature
tSQLtSQL ServerT-SQLMocking, table faking, assertion library
pgTAPPostgreSQLSQLTAP-compliant, 100+ assertion functions
utPLSQLOraclePL/SQLxUnit-style, code coverage
DbUnitAny (JDBC)JavaDataset-based setup/teardown
TestcontainersAnyJava/Python/Go/.NETDisposable Docker containers

Data Comparison Tools

ToolPurpose
DBeaverGUI-based database comparison and inspection
DataGripJetBrains IDE with schema diff and data comparison
ToadOracle/SQL Server database management and testing
dbForgeSchema and data comparison for multiple databases

Migration Tools

ToolPurpose
FlywayVersion-controlled SQL migrations with validation
LiquibaseDatabase-agnostic migrations with rollback support
AlembicPython/SQLAlchemy migration framework
Knex.jsJavaScript migration builder

Performance and Security Tools

ToolPurpose
JMeter (JDBC)Load testing with database query samplers
HammerDBDatabase benchmarking (TPC-C, TPC-H)
Percona ToolkitMySQL/PostgreSQL analysis and optimization
sqlmapAutomated SQL injection detection

pgTAP Example (PostgreSQL)

-- pgTAP test: Verify table structure and constraints
BEGIN;
SELECT plan(5);
 
-- Test: Table exists
SELECT has_table('orders');
 
-- Test: Column exists with correct type
SELECT has_column('orders', 'total_amount');
SELECT col_type_is('orders', 'total_amount', 'numeric(10,2)');
 
-- Test: Foreign key exists
SELECT fk_ok('orders', 'customer_id', 'customers', 'id');
 
-- Test: Check constraint exists
SELECT has_check('orders');
 
SELECT * FROM finish();
ROLLBACK;

tSQLt Example (SQL Server)

-- tSQLt test: Verify stored procedure behavior
EXEC tSQLt.NewTestClass 'OrderTests';
 
CREATE PROCEDURE OrderTests.[test process_refund rejects negative amount]
AS
BEGIN
    EXEC tSQLt.FakeTable 'dbo.orders';
 
    INSERT INTO dbo.orders (id, total_amount, status)
    VALUES (1, 100.00, 'completed');
 
    DECLARE @status VARCHAR(20);
    EXEC process_refund @order_id = 1, @refund_amount = -10, @status = @status OUTPUT;
 
    EXEC tSQLt.AssertEquals 'INVALID_AMOUNT', @status;
END;

Integrating Database Tests into CI/CD

Database tests should run automatically in your CI/CD pipeline to catch issues before deployment. For a detailed guide on setting up CI/CD pipelines for testing, see our GitHub Actions for test automation guide.

Pipeline Structure

A typical database testing pipeline has these stages:

  1. Spin up test database — Start a container with the target database engine
  2. Run migrations — Apply all migration scripts to build the schema
  3. Schema validation — Verify the final schema matches expectations
  4. Seed test data — Load baseline test data
  5. Run unit tests — Execute stored procedure and trigger tests
  6. Run integration tests — Execute CRUD and transaction tests
  7. Performance checks — Run critical query benchmarks (optional, nightly)
  8. Tear down — Destroy the test container

GitHub Actions Example

name: Database Tests
on: [push, pull_request]
 
jobs:
  db-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_pass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Run migrations
        run: |
          PGPASSWORD=test_pass psql -h localhost -U test_user -d test_db \
            -f migrations/001_create_tables.sql \
            -f migrations/002_add_constraints.sql
 
      - name: Run pgTAP tests
        run: |
          PGPASSWORD=test_pass psql -h localhost -U test_user -d test_db \
            -f tests/db/*.sql
 
      - name: Run application database tests
        run: npm test -- --testPathPattern=database

Testing Migration Safety

Add a CI step that verifies migrations can run cleanly from scratch:

- name: Test migration chain
  run: |
    # Apply all migrations sequentially
    for migration in migrations/*.sql; do
      PGPASSWORD=test_pass psql -h localhost -U test_user -d test_db -f "$migration"
    done
 
    # Verify final schema matches expected state
    PGPASSWORD=test_pass pg_dump -h localhost -U test_user -d test_db --schema-only > actual_schema.sql
    diff expected_schema.sql actual_schema.sql

Best Practices for Database Testing

1. Test the Database Independently

Don't rely solely on application-level tests to validate database behavior. Database constraints, triggers, and stored procedures should be tested directly against the database.

2. Use Production-Representative Data Volumes

A test that passes with 100 rows may fail or timeout with 10 million rows. Test critical queries with production-scale data to catch performance issues early.

3. Version Control Everything

Keep migrations, test data scripts, stored procedures, and database test code in version control alongside application code. Treat database code as first-class code.

4. Automate Migration Testing

Never run migrations in production that haven't been tested against a copy of production data. Automate this in CI/CD to prevent human error.

5. Test Rollback Procedures

Every forward migration should have a tested rollback. If a deployment goes wrong, you need confidence that rolling back won't make things worse.

6. Isolate Test Environments

Use containerized or transaction-isolated test databases so tests don't interfere with each other and can run in parallel.

7. Monitor After Deployment

Database testing doesn't end at deployment. Monitor query performance, error rates, and data quality metrics in production to catch issues that testing didn't cover.

8. Test Data Privacy Compliance

Verify that your test environments don't contain unmasked production PII. This is both a security best practice and a legal requirement under GDPR, CCPA, and similar regulations.

9. Include Database Tests in Code Reviews

When reviewing pull requests that include migrations or database changes, verify that corresponding tests exist and cover edge cases.

10. Test Concurrent Access Patterns

Single-user testing misses concurrency bugs. Test with multiple simultaneous connections to verify locking behavior, deadlock handling, and transaction isolation.

Related Testing Types

Database testing intersects with several other testing disciplines:

  • Integration Testing — Tests how the application layer interacts with the database through repositories, ORMs, and data access layers
  • Performance Testing — Database query performance under load is a critical subset of application performance testing
  • Security Testing — SQL injection, access control, and encryption testing protect the data layer
  • Regression Testing — Schema changes and migration testing prevent regressions in data handling
  • Acceptance Testing — Data accuracy is a key acceptance criterion for data-intensive applications

Quiz

Quiz on Database Testing

Your Score: 0/10

Question: Which type of database testing verifies that tables, columns, data types, constraints, and indexes match the design specifications?

FAQs

Frequently Asked Questions (FAQs) / People Also Ask (PAA)

What is database testing and why is it important?

What is the difference between structural testing and functional testing of a database?

How do I test stored procedures and triggers effectively?

What are the key areas to cover in database security testing?

What tools can I use for database testing?

How do I handle test data management for database testing?

What is ETL testing and how does it differ from regular database testing?

How should I approach testing database migrations?

What should I test differently when working with NoSQL databases compared to relational databases?

How do I integrate database testing into a CI/CD pipeline?

Wrapping Up

Database testing protects the most valuable part of most software systems: the data. While UI bugs are visible and API bugs surface quickly, database bugs can silently corrupt data for weeks before anyone notices.

Effective database testing covers the full spectrum: structural validation ensures your schema is correct, functional testing verifies operations work as specified, integrity testing prevents invalid data states, and non-functional testing validates performance and security.

The key is to treat your database as a first-class component that deserves its own test suite, not an implementation detail that's implicitly tested through the application. Use the tools and techniques in this guide to build a systematic database testing practice that catches data issues before they reach production.

Start with the areas that represent the highest risk for your specific application: if you handle financial data, prioritize transaction and integrity testing. If you run frequent migrations, invest in migration testing automation. If you process large data volumes, focus on performance testing with realistic datasets.