Seluruh fitur aplikasi kontak sudah berdiri: schema dengan constraint, repository dengan sepuluh method, dan validator dengan kantong galat. Selama ini kita membuktikannya dengan menjalankan snippet satu per satu — cara yang jujur, tetapi lambat dan mudah terlupakan begitu kode mulai berubah. Pada bagian penutup ini kita menggantinya dengan test otomatis: tiga belas test PHPUnit yang menjalankan seluruh perilaku repository di atas database :memory: yang segar untuk setiap test, ditata dalam proyek yang punya composer.json layaknya paket PHP sungguhan. Di akhir bagian, proyek kontak kita rangkum utuh.
Dari Uji Manual ke Test Otomatis #
Uji manual dengan menjalankan snippet punya tiga kelemahan yang saling memperburuk. Pertama, ia lambat: membuktikan lima belas perilaku berarti membaca lima belas output dengan mata. Kedua, ia tidak bisa diulang persis: setelah kode repository berubah, tidak ada jaminan semua snippet dicek ulang dengan disiplin yang sama. Ketiga, ia tidak berteriak: kesalahan yang tidak tercetak tidak akan terlihat, dan regresi kecil lolos begitu saja. Test otomatis membalik ketiganya — pemeriksaan berjalan dalam hitungan milidetik, diulang dengan satu perintah, dan berhenti dengan exit code yang jelas begitu satu saja perilaku dilanggar.
Target test kita adalah ContactRepository — lapisan data tempat seluruh SQL hidup. Validator sengaja tidak ikut suite ini karena logikanya murni dan cukup diuji terpisah, sementara schema dan constraint justru teruji tidak langsung setiap kali repository menyimpan dan membaca data. Strategi menguji lapisan data dengan database nyata yang ringan sudah pernah dibahas pada chapter Database Testing
; di sini kita mempraktikkannya penuh.
Menata Proyek dengan composer.json #
Sebelum menulis test, proyek diberi berkas identitas standar ekosistem PHP. Berikut composer.json pada bundle bagian ini:
{
"name": "kursus/aplikasi-kontak",
"description": "Aplikasi kontak berbasis SQLite: proyek Course PHP.",
"type": "project",
"license": "MIT",
"require": {
"php": ">=8.1",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^12.0"
},
"autoload": {
"psr-4": {
"Kursus\\Kontak\\": "src/"
}
},
"scripts": {
"test": "phpunit --no-progress tests"
}
}Peran setiap bagian berkas tersebut:
namedandescriptionmengidentifikasi paket dalam formatvendor/nama— alamat yang akan dipakai bila proyek ini kelak dipublikasikan ke Packagist.requiremendeklarasikan kebutuhan runtime: PHP 8.1 ke atas serta ekstensipdodanpdo_sqlite. Composer akan menolak instalasi di lingkungan yang tidak memenuhinya.require-devmenampung alat yang hanya dibutuhkan saat mengembangkan — PHPUnit masuk di sini, bukan direquire, karena server produksi tidak perlu menjalankan test.autoloaddengan PSR-4 memetakan namespaceKursus\Kontak\ke foldersrc/sehingga class dimuat otomatis tanparequiremanual.scripts.testmendefinisikan pintu satu perintah:composer testmenjalankan seluruh suite.
Satu hal perlu dijelaskan dengan jujur: pada bundel course ini, class tidak hidup di folder src/, melainkan tersebar di folder tiap bagian agar setiap halaman mandiri. Karena itu mapping PSR-4 di atas bersifat ilustratif untuk tata letak proyek nyata — pada proyek sungguhan, ContactRepository.php akan pindah ke src/ContactRepository.php dengan deklarasi namespace Kursus\Kontak;, dan seluruh require digantikan sekali composer dump-autoload. Test pada bundle ini tetap memuat class lewat path relatif, dan berkasnya tetap lolos pemeriksaan Composer:
php composer.phar validate composer.jsoncomposer.json is validMenulis ContactRepositoryTest #
Satu berkas test berisi class ContactRepositoryTest dengan tiga belas method test*. Berikut kerangkanya beserta beberapa test perwakilan; berkas lengkap tersedia di tests/ContactRepositoryTest.php pada bagian bawah halaman:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
// Suite ini menguji ContactRepository versi final (Bagian 3): operasi
// CRUD dari Bagian 2 plus pencarian, filter, dan paginasi dari Bagian 3.
// Berkas class tidak berada di folder ini, melainkan pada bundle Bagian 3,
// sehingga dimuat lewat path relatif antar bagian proyek.
require_once __DIR__ . '/../../bagian-3-pencarian-dan-filtering/ContactRepository.php';
final class ContactRepositoryTest extends TestCase
{
private PDO $pdo;
private ContactRepository $repository;
protected function setUp(): void
{
// Database :memory: baru untuk setiap test: terisolasi, cepat,
// dan musnah sendiri saat koneksi ditutup.
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec('PRAGMA foreign_keys = ON');
$this->pdo->exec(
file_get_contents(__DIR__ . '/../../bagian-1-desain-data/schema.sql')
);
$this->repository = new ContactRepository($this->pdo);
}
public function testSemuaMengembalikanEnamDataAwal(): void
{
$semua = $this->repository->semua();
$this->assertCount(6, $semua);
$this->assertSame('Andi Wijaya', $semua[0]['nama']);
$this->assertSame('Siti Nurhaliza', $semua[5]['nama']);
}
public function testSimpanMengembalikanIdDanMenyimpanData(): void
{
$id = $this->repository->simpan('Eko Prasetyo', '[email protected]', '081399998888');
$this->assertSame(7, $id);
$kontak = $this->repository->cariId($id);
$this->assertSame('Eko Prasetyo', $kontak['nama']);
$this->assertSame('[email protected]', $kontak['email']);
$this->assertSame('081399998888', $kontak['telepon']);
}
public function testSimpanEmailDuplikatDitolakDatabase(): void
{
try {
$this->repository->simpan('Budi Kedua', '[email protected]', '081111122222');
$this->fail('Email duplikat seharusnya memicu PDOException.');
} catch (PDOException $e) {
$this->assertSame('23000', $e->getCode());
$this->assertStringContainsString(
'UNIQUE constraint failed: contacts.email',
$e->getMessage()
);
}
}
public function testCariMengescapeWildcardLike(): void
{
$this->repository->simpan('Bayu Promo 50rb', '[email protected]', '081277700011');
$this->repository->simpan('Rani Diskon 50%', '[email protected]', '081377700022');
$hasilPersen = $this->repository->cari('50%');
$this->assertCount(1, $hasilPersen);
$this->assertSame('Rani Diskon 50%', $hasilPersen[0]['nama']);
$this->assertCount(0, $this->repository->cari('b_d'));
}
// Delapan test lainnya berpola sama: perbarui, hapus, id tak dikenal,
// cari tanpa peduli kapital, filter domain, cariEmail, hitung, dan
// paginasi. Berkas lengkapnya ada di bundle bagian ini.
}Empat keputusan pada berkas tersebut menjadi inti suite ini:
setUp()membangun dunia baru untuk setiap test. PHPUnit memanggil method ini sebelum setiap methodtest*, sehingga ketiga belas test berjalan pada:memory:masing-masing — data awal selalu enam, id selalu mulai dari 7, dan tidak ada test yang mewarisi sisa kerusakan test lain. Inilah makna terisolasi pada praktiknya.require_oncedengan path relatif memuatContactRepository.phpfinal dari Bagian 3 — folder bagian memang bersaudara di bawahaplikasi-kontak/, jadi__DIR__ . '/../../bagian-3-pencarian-dan-filtering/...'menyeberang daritests/ke sana.assertSame()dipilih sebagai assertion utama karena membandingkan nilai sekaligus tipe: id7integer tidak boleh lolos sebagai string'7'.assertCount()memeriksa jumlah hasil, danassertStringContainsString()memastikan pesan exception memuat klausa constraint yang diharapkan.- Pola
try/catch+$this->fail()pada test duplikat memungkinkan satu method memeriksa tiga hal sekaligus: exception memang terlempar, kodenya23000, dan pesannya menyebut constraint email. Bilasimpan()ternyata sukses,fail()menggagalkan test dengan pesan yang jelas.
Penamaan test-nya juga bagian dari desain: testSimpanEmailDuplikatDitolakDatabase membaca seperti daftar kontrak perilaku repository, dan ketika salah satu gagal, namanya sendiri yang menunjuk lokasi masalah — kebiasaan yang dibahas tuntas pada chapter Menulis PHPUnit Tests
.
Rasa Test dalam PHP Polos #
Sebelum menjalankan PHPUnit, mari rasakan bahwa test hanyalah kode PHP biasa: bandingkan hasil dengan harapan, lalu berteriak bila tidak cocok. Harness mini berikut meniru inti dari empat test pada suite menggunakan if dan echo:
<?php
require_once __DIR__ . '/../bagian-3-pencarian-dan-filtering/ContactRepository.php';
function buatRepositoryBaru(): ContactRepository
{
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));
return new ContactRepository($pdo);
}
function cek(bool $kondisi, string $nama): void
{
echo $kondisi ? 'LULUS' : 'GAGAL';
echo " - $nama\n";
}
$repo = buatRepositoryBaru();
cek($repo->hitung() === 6, 'data awal berjumlah enam kontak');
$id = $repo->simpan('Eko Prasetyo', '[email protected]', '081399998888');
cek($id === 7 && $repo->cariId($id)['nama'] === 'Eko Prasetyo', 'simpan lalu cariId menemukan kontak');
cek($repo->cariId(999) === null, 'cariId yang tidak dikenal mengembalikan null');
$repo->perbarui($id, 'Eko Purnomo', '[email protected]', '081399998888');
cek($repo->cariId($id)['nama'] === 'Eko Purnomo', 'perbarui mengganti nama kontak');
$repo->hapus($id);
cek($repo->cariId($id) === null && $repo->hitung() === 6, 'hapus menghilangkan kontak');
cek(count($repo->cari('sit')) === 1, 'cari menemukan nama tanpa peduli kapital');
try {
$repo->simpan('Budi Kedua', '[email protected]', '081111122222');
cek(false, 'email duplikat ditolak database');
} catch (PDOException $e) {
cek($e->getCode() === '23000', 'email duplikat ditolak database');
}Output yang Dihasilkan:
LULUS - data awal berjumlah enam kontak
LULUS - simpan lalu cariId menemukan kontak
LULUS - cariId yang tidak dikenal mengembalikan null
LULUS - perbarui mengganti nama kontak
LULUS - hapus menghilangkan kontak
LULUS - cari menemukan nama tanpa peduli kapital
LULUS - email duplikat ditolak databaseFungsi cek() adalah assertion polos: parameter pertama kondisi, parameter kedua nama perilaku, dan hasilnya baris LULUS atau GAGAL yang terbaca tanpa alat bantu apa pun. Fungsi buatRepositoryBaru() menggantikan setUp() — dipanggil sekali di sini karena harness berjalan sekuensial, padahal filosofi setUp() adalah memanggilnya sebelum setiap pemeriksaan. Tujuh baris LULUS pada output itu setara dengan tujuh test hijau; PHPUnit hanya membungkus pola yang sama dengan laporan terstruktur, exit code, dan ribuan fitur lain.
Menjalankan Suite dengan PHPUnit #
Saatnya versi sungguhannya. Dari folder bagian-5-testing-dan-finalisasi, arahkan PHPUnit ke berkas test:
php phpunit.phar --no-progress tests/ContactRepositoryTest.phpKami menjalankan perintah tersebut pada lingkungan bagian ini (PHP 8.4.23, PHPUnit 12.5.39) dan berikut output lengkapnya:
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
Time: 00:00.009, Memory: 28.05 MB
OK (13 tests, 33 assertions)Baris OK (13 tests, 33 assertions) adalah ringkasan yang paling sering Anda baca: tiga belas test dijalankan, tiga puluh tiga assertion dievaluasi, semuanya lulus. Angka Time dan Memory bervariasi antar mesin dan antar jalan, jadi jangan dikhawatirkan; opsi --no-progress kita pakai agar output stabil tanpa animasi progress bar. Pada proyek dengan composer.json, perintah yang sama juga bisa dijalankan lewat pintu composer test setelah composer install, sebagaimana dibahas pada chapter Menginstal PHPUnit
. Berbeda dengan harness polos, PHPUnit mengembalikan exit code 0 saat lulus dan 1 saat ada kegagalan — angka inilah yang dibaca pipeline CI agar commit yang merusak perilaku langsung tertahan.
Apa yang Berubah #
Proyek kontak kini memiliki lapisan kepercayaan dan identitas paket:
- Berkas
composer.jsonbaru mendeklarasikan kebutuhan runtime, dependency pengembangan, autoload PSR-4 ilustratif, dan pintu perintahcomposer test. - Berkas
tests/ContactRepositoryTest.phpbaru berisi tiga belas test yang menyelimuti CRUD, pencarian dengan escaping, filter domain, pencarian email, paginasi, dan penolakan duplikat — masing-masing pada database:memory:sendiri. - Struktur final proyek selesai dan setiap berkasnya pernah menjadi bintang pada satu bagian:
aplikasi-kontak/
├── bagian-1-desain-data/
│ └── schema.sql
├── bagian-2-operasi-kontak/
│ └── ContactRepository.php
├── bagian-3-pencarian-dan-filtering/
│ └── ContactRepository.php
├── bagian-4-validasi-dan-integritas/
│ └── ContactValidator.php
└── bagian-5-testing-dan-finalisasi/
├── composer.json
└── tests/
└── ContactRepositoryTest.phpRingkasan #
Perjalanan lima bagian ini memindahkan aplikasi kontak dari nol menjadi proyek berlapis yang bisa dipercaya. Bagian 1 menerjemahkan kebutuhan menjadi schema dengan constraint dan data awal yang reproducible; Bagian 2 memusatkan CRUD ke repository dengan PDO yang disuntikkan; Bagian 3 menambah pencarian, filter, dan paginasi dengan escaping yang benar; Bagian 4 membangun validasi berlapis hingga penerjemahan kode 23000; dan bagian ini menutupnya dengan test otomatis yang menjalankan semuanya dalam hitungan milidetik. Pola yang kita pakai — schema dulu, repository sebagai satu pintu data, validasi di depan, constraint di belakang, test sebagai penjaga — adalah pola yang sama pada aplikasi jauh lebih besar. Keterampilan itu akan langsung terpakai pada proyek berikutnya: Website Blog
, tempat satu database menampung banyak tabel yang saling berelasi.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
composer.json
Unduh berkas{
"name": "kursus/aplikasi-kontak",
"description": "Aplikasi kontak berbasis SQLite: proyek Course PHP.",
"type": "project",
"license": "MIT",
"require": {
"php": ">=8.1",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^12.0"
},
"autoload": {
"psr-4": {
"Kursus\\Kontak\\": "src/"
}
},
"scripts": {
"test": "phpunit --no-progress tests"
}
}
tests/ContactRepositoryTest.php
Unduh berkas<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
// Suite ini menguji ContactRepository versi final (Bagian 3): operasi
// CRUD dari Bagian 2 plus pencarian, filter, dan paginasi dari Bagian 3.
// Berkas class tidak berada di folder ini, melainkan pada bundle Bagian 3,
// sehingga dimuat lewat path relatif antar bagian proyek.
require_once __DIR__ . '/../../bagian-3-pencarian-dan-filtering/ContactRepository.php';
final class ContactRepositoryTest extends TestCase
{
private PDO $pdo;
private ContactRepository $repository;
protected function setUp(): void
{
// Database :memory: baru untuk setiap test: terisolasi, cepat,
// dan musnah sendiri saat koneksi ditutup.
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec('PRAGMA foreign_keys = ON');
$this->pdo->exec(
file_get_contents(__DIR__ . '/../../bagian-1-desain-data/schema.sql')
);
$this->repository = new ContactRepository($this->pdo);
}
public function testSemuaMengembalikanEnamDataAwal(): void
{
$semua = $this->repository->semua();
$this->assertCount(6, $semua);
$this->assertSame('Andi Wijaya', $semua[0]['nama']);
$this->assertSame('Siti Nurhaliza', $semua[5]['nama']);
}
public function testSimpanMengembalikanIdDanMenyimpanData(): void
{
$id = $this->repository->simpan('Eko Prasetyo', '[email protected]', '081399998888');
$this->assertSame(7, $id);
$kontak = $this->repository->cariId($id);
$this->assertSame('Eko Prasetyo', $kontak['nama']);
$this->assertSame('[email protected]', $kontak['email']);
$this->assertSame('081399998888', $kontak['telepon']);
}
public function testCariIdTidakDikenalMengembalikanNull(): void
{
$this->assertNull($this->repository->cariId(999));
}
public function testPerbaruiMenggantiDataKontak(): void
{
$berhasil = $this->repository->perbarui(
2,
'Siti Nurhaliza Putri',
'[email protected]',
'082198765001'
);
$this->assertTrue($berhasil);
$kontak = $this->repository->cariId(2);
$this->assertSame('Siti Nurhaliza Putri', $kontak['nama']);
$this->assertSame('082198765001', $kontak['telepon']);
}
public function testPerbaruiIdTidakDikenalMengembalikanFalse(): void
{
$this->assertFalse(
$this->repository->perbarui(999, 'X', '[email protected]', '081200000000')
);
}
public function testHapusMenghilangkanKontak(): void
{
$this->assertTrue($this->repository->hapus(5));
$this->assertNull($this->repository->cariId(5));
$this->assertSame(5, $this->repository->hitung());
}
public function testHapusIdTidakDikenalMengembalikanFalse(): void
{
$this->assertFalse($this->repository->hapus(999));
}
public function testSimpanEmailDuplikatDitolakDatabase(): void
{
try {
$this->repository->simpan('Budi Kedua', '[email protected]', '081111122222');
$this->fail('Email duplikat seharusnya memicu PDOException.');
} catch (PDOException $e) {
$this->assertSame('23000', $e->getCode());
$this->assertStringContainsString(
'UNIQUE constraint failed: contacts.email',
$e->getMessage()
);
}
}
public function testCariMenemukanNamaTanpaPeduliKapital(): void
{
$hasil = $this->repository->cari('SIT');
$this->assertCount(1, $hasil);
$this->assertSame('Siti Nurhaliza', $hasil[0]['nama']);
}
public function testCariMengescapeWildcardLike(): void
{
$this->repository->simpan('Bayu Promo 50rb', '[email protected]', '081277700011');
$this->repository->simpan('Rani Diskon 50%', '[email protected]', '081377700022');
$hasilPersen = $this->repository->cari('50%');
$this->assertCount(1, $hasilPersen);
$this->assertSame('Rani Diskon 50%', $hasilPersen[0]['nama']);
$this->assertCount(0, $this->repository->cari('b_d'));
}
public function testFilterByDomainMemisahkanDomain(): void
{
$this->repository->simpan('Eko Prasetyo', '[email protected]', '081399998888');
$this->repository->simpan('Fitri Handayani', '[email protected]', '085699997777');
$this->assertCount(6, $this->repository->filterByDomain('example.com'));
$this->assertCount(2, $this->repository->filterByDomain('kantor.example.com'));
$this->assertCount(0, $this->repository->filterByDomain('gmail.com'));
}
public function testCariEmailMengembalikanKontakPersis(): void
{
$budi = $this->repository->cariEmail('[email protected]');
$this->assertSame(1, $budi['id']);
$this->assertSame('Budi Santoso', $budi['nama']);
$this->assertNull($this->repository->cariEmail('[email protected]'));
}
public function testHitungDanHalamanMembagiDaftar(): void
{
$this->assertSame(6, $this->repository->hitung());
$halaman1 = $this->repository->halaman(2, 0);
$this->assertSame(['Andi Wijaya', 'Budi Santoso'], array_column($halaman1, 'nama'));
$halaman3 = $this->repository->halaman(2, 4);
$this->assertSame(['Rian Hidayat', 'Siti Nurhaliza'], array_column($halaman3, 'nama'));
$this->assertSame([], $this->repository->halaman(2, 6));
}
}