databases storage and caching
Database Test Scenarios And Fixtures
A fixture is known test data. Good fixtures are small, explicit, and tied to the behaviour being tested. A failing database test should point to the rule that broke, not to a huge shared data setup nobody understands.
A Small Fixture
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)');
$fixture = ['email' => 'sam@example.com'];
$stmt = $pdo->prepare('INSERT INTO users (email) VALUES (:email)');
$stmt->execute($fixture);
echo $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn() . PHP_EOL;
// Prints:
// 1
This fixture sets up exactly one user. That is enough for tests about looking up a user by email or rejecting duplicate email addresses.
What To Test At The Database Boundary
Useful database tests often cover:
- Insert and fetch behaviour for repositories.
- Required fields and unique constraints.
- Foreign key behaviour.
- Transaction rollback on failure.
- Query filters and sorting.
- Pagination and batch processing.
- Migrations creating the expected schema.
- Data backfills changing the right rows.
Do not use a database test when a pure function test would prove the same thing more cheaply. Use a database test when the database behaviour is part of the risk.
Testing Constraints
Constraints are worth testing because they protect production data even when PHP code changes later.
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)');
$insert = $pdo->prepare('INSERT INTO users (email) VALUES (:email)');
$insert->execute(['email' => 'sam@example.com']);
try {
$insert->execute(['email' => 'sam@example.com']);
echo 'duplicate allowed' . PHP_EOL;
} catch (PDOException) {
echo 'duplicate rejected' . PHP_EOL;
}
// Prints:
// duplicate rejected
This test proves the database, not just PHP validation, rejects duplicate emails.
Test Isolation
Each test should start from a known state. Common approaches include:
- Recreate an in-memory SQLite database per test.
- Wrap each test in a transaction and roll it back.
- Run migrations into a temporary test database.
- Truncate tables between tests.
- Use factories to create only the rows the test needs.
Avoid tests that pass only because another test created data first. Order-dependent tests are fragile.
SQLite vs Production Database
SQLite is useful for fast examples and many tests, but it is not identical to MySQL or PostgreSQL. If a feature depends on database-specific behaviour, test it against that database engine in CI or an integration environment.
Examples that may need production-engine tests include:
- PostgreSQL JSONB queries.
- MySQL-specific collation behaviour.
- Locking and deadlock handling.
- Complex migrations.
- Full-text search.
- Foreign key behaviour if SQLite pragmas are not enabled.
What To Check
Before moving on, make sure you can:
- Explain what a fixture is.
- Keep database fixtures small and focused.
- Test repository behaviour against real tables.
- Test constraints and transaction rollback.
- Keep tests isolated from each other.
- Decide when SQLite is enough and when the production database engine matters.
Practice
Practice: Test A User Repository Fixture
Write a self-contained PHP example that uses a database fixture to test user lookup behaviour.
Requirements
- Use SQLite in memory.
- Create a
userstable with a unique email column. - Insert a small fixture.
- Test the happy path: an existing email is found.
- Test the edge case: a missing email returns
null. - Test that a duplicate email is rejected by the database.
- Keep the fixture focused on this behaviour.
Show solution
This example uses plain assertions so it can run without installing PHPUnit. The same setup could be moved into a PHPUnit integration test.
<?php
declare(strict_types=1);
function createTestDatabase(): PDO
{
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)');
$pdo->prepare('INSERT INTO users (email) VALUES (:email)')
->execute(['email' => 'sam@example.com']);
return $pdo;
}
function findUserByEmail(PDO $pdo, string $email): ?array
{
$statement = $pdo->prepare('SELECT id, email FROM users WHERE email = :email');
$statement->execute(['email' => $email]);
$row = $statement->fetch();
return $row === false ? null : $row;
}
$pdo = createTestDatabase();
assert(findUserByEmail($pdo, 'sam@example.com')['email'] === 'sam@example.com');
assert(findUserByEmail($pdo, 'missing@example.com') === null);
try {
$pdo->prepare('INSERT INTO users (email) VALUES (:email)')
->execute(['email' => 'sam@example.com']);
echo 'duplicate allowed' . PHP_EOL;
} catch (PDOException) {
echo 'duplicate rejected' . PHP_EOL;
}
echo 'database fixture checks passed' . PHP_EOL;
// Prints:
// duplicate rejected
// database fixture checks passed
The fixture contains one user because that is all this behaviour needs. Larger shared fixtures would make the test harder to understand and debug.