BVA and Equivalence Partitioning for Database Testing: Schema, Queries, and Constraints
Database testing has its own equivalence partition structure driven by database constraints, SQL semantics, and the specific behaviors of NULL, type boundaries, and referential integrity.
This post covers how to apply BVA and EP to database layer testing specifically.
Database Column Constraints as Partition Sources
Every column constraint defines partitions:
NOT NULL constraints
For a column with NOT NULL:
| Partition | Value | Expected |
|---|---|---|
| Valid non-null | "John Doe" | INSERT succeeds |
| Null | NULL | INSERT fails: NOT NULL violation |
| Empty string | "" | INSERT succeeds (empty ≠ null) |
The empty string partition is frequently missed. In SQL, empty string and NULL are different — a NOT NULL column can hold an empty string. Depending on business requirements, empty string may be valid or may require a separate CHECK constraint.
VARCHAR length constraints
For name VARCHAR(100):
| Partition | Length | Expected |
|---|---|---|
| Length 0 (empty) | 0 | INSERT succeeds |
| Length 1 | 1 | INSERT succeeds |
| Length 99 | 99 | INSERT succeeds |
| Length 100 | 100 | INSERT succeeds |
| Length 101 | 101 | INSERT fails: string too long |
| Multibyte characters at boundary | "é" × 100 | Behavior depends on encoding |
The multibyte character partition is important for internationalized applications. In UTF-8, VARCHAR(100) means 100 characters in PostgreSQL but 100 bytes in some MySQL configurations. A string of 100 two-byte characters may fail in one database but succeed in another.
Numeric precision and scale
For DECIMAL(10, 2) (10 total digits, 2 after decimal):
| Partition | Value | Expected |
|---|---|---|
| Valid precision | 12345678.99 | INSERT succeeds |
| Zero | 0.00 | INSERT succeeds |
| Negative | -12345678.99 | INSERT succeeds (or fails if UNSIGNED) |
| Exceeds precision | 12345678901.00 | Fails: too many digits |
| Exceeds scale | 12345678.999 | Rounds or fails |
| More precision than scale | 123456789.12 | Fails if total > 10 |
The scale truncation behavior varies by database: PostgreSQL rounds, MySQL rounds or truncates depending on SQL mode, Oracle raises an error. Testing with boundary precision values catches these database-specific behaviors.
CHECK constraints
For CHECK (age BETWEEN 0 AND 150):
| Partition | Value | Expected |
|---|---|---|
| Below minimum | -1 | Fails: CHECK violation |
| Minimum | 0 | Succeeds |
| Valid | 75 | Succeeds |
| Maximum | 150 | Succeeds |
| Above maximum | 151 | Fails: CHECK violation |
Check constraints are often added in migrations and tested only at the application layer. Direct database testing verifies the constraint fires correctly even when the application bypasses it (direct SQL, batch imports, other applications sharing the database).
NULL Partitions in SQL Queries
NULL has three-valued logic in SQL (true/false/null). Queries that don't account for NULL produce incorrect results — a class of bugs that EP for NULL specifically targets.
NULL in WHERE clauses
For SELECT * FROM users WHERE department_id = ?:
| Partition | Value | Notes |
|---|---|---|
| Valid department | 5 | Returns users in dept 5 |
| Non-existent department | 9999 | Returns 0 rows |
| NULL parameter | NULL | department_id = NULL → no rows (use IS NULL) |
A query using = NULL instead of IS NULL silently returns zero rows. This is a correctness bug, not a crash bug, and is easily missed.
NULL in aggregate functions
Aggregate behavior with NULL is another EP class:
For SELECT COUNT(*), COUNT(field), AVG(field), SUM(field), MIN(field), MAX(field):
| Scenario | COUNT(*) | COUNT(field) | AVG(field) | SUM(field) | MIN(field) | MAX(field) |
|---|---|---|---|---|---|---|
| All non-null | n | n | avg | sum | min | max |
| Some null | n | n - nulls | avg(non-null) | sum(non-null) | min(non-null) | max(non-null) |
| All null | n | 0 | NULL | NULL | NULL | NULL |
| Empty table | 0 | 0 | NULL | NULL | NULL | NULL |
The all-null and empty-table partitions are the boundary conditions. Queries that divide by COUNT or that compare the result directly to a number without NULL-handling crash or produce wrong results in these partitions.
NULL in JOINs
For INNER JOIN, LEFT JOIN, RIGHT JOIN with NULL foreign keys:
| Partition | FK value | JOIN type | Behavior |
|---|---|---|---|
| Valid FK | 5 | INNER JOIN | Row included if matching row exists |
| NULL FK | NULL | INNER JOIN | Row excluded |
| NULL FK | NULL | LEFT JOIN | Row included with NULL columns from right |
| No match | 999 | INNER JOIN | Row excluded |
| No match | 999 | LEFT JOIN | Row included with NULL columns from right |
Testing LEFT JOIN with NULL FK values verifies orphan detection queries work correctly. An analytics query that should count orphaned records fails silently if NULL FK handling is wrong.
Query Result Partitions
When testing queries directly (rather than through the application), apply EP to the result set.
Pagination query partitions
For SELECT ... LIMIT ? OFFSET ?:
| Partition | LIMIT | OFFSET | Notes |
|---|---|---|---|
| First page | 20 | 0 | Normal first page |
| Middle page | 20 | 20 | Second page |
| Last page | 20 | N-20 | Last full page |
| Partial last page | 20 | N-10 | Returns fewer than LIMIT rows |
| Beyond end | 20 | N+100 | Returns 0 rows |
| LIMIT = 0 | 0 | 0 | Returns 0 rows or error (DB-specific) |
| LIMIT = all | MAX_INT | 0 | Returns all rows (potential OOM) |
OFFSET beyond the end of results should return an empty result set, not an error. Some ORMs and query builders return errors or incorrect results when OFFSET exceeds total count.
Ordering edge cases
For SELECT ... ORDER BY created_at ASC:
| Partition | Data | Notes |
|---|---|---|
| All unique timestamps | Normal data | Stable ordering |
| Duplicate timestamps | Multiple rows with same timestamp | Order within ties is undefined |
| NULL timestamps | Some NULL | NULL ordering depends on ASC/DESC and database |
NULL ordering in SQL is database-specific: in PostgreSQL, NULLs sort last for ASC by default; in MySQL, NULLs sort first. If the application relies on consistent ordering of NULLs without explicit NULLS FIRST/LAST, behavior differs across databases.
Referential Integrity Partitions
For tables with foreign key relationships:
INSERT with FK constraint
For orders(user_id REFERENCES users(id)):
| Partition | user_id | Expected |
|---|---|---|
| Valid user | 1 (exists) | INSERT succeeds |
| Non-existent user | 99999 | FK violation |
| NULL | NULL | Depends on NULLABLE; if NOT NULL → error |
DELETE with dependent rows
For deleting a user with existing orders:
| Partition | Orders | FK action | Expected |
|---|---|---|---|
| No orders | 0 | Any | DELETE succeeds |
| Has orders | 1+ | RESTRICT | DELETE fails: FK violation |
| Has orders | 1+ | CASCADE | DELETE succeeds; orders deleted |
| Has orders | 1+ | SET NULL | DELETE succeeds; orders.user_id = NULL |
Test each FK action explicitly. A schema defined with RESTRICT but intended to cascade is a data integrity bug.
Transaction and Concurrency Partitions
Isolation level partitions
For concurrent transactions, the isolation level determines what each transaction sees:
| Partition | Isolation Level | Phenomena |
|---|---|---|
| No protection | READ UNCOMMITTED | Dirty reads |
| Default (PostgreSQL) | READ COMMITTED | No dirty reads; non-repeatable reads |
| Snapshot | REPEATABLE READ | No dirty/non-repeatable reads; phantom reads possible |
| Full isolation | SERIALIZABLE | No anomalies |
Test each isolation level against the specific phenomena it should prevent:
Dirty read test (at READ UNCOMMITTED):
- Transaction A begins, updates row
- Transaction B reads the same row (sees uncommitted value — dirty read)
- Transaction A rollbacks
Non-repeatable read test (at READ COMMITTED):
- Transaction A begins, reads row → gets value X
- Transaction B updates and commits the row → value becomes Y
- Transaction A reads the same row again → gets value Y (non-repeatable)
Concurrency boundary partitions
For operations that must be atomic:
| Partition | Scenario | Expected |
|---|---|---|
| Single operation | One thread | Correct result |
| Sequential operations | Two operations, no overlap | Correct result |
| Concurrent read + write | One reads while other writes | Consistent view |
| Concurrent writes (same row) | Two updates same row simultaneously | One wins, one is serialized |
| Deadlock scenario | Two transactions, each waits for the other | One rolls back, retries |
Deadlock tests require timing control — deliberately constructing the interleaving that produces the deadlock. In practice, run the concurrent operations with a barrier to synchronize them at the conflict point.
Database-Specific Boundary Values
Different databases have different type boundary values that matter for partition analysis:
PostgreSQL
| Type | Minimum | Maximum | Notes |
|---|---|---|---|
| SMALLINT | -32768 | 32767 | 2 bytes |
| INTEGER | -2147483648 | 2147483647 | 4 bytes |
| BIGINT | -9223372036854775808 | 9223372036854775807 | 8 bytes |
| REAL | ~-3.4e38 | ~3.4e38 | 4-byte float |
| DOUBLE PRECISION | ~-1.8e308 | ~1.8e308 | 8-byte float |
| TIMESTAMP | 4713 BC | 294276 AD | |
| TEXT | ~1GB | ~1GB | Unlimited in practice |
| BYTEA | ~1GB | ~1GB |
Test overflow: inserting 2147483648 into an INTEGER column should produce an overflow error, not silent truncation to a wrong value.
MySQL
MySQL has STRICT_TRANS_TABLES mode. Without strict mode, out-of-range values are silently clamped. With strict mode, they produce errors. Testing in both modes reveals whether application code depends on silent truncation.
Summary
Database layer EP follows the same structure as application layer EP: identify the behavioral regions, test boundaries and transitions. The database-specific additions: column constraint partitions, three-valued NULL logic, referential integrity states, and transaction isolation levels.
Direct database testing is valuable even when application-level tests exist. Applications can bypass constraints (direct SQL, batch imports), ORM mappings can have bugs, and database vendor differences create behavior that only direct testing reveals.
For each database constraint, write tests that exercise both the passing and failing side. For each query, test the NULL and empty-result partitions explicitly. For concurrent operations, test the boundary between success and conflict.