Database testing and correctness
What your database tests are not telling you
The idea in 30 seconds
A green test suite can hide three gaps. Tests run on a different engine, such as SQLite, accept data PostgreSQL rejects. Tests wrapped in a transaction that is rolled back never reach COMMIT, so they never see what happens there. Tests that run one request at a time cannot find races, which only exist when requests overlap. Close each gap deliberately, and state the rules your data must obey as constraints and checks the database runs for you.
Helps to know first: Race conditions and correct state transitions (coming soon), Isolation levels and anomalies (coming soon), Constraints and data integrity, Benchmarking and performance measurement (coming soon)
On this page
Gap 1: the tests use a different database
Running tests against SQLite in memory is fast and needs no setup, so it is common. It is also a different database. We ran the same statements in SQLite 3.51 and PostgreSQL 18.3:
CREATE TABLE products (id integer PRIMARY KEY, name varchar(10) NOT NULL,
price numeric(10,2) NOT NULL CHECK (price > 0), stock integer);
INSERT INTO products VALUES (1, 'a name much longer than ten characters', 19.999, 'lots');
CREATE TABLE orders (id integer PRIMARY KEY, product_id integer REFERENCES products (id));
INSERT INTO orders VALUES (1, 999);
SELECT 'Abc' LIKE 'abc';
| SQLite | PostgreSQL | |
|---|---|---|
38 characters into varchar(10) | Stored | ERROR: value too long for type character varying(10) |
'lots' into an integer column | Stored, as text | ERROR: invalid input syntax for type integer |
19.999 into numeric(10,2) | Stored as 19.999 | Rounded to 20.00 |
| Order for product 999, which does not exist | Stored | ERROR: ... violates foreign key constraint |
'Abc' LIKE 'abc' | true | false |
Every row is a bug that passes in tests and fails, or behaves differently, in production. SQLite is not wrong; it is documented to work this way. Column types are recommendations, length limits are ignored, and foreign keys are off unless each connection turns them on. It is simply not the database you run.
The fix is cheap now: run tests against the same major version of PostgreSQL you deploy, in a container or a local install. Starting a fresh database per test run takes seconds.
Gap 2: the test never commits
A popular way to keep tests independent is to open a transaction at the start of each test and roll it back at the end. It is fast and leaves nothing behind. But a test that never commits cannot see anything that happens at commit, or anything that depends on time passing inside a transaction.
PredictA test that passes, code that fails
An invoices table has a foreign key to customers, declared DEFERRABLE INITIALLY DEFERRED so that related rows can be inserted in any order within a transaction. A test inserts an invoice for customer 42, who does not exist, and checks that one invoice is there. The test framework then rolls the transaction back. Does the test pass?
Yes. We ran it:
BEGIN;
INSERT INTO invoices (id, customer_id) VALUES (1, 42);
SELECT count(*) FROM invoices; -- 1: test passes
ROLLBACK;The same insert, committed as production would:
COMMIT;
ERROR: insert or update on table "invoices" violates foreign key constraint "invoices_customer_id_fkey"
DETAIL: Key (customer_id)=(42) is not present in table "customers".A deferred constraint is checked at COMMIT. A test that always rolls back never gets there.
Time behaves the same way. now() returns the time the transaction started, all the way through it. In one test transaction we created an invoice, waited one second and marked it paid with now():
time_to_pay | real_elapsed
-------------+-----------------
00:00:00 | 00:00:01.002202
A test of “invoices paid within N seconds” or “sort by most recently updated” can pass or fail for reasons that never happen in production.
Rolled-back tests are still worth having for most code. For anything involving deferred constraints, commit-time triggers, time, or work done over a second connection, write tests that really commit, and clean up by truncating tables or creating a fresh database.
Gap 3: the test runs one thing at a time
A race condition needs two operations to overlap. A test suite that runs one request at a time cannot produce one, however many cases it covers. You will see this in the lab: a “read the balance, then write balance minus one” withdrawal was correct for 4,000 withdrawals run one at a time, and lost about 85% of them when 8 clients ran it at once.
Testing for races means generating concurrency on purpose: several connections running the same operation on the same rows at once, then checking an invariant afterwards. pgbench with a custom script does this in a few lines.
Make the database check the rules
Tests check the code paths you thought of. Constraints check every write, from every path, forever:
- “Each email is used once” is a
UNIQUEindex, not anifin the signup code. - “A price is positive” is a
CHECK. - “An order belongs to a real customer” is a foreign key.
Some rules cannot be constraints, usually because they span many rows: “the stored total_spent equals the sum of the customer’s orders”, “every paid order has at least one item”. Write each one as a query that returns the rows breaking it, and run those queries on a schedule against production, alerting when any return rows:
-- paid or shipped orders with no items: should always return nothing
SELECT o.id FROM orders o
WHERE o.status IN ('paid', 'shipped')
AND NOT EXISTS (SELECT 1 FROM order_items i WHERE i.order_id = o.id);
Check yourself
Name three things SQLite accepted that PostgreSQL rejected or changed.
Show answer
Any three of: a string longer than
varchar(10); the text'lots'in anintegercolumn;19.999innumeric(10,2)(PostgreSQL rounds it to20.00); an order referencing a product that does not exist; and'Abc' LIKE 'abc', which is true in SQLite and false in PostgreSQL.Why did the rolled-back test pass while the committed insert failed?
Show answer
The foreign key was deferred, so it is only checked at
COMMIT. The test rolled back instead of committing, so the check never ran.A team’s tests cover the withdrawal code thoroughly and all pass. Why might money still go missing in production?
Show answer
The tests run one withdrawal at a time. A read-then-write race only appears when two withdrawals overlap, which the tests never cause.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.SQLite documentation: Datatypes In SQLitePrimary· §3 Type Affinity; §3.1.1 Affinity Name Examples
In SQLite a column's type is a recommendation and any column can store any type; length limits such as VARCHAR(255) are ignored.
- 2.SQLite documentation: SQLite Foreign Key SupportPrimary· §2 Enabling Foreign Key Support
SQLite foreign key constraints are disabled by default and must be enabled per connection with PRAGMA foreign_keys.
- 3.SQLite documentation: SQL Language ExpressionsPrimary· §5 The LIKE, GLOB, REGEXP, MATCH, and extract operators
SQLite's LIKE is case-insensitive for ASCII characters by default.
- 4.PostgreSQL documentation: SET CONSTRAINTSPrimary· Description
DEFERRED constraints are not checked until transaction commit.
- 5.PostgreSQL documentation: 9.9 Date/Time Functions and OperatorsPrimary· §9.9.5 Current Date/Time
now() and CURRENT_TIMESTAMP return the start time of the current transaction and do not change during it.
- 6.PostgreSQL documentation: 5.5 ConstraintsPrimary· §5.5 Constraints
CHECK, NOT NULL, UNIQUE and foreign key constraints are enforced by the database on every write.
Go further
What to read next, and why it is worth the time.
- 1.SQLite documentation: Datatypes In SQLitePrimary
If your tests use SQLite, this page lists most of the ways it will disagree with PostgreSQL.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.