Database adalah dependensi yang paling sering diintegrasikan ke dalam test — sekaligus yang paling sering disalahkan ketika test menjadi lambat dan rapuh. Repository yang isinya hanya memanggil prepare() dan execute() terlihat “sudah jelas benar” di atas kertas, padahal salah ketik nama kolom, tipe data yang bergeser, atau nilai bawaan yang berbeda antar mesin database hanya akan muncul ketika SQL-nya benar-benar dijalankan. Pada Chapter ini kita menguji repository secara sungguhan dengan SQLite, memakai database segar per test, fixture yang dapat diprediksi, assertion ketat atas hasil query, hingga pola transaction rollback sebagai alternatif. Pembahasan pengujian skema dan migrasi database itu sendiri sudah tersedia pada Section database modeling dan migrations .

Repository dengan Koneksi yang Disuntikkan #

Objek ujian kita adalah UserRepository dengan empat operasi dasar: menyimpan pengguna baru, mencari lewat email, menonaktifkan, dan menghitung pengguna aktif. Sesuai pelajaran Chapter integration testing , koneksi PDO disuntikkan lewat constructor:

php
<?php

declare(strict_types=1);

final class UserRepository
{
    public function __construct(
        private readonly PDO $connection,
    ) {
    }

    public function save(string $nama, string $email): int
    {
        $statement = $this->connection->prepare(
            'INSERT INTO pengguna (nama, email, aktif) VALUES (:nama, :email, 1)',
        );
        $statement->execute(['nama' => $nama, 'email' => $email]);

        return (int) $this->connection->lastInsertId();
    }

    public function findByEmail(string $email): ?array
    {
        $statement = $this->connection->prepare(
            'SELECT id, nama, email, aktif FROM pengguna WHERE email = :email',
        );
        $statement->execute(['email' => $email]);
        $baris = $statement->fetch();

        return $baris === false ? null : $baris;
    }

    public function deactivate(int $id): int
    {
        $statement = $this->connection->prepare(
            'UPDATE pengguna SET aktif = 0 WHERE id = :id',
        );
        $statement->execute(['id' => $id]);

        return $statement->rowCount();
    }

    public function countAktif(): int
    {
        return (int) $this->connection
            ->query('SELECT COUNT(*) FROM pengguna WHERE aktif = 1')
            ->fetchColumn();
    }
}

Setiap method mengembalikan nilai yang bisa ditegaskan: save() mengembalikan ID baru, deactivate() mengembalikan jumlah baris yang berubah, dan findByEmail() mengembalikan baris utuh atau null. Nilai-nilai balik inilah yang akan menjadi objek assertion — kita menegaskan perilaku yang teramati, bukan bentuk string SQL-nya.

Database Segar untuk Setiap Test #

Test berikut membuat koneksi sqlite::memory: baru beserta skemanya di setiap setUp(). Semua query yang dibutuhkan test ini dijalankan terhadap database yang benar-benar asli, hanya saja ia hidup di memori:

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class UserRepositoryTest extends TestCase
{
    private PDO $connection;

    protected function setUp(): void
    {
        $this->connection = new PDO('sqlite::memory:');
        $this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->connection->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
        $this->connection->exec(
            'CREATE TABLE pengguna (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                nama TEXT NOT NULL,
                email TEXT NOT NULL UNIQUE,
                aktif INTEGER NOT NULL DEFAULT 1
            )',
        );
    }

    public function testSaveMenyimpanDanMenemukanLewatEmail(): void
    {
        $repository = new UserRepository($this->connection);

        $id = $repository->save('Budi', '[email protected]');

        $this->assertSame(1, $id);
        $this->assertSame(
            ['id' => 1, 'nama' => 'Budi', 'email' => '[email protected]', 'aktif' => 1],
            $repository->findByEmail('[email protected]'),
        );
    }

    public function testFindByEmailMengembalikanNullUntukEmailTakDikenal(): void
    {
        $repository = new UserRepository($this->connection);

        $this->assertNull($repository->findByEmail('[email protected]'));
    }

    public function testDeactivateMenonaktifkanTepatSatuPengguna(): void
    {
        $repository = new UserRepository($this->connection);
        $repository->save('Budi', '[email protected]');
        $repository->save('Siti', '[email protected]');

        $barisTerubah = $repository->deactivate(1);

        $this->assertSame(1, $barisTerubah);
        $this->assertSame(1, $repository->countAktif());
    }

    public function testCountAktifMengabaikanPenggunaNonaktif(): void
    {
        $this->seedPengguna('Budi', '[email protected]');
        $this->seedPengguna('Siti', '[email protected]', 0);
        $this->seedPengguna('Andi', '[email protected]');

        $repository = new UserRepository($this->connection);

        $this->assertSame(2, $repository->countAktif());
    }

    private function seedPengguna(string $nama, string $email, int $aktif = 1): void
    {
        $statement = $this->connection->prepare(
            'INSERT INTO pengguna (nama, email, aktif) VALUES (:nama, :email, :aktif)',
        );
        $statement->execute(['nama' => $nama, 'email' => $email, 'aktif' => $aktif]);
    }
}

Ada tiga pengaturan penting di dalam setUp(). DSN sqlite::memory: menghidupkan database hanya selama koneksi itu hidup, PDO::ERRMODE_EXCEPTION mengubah kegagalan SQL menjadi exception yang menggagalkan test, dan CREATE TABLE dijalankan ulang setiap test sehingga tabel selalu kosong — kecuali data yang sengaja kita isi di dalam test itu sendiri.

Test pertama layak dibedah karena memuat dua pola assertion sekaligus. assertSame(1, $id) menegaskan bahwa auto-increment dimulai dari 1 pada database bersih, dan assertSame(array(...), $repository->findByEmail(...)) menegaskan seluruh isi baris, termasuk kolom aktif yang tidak pernah kita berikan secara eksplisit — inilah cara menangkap nilai DEFAULT yang keliru. Test ketiga memakai rowCount() untuk memastikan UPDATE tepat menyentuh satu baris, sebuah perlindungan murah terhadap klausa WHERE yang terlalu luas.

Seeding Fixture dengan Data yang Diketahui #

Empat test di atas menunjukkan dua cara menyiapkan data awal (fixture). Tiga test pertama menyemai lewat save() — praktis dan terbaca seperti alur nyata, tetapi datanya hanya sebatas kemampuan method tersebut. Test keempat memakai helper privat seedPengguna() yang menyuntik INSERT langsung, sehingga ia bisa membuat kondisi yang tidak mungkin (atau belum didukung) lewat repository — pada contoh ini, pengguna yang nonaktif sejak awal.

Menggunakan fixture SQL langsung punya manfaat tersembunyi: kesalahan pada save() tidak ikut merusak fase arrange. Jika save() ternyata keliru menulis kolom aktif, test yang menyemai lewat save() bisa gagal dengan pesan menyesatkan, sedangkan fixture SQL tetap menaruh data persis seperti yang ditulis. Kebalikannya juga benar — saat kita menguji save() itu sendiri, semaikan lewat SQL langsung supaya assertion findByEmail() tidak membandingkan save() dengan dirinya sendiri. Keduanya bukan soal selera, melainkan soal apa yang sedang diuji.

Menegaskan Hasil Query dengan Tegas #

Assertion database yang baik hampir selalu memakai assertSame ketimbang assertEquals. Alasannya adalah tipe data: PDO driver sqlite mengembalikan kolom INTEGER sebagai int, TEXT sebagai string, dan REAL sebagai float, sehingga assertEquals(1, $baris['aktif']) akan tetap lulus meskipun nilainya ternyata string '1' dari driver lain. Tiga pola berikut menutupi hampir semua kebutuhan:

  • Isi baris utuh — assertSame([...], $repository->findByEmail(...)) untuk memastikan setiap kolom berisi nilai dan tipe yang benar.
  • Jumlah baris lewat agregasi — (int) $connection->query('SELECT COUNT(*) ...')->fetchColumn() dipasangkan assertSame() ketika kita hanya peduli berapa banyak baris yang cocok.
  • Ketidakhadiran — assertNull() pada pencarian yang gagal, memastikan fetch() yang mengembalikan false benar-benar diterjemahkan menjadi null.

Untuk memeriksa data yang tidak diekspos repository, tidak ada yang salah dengan membaca langsung lewat koneksi di dalam test:

php
<?php

declare(strict_types=1);

// Di dalam test: periksa efek write yang tidak diekspos repository
$jumlah = (int) $this->connection
    ->query('SELECT COUNT(*) FROM pengguna WHERE aktif = 0')
    ->fetchColumn();

$this->assertSame(1, $jumlah);

Pola “assertion lewat query langsung” ini sah karena test tetap membaca keadaan database, bukan internal objek. Batasannya: jangan sampai test menegaskan detail yang tidak pernah dijanjikan repository, misalnya urutan baris tanpa klausa ORDER BY pada query yang diuji.

Pola Transaction Rollback #

Membuat koneksi dan skema baru di setiap test adalah paling sederhana, tetapi ada kalanya terlalu mahal — misalnya skema besar yang butuh waktu dibangun, atau database server sungguhan yang dipakai bergantian. Pola transaction rollback menyelesaikannya: skema dibangun sekali, setiap test berjalan di dalam transaksi, dan tearDown() membuang semua perubahannya. Perilaku dasarnya bisa dibuktikan dengan skrip kecil:

php
<?php

declare(strict_types=1);

$connection = new PDO('sqlite::memory:');
$connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$connection->exec(
    'CREATE TABLE pesanan (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        pelanggan TEXT NOT NULL,
        total REAL NOT NULL
    )',
);

$connection->beginTransaction();
$connection->exec("INSERT INTO pesanan (pelanggan, total) VALUES ('Budi', 150000)");
$connection->exec("INSERT INTO pesanan (pelanggan, total) VALUES ('Siti', 25000)");
echo 'Sebelum rollback: ', $connection->query('SELECT COUNT(*) FROM pesanan')->fetchColumn(), " pesanan\n";

$connection->rollBack();
echo 'Setelah rollback: ', $connection->query('SELECT COUNT(*) FROM pesanan')->fetchColumn(), " pesanan\n";

Output yang Dihasilkan:

text
Sebelum rollback: 2 pesanan
Setelah rollback: 0 pesanan

Dua baris yang ditulis setelah beginTransaction() menghilang tanpa jejak begitu rollBack() dipanggil. Versi lengkapnya dalam bentuk test memakai berkas SQLite sungguhan yang skemanya dibangun sekali di setUpBeforeClass():

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class OrderRepositoryTransactionTest extends TestCase
{
    private static PDO $connection;
    private static string $databaseFile;

    public static function setUpBeforeClass(): void
    {
        self::$databaseFile = tempnam(sys_get_temp_dir(), 'toko-') . '.sqlite';
        self::$connection = new PDO('sqlite:' . self::$databaseFile);
        self::$connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        self::$connection->exec(
            'CREATE TABLE pesanan (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                pelanggan TEXT NOT NULL,
                total REAL NOT NULL
            )',
        );
    }

    public static function tearDownAfterClass(): void
    {
        @unlink(self::$databaseFile);
    }

    protected function setUp(): void
    {
        self::$connection->beginTransaction();
    }

    protected function tearDown(): void
    {
        self::$connection->rollBack();
    }

    public function testPesananBaruTersimpan(): void
    {
        $statement = self::$connection->prepare(
            'INSERT INTO pesanan (pelanggan, total) VALUES (:pelanggan, :total)',
        );
        $statement->execute(['pelanggan' => 'Budi', 'total' => 150000.0]);

        $jumlah = (int) self::$connection
            ->query('SELECT COUNT(*) FROM pesanan')
            ->fetchColumn();

        $this->assertSame(1, $jumlah);
    }

    public function testRollbackMenjagaTestKeduaTidakMelihatTestPertama(): void
    {
        $jumlah = (int) self::$connection
            ->query('SELECT COUNT(*) FROM pesanan')
            ->fetchColumn();

        $this->assertSame(0, $jumlah);
    }
}

Test kedua adalah penjaga pola ini: ia sengaja tidak menulis apa pun dan menegaskan tabel kosong, sehingga akan langsung gagal jika rollback tidak bekerja dan data test pertama bocor. Kedua berkas test Chapter ini dijalankan bersama:

bash
php /home/z/my-project/bin/phpunit.phar --bootstrap bootstrap.php tests
text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

......                                                              6 / 6 (100%)

Time: 00:00.005, Memory: 28.05 MB

OK (6 tests, 8 assertions)

Menghindari State yang Dibagi Antar Test #

Musuh utama database testing adalah state yang menetes antar test. Gejalanya khas: test lulus saat dijalankan sendiri, tetapi gagal saat seluruh suite dijalankan — karena data dari test sebelumnya masih tergeletak di tabel. Sumber kebocoran paling umum adalah koneksi static tanpa rollback atau fixture global yang hanya diisi sekali. Contoh anti-paternnya:

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

// Anti-pattern: koneksi statis tanpa isolasi per test
final class SharedStateTest extends TestCase
{
    private static ?PDO $connection = null;

    protected function setUp(): void
    {
        if (self::$connection === null) {
            self::$connection = new PDO('sqlite::memory:');
            self::$connection->exec('CREATE TABLE log_aktivitas (aksi TEXT NOT NULL)');
        }
        // Tabel TIDAK pernah dibersihkan: baris menumpuk antar test
        self::$connection->exec("INSERT INTO log_aktivitas (aksi) VALUES ('set-up')");
    }
}

Class di atas hanya membuat koneksi sekali, sehingga test kedua menjumpai sisa baris dari test pertama dan hasilnya bergantung pada urutan eksekusi. Dua pola Chapter ini kebal dari masalah itu lewat mekanisme berbeda: :memory: per test menghapus dunia sekaligus bersama koneksinya, sedangkan rollback mengembalikan dunia ke keadaan awal. Pilih salah satu secara konsisten dalam satu test class, dan waspadai juga static lain seperti counter atau cache hasil query yang diam-diam menyimpan keadaan.

Apa yang Perlu Diuji dari Repository #

Terakhir, mari tegaskan batas-batasnya. Yang diuji adalah kontrak repository: “operasi ini menyimpan data ini”, “pencarian ini mengembalikan baris ini atau null”, “update ini menyentuh sebanyak ini baris”. Yang tidak perlu diuji adalah bentuk persis SQL-nya — menegaskan string query membuat test pecah karena refactor kosmetik yang tidak mengubah perilaku. Fokuskan tiap test pada satu perilaku, gunakan fixture seminimal yang masih merepresentasikan skenarionya, dan biarkan detail skema — apakah kolom boleh NULL, apa default-nya, bagaimana migrasinya — diuji oleh pengujian skema khusus yang dibahas pada Section database modeling dan migrations .

Ringkasan #

Database testing yang sehat berdiri di atas tiga pilar: koneksi tersuntikkan sebagai seam, database segar per test (:memory: untuk kesederhanaan, transaction rollback saat skema mahal dibangun), dan assertion ketat assertSame atas hasil query berikut tipenya. Fixture disemai sesuai kebutuhan test — lewat repository saat menguji pencarian, lewat SQL langsung saat menguji penulisan. Dengan pola ini, test database tetap cepat, deterministik, dan bebas dari kebocoran state. Setelah lapisan data teruji, Chapter berikutnya naik satu lapisan: menguji HTTP handler tanpa harus menjalankan web server sungguhan.

Referensi #