09 · Testing Database Code¶
Code that touches a database needs tests just like any other code — but the database itself is part of what's under test. The standard approach: run tests against a real (in-memory) instance of the same database engine, not a mock, so the tests catch real constraint violations and transaction behavior. This module builds a small transfer function and a full test suite for it, run and passing.
The code under test¶
def transfer(conn, from_owner, to_owner, amount):
cur = conn.cursor()
cur.execute("BEGIN")
try:
cur.execute("SELECT balance FROM accounts WHERE owner = ?", (from_owner,))
row = cur.fetchone()
if row is None or row[0] < amount:
raise ValueError("insufficient funds")
cur.execute("UPDATE accounts SET balance = balance - ? WHERE owner = ?", (amount, from_owner))
cur.execute("UPDATE accounts SET balance = balance + ? WHERE owner = ?", (amount, to_owner))
conn.commit()
except Exception:
conn.rollback()
raise
The schema it runs against:
CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
owner TEXT NOT NULL,
balance REAL NOT NULL CHECK (balance >= 0)
);
Why an in-memory SQLite database, not a mock¶
Mocking the database (faking execute/fetchone to return canned values)
tests that your code calls the right methods — it can't tell you whether
transfer actually respects the CHECK (balance >= 0) constraint, or
whether the transaction really rolls back on failure. An in-memory SQLite
database (sqlite3.connect(":memory:")) is a real, fully-functional
database that exists only for the test process and disappears when the
connection closes — fast (no disk I/O) and completely isolated between test
runs, while still enforcing every constraint and transaction guarantee the
production database would.
The full test suite, run and passing¶
import sqlite3, unittest
SCHEMA = """
CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
owner TEXT NOT NULL,
balance REAL NOT NULL CHECK (balance >= 0)
);
"""
class TestTransfer(unittest.TestCase):
def setUp(self):
self.conn = sqlite3.connect(":memory:")
self.conn.executescript(SCHEMA)
self.conn.executemany("INSERT INTO accounts (owner, balance) VALUES (?, ?)",
[("Priya", 100.0), ("Marco", 20.0)])
self.conn.commit()
def test_successful_transfer_moves_money(self):
transfer(self.conn, "Priya", "Marco", 30.0)
balances = dict(self.conn.execute("SELECT owner, balance FROM accounts").fetchall())
self.assertEqual(balances["Priya"], 70.0)
self.assertEqual(balances["Marco"], 50.0)
def test_insufficient_funds_raises_and_rolls_back(self):
with self.assertRaises(ValueError):
transfer(self.conn, "Marco", "Priya", 1000.0)
balances = dict(self.conn.execute("SELECT owner, balance FROM accounts").fetchall())
self.assertEqual(balances["Marco"], 20.0)
self.assertEqual(balances["Priya"], 100.0)
def test_check_constraint_blocks_negative_balance_directly(self):
with self.assertRaises(sqlite3.IntegrityError):
self.conn.execute("UPDATE accounts SET balance = -5 WHERE owner = 'Priya'")
if __name__ == "__main__":
unittest.main(verbosity=2)
test_check_constraint_blocks_negative_balance_directly ... ok
test_insufficient_funds_raises_and_rolls_back ... ok
test_successful_transfer_moves_money ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.001s
OK
setUp runs before every test method, giving each one a fresh, identically
seeded database — tests don't leak state into each other, which is exactly
what lets test_insufficient_funds_raises_and_rolls_back assert Marco's
balance is untouched after a failed transfer, regardless of what the
previous test did to the (different, disposed-of) in-memory database.
What each test is actually proving¶
test_successful_transfer_moves_money— the happy path: money moved correctly, both balances updated in the same transaction.test_insufficient_funds_raises_and_rolls_back— the interesting one. It doesn't just check thatValueErroris raised; it re-queries the database afterward to confirm the failed transfer left no partial effect — proving thetry/except/rollback()actually works, not just that the exception propagates.test_check_constraint_blocks_negative_balance_directly— tests the database's own defense, independent of the application code. Even iftransfer's validation logic had a bug and let a negative amount through, the schema'sCHECKconstraint is a second layer that stops it — this test exists to confirm that layer works on its own, not just as a side-effect oftransferbeing correct.
Fixtures and isolation, beyond a single test file¶
For a larger test suite:
- One fresh connection per test (as
setUpdoes here) is the simplest and safest isolation strategy — no test can see another's data. - Seed data as a fixture function, not copy-pasted
INSERTstatements in every test — change the seed once, every test picks it up. - Test against the same schema file production uses, not a hand-typed
copy that can drift out of sync — load it from the actual
schema.sql/ migration files your application ships. - For Postgres/MySQL (not SQLite, which has no server), the equivalent
pattern is usually a disposable test database created and torn down per
test run, or a transaction per test that's always rolled back at the end
— tools like
pytest-postgresqlor Django'sTestCaseautomate this.
Cheat sheet¶
| Testing concern | Approach |
|---|---|
| Fast, isolated test database | sqlite3.connect(":memory:"), fresh per test |
| Consistent starting state | Seed data in setUp/a fixture, not per-test copy-paste |
| Prove a rollback actually rolled back | Re-query the data after the failure, don't just check the exception |
| Test constraints independently of app logic | A test that violates the constraint directly, bypassing your code |
| Avoid mocking the database itself | Real (in-memory) database > mocked cursor — catches real constraint/transaction bugs |
How It Actually Works¶
A common pattern — wrapping each test in a transaction and rolling it back
at the end instead of truncating tables — works specifically because of the
rollback journal mechanism covered earlier in this course: ROLLBACK
doesn't undo changes by executing inverse SQL statements, it restores the
original page images that were copied into the journal before any write
happened, which is why it's essentially instantaneous regardless of how much
data the test modified — there's no re-computation, just discarding the
dirty pages and reverting to the pre-transaction page set. Using an
in-memory database (:memory:) for tests skips file I/O and fsync()
entirely — pages live only in the process's memory-mapped page cache with no
disk-backed durability guarantee needed, which is exactly why it's
dramatically faster for test suites that don't need persistence across
runs. Testing query correctness against EXPLAIN QUERY PLAN output (not
just result rows) is a legitimate technique for catching performance
regressions in CI — asserting a query still produces a SEARCH USING INDEX
rather than a SCAN is a stronger guarantee than eyeballing manual runs
after a schema or index change.
Exercise¶
- Add a test
test_transfer_to_nonexistent_owner_rolls_backthat callstransfer(conn, "Priya", "NoSuchPerson", 10.0)and confirms Priya's balance is unchanged (the secondUPDATEsilently affects zero rows — is that actually caught by the currenttransferimplementation, or is this a bug the test uncovers?). - Write a test that seeds 0 accounts and confirms
transferraises cleanly rather than crashing onrow is None. - Extend the schema with a
transfer_logtable and a trigger (Level 3) that records every balance change, then write a test asserting the log has exactly one row after a successful transfer and zero after a failed one.