Pengujian database

Trait RefreshDatabase dan penataan state dengan factory.

Daftar isi

Tes fitur menyentuh database, sehingga keadaan tabel yang ditinggalkan satu kasus dapat mengganggu kasus lain apabila tidak dikelola. Topik ini membahas trait RefreshDatabase yang menjaga skema dan keadaan database antar kasus, beserta factory untuk menata data uji secara deterministik. Seluruh contoh dijalankan pada aplikasi catatan dengan database SQLite dalam memori yang ditetapkan oleh phpunit.xml bawaan, sehingga database pengembangan tidak pernah tersentuh oleh tes.

Trait RefreshDatabase menjaga keadaan antar kasus#

Trait RefreshDatabase melakukan dua hal. Saat proses tes dimulai, trait ini menjalankan seluruh migration agar skema lengkap tersedia, lalu setiap kasus dibungkus dalam satu transaksi database yang di-rollback ketika kasusnya selesai. Akibatnya, setiap kasus fitur selalu dimulai dari tabel yang kosong dengan skema yang sama, tanpa biaya migrasi ulang pada setiap kasus. Konfigurasi database pengujian sudah mengarah ke SQLite dalam memori melalui DB_DATABASE bernilai :memory: pada phpunit.xml, sehingga skema dibangun di memori dan hilang bersama berakhirnya proses tes.

Mekanisme tersebut dapat dibuktikan dengan dua kasus sederhana. Kasus pertama menyiapkan tiga baris data, sedangkan kasus kedua menyatakan tabel masih kosong. Apabila rollback berjalan semestinya, kedua kasus lulus ketika dijalankan dalam satu eksekusi. Kelas berikut memuat empat kasus yang digunakan sepanjang topik ini, dan dua kasus pertama dijalankan terlebih dahulu.

bash
# menyiapkan kelas tes keadaan database lalu menjalankan dua kasus pertama
cd ~/aplikasi-catatan
cat > tests/Feature/KeadaanDatabaseTest.php <<'PHP'
<?php

namespace Tests\Feature;

use App\Models\Category;
use App\Models\Note;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class KeadaanDatabaseTest extends TestCase
{
    use RefreshDatabase;

    public function test_kasus_pertama_menemukan_tabel_kosong(): void
    {
        $this->assertSame(0, Note::count());

        $kategori = Category::factory()->create(['nama' => 'Kerja']);
        Note::factory()->count(3)->for($kategori)->create();

        $this->assertSame(3, Note::count());
    }

    public function test_kasus_kedua_mulai_dari_keadaan_bersih(): void
    {
        $this->assertSame(0, Note::count());
    }

    public function test_make_menghasilkan_instansi_tanpa_penyimpanan(): void
    {
        $catatan = Note::factory()->make(['judul' => 'Draf laporan']);

        $this->assertSame('Draf laporan', $catatan->judul);
        $this->assertNull($catatan->id);
        $this->assertSame(0, Note::count());
    }

    public function test_factory_menata_state_dan_relasi(): void
    {
        $kategori = Category::factory()->create(['nama' => 'Belajar']);

        Note::factory()
            ->count(2)
            ->for($kategori)
            ->create(['isi' => 'Menyusun ringkasan materi.']);

        $this->assertSame(2, $kategori->notes()->count());
        $this->assertSame('Belajar', Note::first()->category->nama);
        $this->assertSame('Menyusun ringkasan materi.', Note::first()->isi);
    }
}
PHP
php artisan test --filter="KeadaanDatabaseTest::test_kasus" | grep -E "PASS|Tests:"
text Keluaran
   PASS  Tests\Feature\KeadaanDatabaseTest
  Tests:    2 passed (3 assertions)

Baris Tests: menunjukkan dua kasus lulus dengan tiga asersi. Kasus pertama membaca Note::count() dua kali, yaitu nol sebelum data disiapkan dan tiga setelah tiga catatan dibuat melalui factory. Kasus kedua membaca hitungan yang sama dan memperoleh nilai nol, padahal kedua kasus dijalankan berurutan dalam satu proses. Nilai nol pada kasus kedua hanya mungkin apabila transaksi kasus pertama benar-benar di-rollback, sehingga keluaran ini sekaligus membuktikan mekanisme trait tersebut.

Pendekatan transaksi inilah yang menjaga suite tetap cepat. Skema dibangun satu kali di awal proses, sedangkan pengosongan antar kasus dilakukan melalui pembatalan transaksi yang jauh lebih murah daripada menghapus isi tabel satu per satu. Alternatif lain seperti trait DatabaseMigrations menjalankan migrasi ulang untuk setiap kasus, sehingga RefreshDatabase menjadi pilihan yang lazim untuk suite kasus fitur.

Siklus yang dijalankan RefreshDatabase untuk setiap kasus dirangkum pada diagram berikut.

Migrasisekali per proses tesMulai transaksiper kasusRollbacksetelah kasuskasus berikutnyaSiklus RefreshDatabase
Siklus migrasi dan transaksi pada RefreshDatabase

Menata data uji dengan factory#

Factory menghasilkan model dengan nilai default yang ditetapkan pada kelasnya, yaitu NoteFactory dan CategoryFactory di dalam direktori database/factories. Metode create() menyimpan model ke database, sedangkan make() hanya membentuk instansinya tanpa pernyataan INSERT. Atribut tertentu ditimpa dengan argumen array, dan count(n) mengulang pembuatan sebanyak n kali.

Penautan relasi juga ditangani factory. Karena Note berelasi belongsTo ke Category, pemanggilan Note::factory()->for($kategori) mengisi kolom category_id dari kunci induknya secara otomatis. Penautan serupa dapat dilakukan dari sisi induk melalui Category::factory()->has(Note::factory()->count(2)). Dua kasus berikutnya pada kelas yang sama menunjukkan perbedaan make() dan create() beserta penataan relasinya.

bash
# menjalankan kasus make dan kasus penataan relasi
cd ~/aplikasi-catatan
php artisan test --filter="test_make_menghasilkan_instansi_tanpa_penyimpanan|test_factory_menata_state_dan_relasi" | grep -E "PASS|Tests:"
text Keluaran
   PASS  Tests\Feature\KeadaanDatabaseTest
  Tests:    2 passed (6 assertions)

Kasus make() membuktikan bahwa instansi hasil factory tidak tersimpan, ditandai oleh properti id yang masih null dan hitungan tabel yang tetap nol. Kasus kedua menyimpan dua catatan dengan isi yang ditimpa, lalu membaca relasinya dari arah sebaliknya melalui $kategori->notes() dan dari arah anak melalui $catatan->category. Selain membaca hitungan melalui model, jumlah baris juga dapat diperiksa langsung pada tabelnya melalui $this->assertDatabaseCount('notes', 3). Kedua kasus lulus dengan nilai yang konsisten karena definisi factory pada aplikasi ini menetapkan nilai yang sama pada setiap pemanggilan, bukan nilai acak, sehingga asersinya dapat direproduksi.

Kesalahan umum#

Kesalahan pertama adalah menulis tes fitur tanpa RefreshDatabase. Database :memory: yang digunakan pengujian tidak berisi tabel apa pun sebelum migrasi dijalankan, sehingga pemanggilan pertama ke model berhenti dengan QueryException berupa pesan no such table: notes. Perbaikannya adalah menambahkan trait tersebut pada kelas tes fitur.

Kesalahan kedua adalah mengharapkan relasi terisi tanpa menyatakan induknya. Pemanggilan Note::factory()->create() tanpa for() menyimpan catatan dengan category_id bernilai null karena kolom itu memang boleh kosong pada migrasinya, sehingga $catatan->category juga null. Relasi dinyatakan melalui for($kategori) dari sisi anak, atau melalui has() dari sisi induk.

Kesalahan ketiga adalah mengandalkan data seed dari database pengembangan. Suite pengujian tidak membaca berkas SQLite yang digunakan saat pengembangan, sehingga data yang dibuat db:seed di luar tes tidak pernah dibaca oleh kasus apa pun. Data uji dibangun di dalam kasusnya sendiri melalui factory agar setiap kasus mandiri dan hasilnya sama pada setiap eksekusi.

Kesalahan keempat adalah menghapus pengaturan database pada phpunit.xml sehingga suite pengujian membaca koneksi dari .env proyek. Gejalanya data pengembangan ikut berubah setiap kali tes dijalankan, dan migration milik RefreshDatabase dapat menimpa skema yang sedang digunakan aplikasi. Baris <env name="DB_CONNECTION" value="sqlite"/> beserta nilai :memory: pada phpunit.xml tetap dipertahankan agar lingkungan pengujian sepenuhnya terpisah dari lingkungan pengembangan.

Ringkasan#

  • RefreshDatabase menjalankan migration sekali per proses tes, lalu membungkus setiap kasus dalam transaksi yang di-rollback, sehingga setiap kasus fitur dimulai dari skema lengkap dengan tabel kosong pada SQLite :memory:.
  • create() menyimpan hasil factory ke database, sedangkan make() tidak. Atribut ditimpa melalui argumen, count() mengulang pembuatan, dan for() menautkan relasi belongsTo dari sisi anak.
  • Data uji selalu dibangun di dalam kasusnya sendiri karena suite pengujian tidak membaca data database pengembangan.

Referensi#