Constraint Testing
Database constraints are the database's built-in test suite. They enforce data integrity rules at the lowest level — even if the application code has a bug, constraints prevent invalid data from being stored. Testing constraints verifies that the safety net is in place.
Types of Constraints
| Constraint | Purpose | What to Test |
|---|---|---|
| PRIMARY KEY | Unique row identifier | Insert duplicate PK should fail |
| FOREIGN KEY | References must point to existing rows | Insert with non-existent FK should fail |
| UNIQUE | No duplicate values | Duplicate email should return constraint violation |
| NOT NULL | Column must have a value | NULL for required field should fail |
| CHECK | Custom validation | CHECK (price >= 0) — negative price should fail |
| DEFAULT | Provides value when none specified | Verify default is applied when column is omitted |
Testing Each Constraint Type
Primary Key
def test_primary_key_prevents_duplicates(db):
cursor = db.cursor()
cursor.execute(
"INSERT INTO users (id, name, email) VALUES (%s, %s, %s)",
("user-001", "First", "first@test.com")
)
with pytest.raises(psycopg2.errors.UniqueViolation):
cursor.execute(
"INSERT INTO users (id, name, email) VALUES (%s, %s, %s)",
("user-001", "Duplicate", "duplicate@test.com")
)
Foreign Key
def test_foreign_key_prevents_orphans(db):
"""Cannot create an order for a non-existent user."""
cursor = db.cursor()
with pytest.raises(psycopg2.errors.ForeignKeyViolation):
cursor.execute(
"INSERT INTO orders (user_id, total) VALUES (%s, %s)",
("nonexistent-user-id", 99.99)
)
def test_foreign_key_cascade_delete(db):
"""When a user is deleted, their orders should also be deleted (if CASCADE)."""
cursor = db.cursor()
# Create user and order
cursor.execute("INSERT INTO users (id, name, email) VALUES (%s, %s, %s)",
("cascade-test", "Cascade User", "cascade@test.com"))
cursor.execute("INSERT INTO orders (id, user_id, total) VALUES (%s, %s, %s)",
("cascade-order", "cascade-test", 50.00))
# Delete user
cursor.execute("DELETE FROM users WHERE id = %s", ("cascade-test",))
# Verify order was also deleted (if CASCADE is set)
cursor.execute("SELECT COUNT(*) FROM orders WHERE id = %s", ("cascade-order",))
assert cursor.fetchone()[0] == 0, "Order should be cascade-deleted with user"
def test_foreign_key_restrict_delete(db):
"""If RESTRICT: deleting a user with orders should fail."""
cursor = db.cursor()
cursor.execute("INSERT INTO users (id, name, email) VALUES (%s, %s, %s)",
("restrict-test", "Restrict User", "restrict@test.com"))
cursor.execute("INSERT INTO orders (id, user_id, total) VALUES (%s, %s, %s)",
("restrict-order", "restrict-test", 50.00))
with pytest.raises(psycopg2.errors.ForeignKeyViolation):
cursor.execute("DELETE FROM users WHERE id = %s", ("restrict-test",))
Unique Constraint
def test_unique_email_constraint(db):
cursor = db.cursor()
cursor.execute(
"INSERT INTO users (name, email) VALUES (%s, %s)",
("User A", "dup@test.com")
)
with pytest.raises(psycopg2.errors.UniqueViolation):
cursor.execute(
"INSERT INTO users (name, email) VALUES (%s, %s)",
("User B", "dup@test.com")
)
def test_unique_constraint_case_sensitivity(db):
"""Test whether the unique constraint is case-sensitive."""
cursor = db.cursor()
cursor.execute("INSERT INTO users (name, email) VALUES (%s, %s)",
("User A", "test@example.com"))
try:
cursor.execute("INSERT INTO users (name, email) VALUES (%s, %s)",
("User B", "TEST@EXAMPLE.COM"))
# If this succeeds, the constraint is case-sensitive
# (this may or may not be desired behavior)
except psycopg2.errors.UniqueViolation:
# Constraint is case-insensitive — usually the desired behavior for email
pass
NOT NULL Constraint
def test_not_null_email(db):
cursor = db.cursor()
with pytest.raises(psycopg2.errors.NotNullViolation):
cursor.execute(
"INSERT INTO users (name, email) VALUES (%s, %s)",
("No Email User", None)
)
def test_not_null_name(db):
cursor = db.cursor()
with pytest.raises(psycopg2.errors.NotNullViolation):
cursor.execute(
"INSERT INTO users (name, email) VALUES (%s, %s)",
(None, "valid@test.com")
)
CHECK Constraint
def test_check_price_non_negative(db):
"""Price must be >= 0."""
cursor = db.cursor()
with pytest.raises(psycopg2.errors.CheckViolation):
cursor.execute(
"INSERT INTO products (name, price) VALUES (%s, %s)",
("Bad Product", -10.00)
)
def test_check_allows_zero_price(db):
"""Zero price should be allowed (free products)."""
cursor = db.cursor()
cursor.execute(
"INSERT INTO products (name, price) VALUES (%s, %s)",
("Free Product", 0.00)
)
# Should succeed without error
def test_check_status_enum(db):
"""Status must be one of the allowed values."""
cursor = db.cursor()
with pytest.raises(psycopg2.errors.CheckViolation):
cursor.execute(
"INSERT INTO orders (user_id, status, total) VALUES (%s, %s, %s)",
("user-001", "invalid_status", 50.00)
)
Default Values
def test_default_role_applied(db):
"""When role is not specified, default should be 'viewer'."""
cursor = db.cursor()
cursor.execute(
"INSERT INTO users (name, email) VALUES (%s, %s) RETURNING role",
("Default Role User", "default@test.com")
)
role = cursor.fetchone()[0]
assert role == "viewer"
def test_default_timestamp_applied(db):
"""created_at should default to current time."""
cursor = db.cursor()
cursor.execute(
"INSERT INTO users (name, email) VALUES (%s, %s) RETURNING created_at",
("Timestamp User", "timestamp@test.com")
)
created_at = cursor.fetchone()[0]
assert created_at is not None
# Should be within the last few seconds
from datetime import datetime, timedelta
assert datetime.now() - created_at < timedelta(seconds=5)
Why Test Constraints?
"If the database has the constraint, why test it?" Because:
- Constraints can be accidentally removed during migrations
- Constraints may not exist even when you assume they do
- Application code may bypass constraints (e.g., ORM bug, raw SQL)
- Constraint behavior varies (CASCADE vs RESTRICT, case sensitivity)
- Documentation — constraint tests document the data integrity rules
Discovering Existing Constraints
-- List all constraints on a table (PostgreSQL)
SELECT con.conname, con.contype, pg_get_constraintdef(con.oid)
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
WHERE rel.relname = 'users';
-- contype: p = primary key, f = foreign key, u = unique, c = check
Practical Exercise
- Write tests for each constraint type on a users table: PK, FK, UNIQUE, NOT NULL, CHECK
- Test CASCADE vs RESTRICT behavior on a foreign key
- Test default values: verify defaults are applied when columns are omitted
- Query the database to discover all constraints on a table, then write tests for each
- Test a case-sensitivity edge case on a unique constraint
Key Takeaways
- Database constraints are the last line of defense for data integrity
- Test every constraint type: PK, FK, UNIQUE, NOT NULL, CHECK, DEFAULT
- Test both the positive case (valid data accepted) and negative case (invalid data rejected)
- Foreign key behavior (CASCADE, RESTRICT, SET NULL) must be tested explicitly
- Constraints can be accidentally removed during migrations — tests catch this