Migration adalah kode, dan kode apa pun bisa salah: kolom yang lupa NOT NULL, foreign key yang menunjuk tabel keliru, skrip down yang tidak benar-benar membalikkan up. Kesalahan seperti ini paling murah ditemukan di laptop — bukan di tengah deploy produksi saat CREATE TABLE gagal setengah jalan. Kabar baiknya, menguji schema tidak butuh framework: cukup database segar, query ke katalog schema, dan assertion sederhana. Pada chapter ini kita memakai SQLite in-memory untuk membangun pengujian migration dan constraint dari nol, dengan sudut pandang perancang database.
Database Baru untuk Setiap Pengujian #
Aturan pertama pengujian database: setiap test dimulai dari keadaan yang sama persis. Berbagi satu database antar test membuat test kedua mewarisi sisa test pertama — tabel yang sudah ada, baris yang belum dibersihkan — dan kegagalan jadi sulit dilacak. Dengan sqlite::memory:, database segar harganya nyaris nol: satu koneksi baru berarti satu database kosong yang hidup hanya di memori:
<?php
function freshDb(): PDO
{
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
return $pdo;
}
$tests = [];
$tests["tabel users tercipta"] = function (): void {
$pdo = freshDb();
$pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)");
$count = $pdo->query(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'users'"
)->fetchColumn();
if ((int) $count !== 1) {
throw new RuntimeException("tabel users tidak ditemukan");
}
};
$tests["database selalu dimulai kosong"] = function (): void {
$pdo = freshDb();
$count = $pdo->query(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
)->fetchColumn();
if ((int) $count !== 0) {
throw new RuntimeException("database tidak kosong");
}
};
$gagal = 0;
foreach ($tests as $nama => $test) {
try {
$test();
echo "LULUS: $nama\n";
} catch (Throwable $e) {
$gagal++;
echo "GAGAL: $nama - {$e->getMessage()}\n";
}
}
exit($gagal === 0 ? 0 : 1);Output yang Dihasilkan:
LULUS: tabel users tercipta
LULUS: database selalu dimulai kosongKerangkanya sederhana namun memuat tiga keputusan penting. Pertama, freshDb() dipanggil di dalam setiap test, bukan sekali di atas — tiap test menerima database pribadi yang tidak mungkin tercemar test lain. Kedua, setiap test adalah closure yang melempar RuntimeException saat harapan dilanggar, sehingga kegagalan terbawa keluar sebagai exception. Ketiga, exit() di akhir menyebarkan hasil ke exit code: nol bila semua lulus, satu bila ada yang gagal — inilah sinyal yang dibaca oleh continuous integration untuk menandai build gagal.
Menguji Struktur Schema #
Test pertama kita memeriksa keberadaan tabel lewat sqlite_master. Untuk memeriksa isi tabel — kolom apa saja, apa tipe dan batasannya — SQLite menyediakan PRAGMA table_info() yang mengembalikan satu baris per kolom lengkap dengan flag notnull:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL
)");
function columnNames(PDO $pdo, string $table): array
{
$names = [];
foreach ($pdo->query("PRAGMA table_info($table)") as $col) {
$names[] = $col["name"];
}
return $names;
}
$kolom = columnNames($pdo, "users");
echo "kolom users: " . implode(", ", $kolom) . "\n";
$harapan = ["id", "email", "nama"];
echo "schema sesuai harapan: " . var_export($kolom === $harapan, true) . "\n";
foreach ($pdo->query("PRAGMA table_info(users)") as $col) {
if ($col["name"] === "email") {
echo "email NOT NULL: " . var_export((int) $col["notnull"] === 1, true) . "\n";
}
}Output yang Dihasilkan:
kolom users: id, email, nama
schema sesuai harapan: true
email NOT NULL: trueAssertion pertama membandingkan daftar kolom dengan harapan menggunakan perbandingan ketat === — urutan kolom ikut diperiksa, sehingga penambahan kolom di tengah tabel (yang secara semantik tidak masalah untuk INSERT eksplisit) tetap terdeteksi dan ditinjau. Assertion kedua memeriksa flag notnull milik email: nilainya 1 bila kolom wajib diisi, 0 bila boleh kosong. Pengujian seperti ini menangkap kelalaian klasik — kolom yang seharusnya NOT NULL tapi lupa ditulis — yang biasanya baru terasa saat data kotor mulai masuk produksi.
Menguji Migration Bolak-balik #
Chapter Migration Rollback
menegaskan bahwa skrip down harus menjadi kebalikan up. Klaim semacam itu tidak perlu dipercaya begitu saja — bisa diuji. Terapkan seluruh migration, batalkan semuanya dari belakang, lalu bandingkan isi sqlite_master dengan keadaan awal:
<?php
function userTables(PDO $pdo): array
{
$names = [];
foreach ($pdo->query(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
) as $row) {
$names[] = $row["name"];
}
return $names;
}
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$migrations = [
"2026_01_15_000001_create_users" => [
"up" => fn (PDO $pdo) => $pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)"),
"down" => fn (PDO $pdo) => $pdo->exec("DROP TABLE users"),
],
"2026_01_15_000002_create_articles" => [
"up" => fn (PDO $pdo) => $pdo->exec("CREATE TABLE articles (id INTEGER PRIMARY KEY, judul TEXT NOT NULL, penulis_id INTEGER NOT NULL REFERENCES users(id))"),
"down" => fn (PDO $pdo) => $pdo->exec("DROP TABLE articles"),
],
];
$sebelum = userTables($pdo);
foreach ($migrations as $m) {
$m["up"]($pdo);
}
echo "setelah up : [" . implode(", ", userTables($pdo)) . "]\n";
foreach (array_reverse($migrations) as $m) {
$m["down"]($pdo);
}
$sesudah = userTables($pdo);
echo "setelah down: [" . implode(", ", $sesudah) . "]\n";
echo "roundtrip bersih: " . var_export($sebelum === $sesudah, true) . "\n";Output yang Dihasilkan:
setelah up : [articles, users]
setelah down: []
roundtrip bersih: trueUrutan pengerjaannya meniru migrator sungguhan: up berjalan sesuai urutan versi, down berjalan terbalik lewat array_reverse(), dan daftar tabel diperiksa di tiga titik — sebelum, sesudah naik, dan sesudah turun. Hasil akhir true berarti siklus penuh meninggalkan database persis seperti semula. Jika suatu saat down lupa menjatuhkan tabel atau index, assertion ini langsung bernilai false — dan Anda mengetahuinya sebelum rekan setim menghabiskan satu jam bingung di mesinnya.
Menguji Constraint Bekerja #
Schema yang benar tidak hanya berbentuk benar, tetapi juga menolak data yang salah. Constraint adalah penjaga itu, dan pengujian kebalikannya sederhana: coba masukkan data ilegal, lalu assertion kita berharap PDO melempar PDOException:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE produk (
id INTEGER PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
stok INTEGER NOT NULL CHECK (stok >= 0)
)");
function cekGagal(callable $fn): bool
{
try {
$fn();
return false;
} catch (PDOException) {
return true;
}
}
$hasil = [
["NOT NULL menolak nilai kosong", cekGagal(function () use ($pdo): void {
$pdo->exec("INSERT INTO produk (sku, stok) VALUES (NULL, 5)");
})],
["UNIQUE menolak duplikat sku", cekGagal(function () use ($pdo): void {
$pdo->exec("INSERT INTO produk (sku, stok) VALUES ('SKU-1', 5)");
$pdo->exec("INSERT INTO produk (sku, stok) VALUES ('SKU-1', 6)");
})],
["CHECK menolak stok negatif", cekGagal(function () use ($pdo): void {
$pdo->exec("INSERT INTO produk (sku, stok) VALUES ('SKU-2', -1)");
})],
];
foreach ($hasil as [$nama, $lulus]) {
echo ($lulus ? "LULUS" : "GAGAL") . ": $nama\n";
}Output yang Dihasilkan:
LULUS: NOT NULL menolak nilai kosong
LULUS: UNIQUE menolak duplikat sku
LULUS: CHECK menolak stok negatifHelper cekGagal() membalik logika pengujian biasa: lulus justru ketika INSERT melempar exception, dan gagal ketika data ilegal diterima dengan tenang. Perhatikan juga test UNIQUE: baris pertama (SKU-1) sengaja berhasil disisipkan dulu — yang diuji adalah baris kedua yang menabraknya. Tiga hasil LULUS di atas membuktikan NOT NULL, UNIQUE, dan CHECK semua aktif dan menyaring data sesuai rancangan.
Foreign key pun layak mendapat perlakuan sama, dengan satu jebakan khas SQLite:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY)");
$pdo->exec("CREATE TABLE pesanan (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id)
)");
// Koneksi SQLite baru: penegakan foreign key NONAKTIF secara default
$pdo->exec("INSERT INTO pesanan (user_id) VALUES (999)");
echo "tanpa pragma : user_id 999 lolos tanpa peringatan\n";
// Aktifkan penegakan foreign key untuk koneksi ini
$pdo->exec("PRAGMA foreign_keys = ON");
try {
$pdo->exec("INSERT INTO pesanan (user_id) VALUES (999)");
echo "dengan pragma: lolos\n";
} catch (PDOException $e) {
echo "dengan pragma: ditolak - " . $e->getMessage() . "\n";
}Output yang Dihasilkan:
tanpa pragma : user_id 999 lolos tanpa peringatan
dengan pragma: ditolak - SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failedInsert pertama membuktikan masalahnya: user_id 999 tidak ada di tabel users, tetapi SQLite menerima saja — karena penegakan foreign key nonaktif secara default. Baris PRAGMA foreign_keys = ON menyalakannya, dan insert kedua langsung ditolak dengan FOREIGN KEY constraint failed. Tanpa pengujian ini, schema Anda mungkin terlihat punya foreign key padahal relasi rusak diam-diam.
Keterbatasan dan Langkah Lanjut #
Pendekatan pada chapter ini sengaja tanpa dependensi: assertion ditulis dengan if dan throw, output berupa baris LULUS/GAGAL, dan seluruhnya berjalan di SQLite in-memory. Untuk puluhan test, kerangka seperti ini sudah memadai; untuk ratusan, Anda ingin pelaporan otomatis, filter test, dan integrasi CI — di titik itu beralih ke PHPUnit, yang memberikan assertTrue(), assertEquals(), dan rangkaian setUp()/tearDown() sebagai pengganti kerangka buatan kita.
Kedua, SQLite bukan tiruan sempurna untuk database produksi. Kita sudah melihat satu bedanya (foreign key default), dan akan menemui lainnya saat schema makin kompleks — misalnya ALTER TABLE SQLite yang terbatas, yang memaksa pola table rebuild pada chapter Migration Rollback
. Bila produksi memakai MySQL atau PostgreSQL, jalankan pengujian schema yang sama terhadap engine tersebut di pipeline CI, sambil tetap memakai SQLite in-memory untuk pengujian cepat sehari-hari.
Ringkasan #
Pengujian database dari sudut pandang perancang schema punya empat pilar: database segar per test lewat sqlite::memory:, assertion struktur lewat sqlite_master dan PRAGMA table_info, roundtrip migration yang membuktikan down kebalikan up, dan test constraint yang memastikan data ilegal ditolak. Sejauh ini seluruh perhatian kita tertuju pada database — struktur maupun isinya. Section berikutnya mengangkat pertanyaan yang lebih luas: bagaimana menata kode aplikasi itu sendiri, dimulai dari prinsip paling dasarnya pada chapter Separation of Concerns
.