Unit Test #
Unit test adalah praktik menulis kode yang memverifikasi bahwa unit terkecil dari aplikasimu — fungsi, method, kelas — bekerja dengan benar secara terisolasi. PHPUnit adalah framework testing standar de facto untuk PHP, digunakan oleh hampir semua library dan framework besar termasuk Laravel, Symfony, dan Doctrine. Tapi unit test bukan sekadar menulis kode agar “ada testnya” — test yang buruk justru memperlambat development, rapuh saat refactoring, dan tidak memberikan kepercayaan nyata. Artikel ini membahas PHPUnit dari instalasi hingga praktik lanjutan: test doubles yang benar, data provider, code coverage yang bermakna, dan filosofi menulis test yang benar-benar berguna.
Mengapa Unit Test #
Sebelum masuk ke teknis, penting untuk memahami nilai nyata dari unit test:
flowchart LR
A[Tulis Kode] --> B[Tulis Test]
B --> C{Test Lulus?}
C -- Tidak --> D[Debug & Perbaiki\nKode]
D --> C
C -- Ya --> E[Refactor\ndengan Aman]
E --> F{Test Masih\nLulus?}
F -- Tidak --> G[Temukan Regresi\nSebelum Production]
G --> D
F -- Ya --> H[Deploy\ndengan Percaya Diri]
style G fill:#fef9c3
style H fill:#dcfce7Test yang baik memberikan tiga hal: dokumentasi hidup (test menjelaskan apa yang seharusnya dilakukan kode), jaring pengaman refactoring (ubah implementasi tanpa takut merusak perilaku), dan umpan balik cepat (temukan bug dalam detik, bukan saat user lapor).
Instalasi dan Konfigurasi #
# Instal PHPUnit sebagai dependensi development
composer require --dev phpunit/phpunit "^11.0"
# Cek versi
./vendor/bin/phpunit --version
phpunit.xml — Konfigurasi Test Suite
#
<?xml version="1.0" encoding="UTF-8"?>
<!-- phpunit.xml atau phpunit.xml.dist -->
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
stopOnFailure="false"
beStrictAboutOutputDuringTests="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<!-- Code coverage -->
<coverage>
<include>
<directory suffix=".php">src</directory>
</include>
<exclude>
<directory>src/generated</directory>
</exclude>
<report>
<html outputDirectory="coverage"/>
<clover outputFile="coverage/clover.xml"/>
<text outputFile="coverage/coverage.txt"/>
</report>
</coverage>
<!-- Environment variables untuk test -->
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_DRIVER" value="array"/>
</php>
</phpunit>
Struktur Direktori Test #
tests/
├── Unit/ ← test terisolasi, tanpa I/O
│ ├── Domain/
│ │ └── OrderTest.php
│ ├── Service/
│ │ └── PricingServiceTest.php
│ └── Util/
│ └── SlugGeneratorTest.php
├── Integration/ ← test dengan database atau service nyata
│ └── Repository/
│ └── UserRepositoryTest.php
└── Feature/ ← test end-to-end HTTP request
└── Api/
└── OrderControllerTest.php
Anatomi Test Case #
<?php
// tests/Unit/Service/PricingServiceTest.php
namespace Tests\Unit\Service;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use App\Service\PricingService;
use App\Exception\InvalidPriceException;
class PricingServiceTest extends TestCase
{
// System Under Test — objek yang sedang diuji
private PricingService $service;
// setUp() — dijalankan sebelum SETIAP test method
protected function setUp(): void
{
parent::setUp();
$this->service = new PricingService();
}
// tearDown() — dijalankan setelah SETIAP test method
protected function tearDown(): void
{
// bersihkan resource jika perlu
parent::tearDown();
}
// setUpBeforeClass() — dijalankan sekali sebelum semua test di kelas ini
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
// koneksi database bersama, dll.
}
// tearDownAfterClass() — dijalankan sekali setelah semua test di kelas ini
public static function tearDownAfterClass(): void
{
parent::tearDownAfterClass();
}
#[Test]
public function menghitungHargaDenganDiskon(): void
{
// Arrange — siapkan kondisi
$hargaPokok = 100_000.0;
$diskon = 0.10; // 10%
// Act — jalankan yang diuji
$hasil = $this->service->hitungTotal($hargaPokok, $diskon);
// Assert — verifikasi hasilnya
$this->assertSame(90_000.0, $hasil);
}
}
Konvensi Penamaan Test #
Nama test harus mendeskripsikan perilaku yang diuji, bukan nama method:
<?php
// ANTI-PATTERN: nama yang tidak informatif
public function testHitungTotal(): void { }
public function test1(): void { }
public function testCase3(): void { }
// BENAR: nama yang mendeskripsikan skenario dan ekspektasi
public function hargaFinalMencakupPPNSebagaiElevenPersen(): void { }
public function melemparExceptionJikaDiskonMelebihiSeratus(): void { }
public function mengembalikanNolJikaKeranjangKosong(): void { }
// Format yang baik: [kondisi]_[aksi]_[ekspektasi]
// atau: [aksi] [kondisi] [ekspektasi] (dalam bahasa Indonesia)
public function hargaPokok_denganDiskon10Persen_menghasilkan90000(): void { }
Assertions #
PHPUnit menyediakan lebih dari 60 assertion. Berikut yang paling sering digunakan dan kapan memilihnya:
<?php
// Perbandingan nilai
$this->assertSame(42, $hasil); // === (tipe dan nilai sama) — lebih ketat
$this->assertEquals(42, $hasil); // == (nilai sama, konversi tipe) — lebih longgar
$this->assertNotSame(0, $hasil);
$this->assertNotEquals(0, $hasil);
// Boolean
$this->assertTrue($kondisi);
$this->assertFalse($kondisi);
// Null
$this->assertNull($nilai);
$this->assertNotNull($nilai);
// Angka
$this->assertGreaterThan(0, $hasil);
$this->assertGreaterThanOrEqual(0, $hasil);
$this->assertLessThan(100, $hasil);
$this->assertEqualsWithDelta(3.14, $hasil, delta: 0.001); // untuk float
// String
$this->assertStringContainsString('Halo', $kalimat);
$this->assertStringStartsWith('http', $url);
$this->assertStringEndsWith('.php', $file);
$this->assertMatchesRegularExpression('/^\d{4}$/', $tahun);
$this->assertStringEqualsIgnoringLineEndings("konten", $teks);
// Array
$this->assertCount(3, $array);
$this->assertEmpty($array);
$this->assertNotEmpty($array);
$this->assertContains('apel', $array);
$this->assertArrayHasKey('nama', $array);
$this->assertArrayNotHasKey('password', $responseData);
// Type
$this->assertIsInt($nilai);
$this->assertIsFloat($nilai);
$this->assertIsString($nilai);
$this->assertIsBool($nilai);
$this->assertIsArray($nilai);
$this->assertIsNull($nilai);
$this->assertInstanceOf(User::class, $objek);
// Exception — lihat bagian tersendiri
// File
$this->assertFileExists('/tmp/output.txt');
$this->assertFileNotExists('/tmp/temp.txt');
$this->assertDirectoryExists('/tmp/logs');
Memilih assertSame vs assertEquals
#
<?php
// assertSame menggunakan === (nilai DAN tipe harus sama)
$this->assertSame(1, 1); // lulus
$this->assertSame(1, '1'); // GAGAL — tipe berbeda
$this->assertSame(1, 1.0); // GAGAL — int vs float
$this->assertSame(1, true); // GAGAL — int vs bool
// assertEquals menggunakan == (konversi tipe otomatis)
$this->assertEquals(1, '1'); // lulus — tapi ini mungkin bug tersembunyi!
$this->assertEquals(1, true); // lulus
// PRAKTIK TERBAIK: gunakan assertSame sebagian besar waktu
// Gunakan assertEquals hanya ketika tipe memang tidak relevan
// atau ketika membandingkan objek/array secara rekursif
Data Provider #
Data provider memungkinkan menjalankan test yang sama dengan banyak input berbeda, tanpa duplikasi kode:
<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
class PricingServiceTest extends TestCase
{
public static function skenarioDiskon(): array
{
return [
// [nama skenario, harga, diskon, hasil_diharapkan]
'diskon 0%' => [100_000, 0.00, 100_000.0],
'diskon 10%' => [100_000, 0.10, 90_000.0],
'diskon 50%' => [100_000, 0.50, 50_000.0],
'diskon 100%' => [100_000, 1.00, 0.0],
'harga 0' => [0, 0.20, 0.0],
];
}
#[Test]
#[DataProvider('skenarioDiskon')]
public function menghitungHargaSetelahDiskon(
float $harga,
float $diskon,
float $ekspektasi,
): void {
$hasil = $this->service->hitungTotal($harga, $diskon);
$this->assertEqualsWithDelta($ekspektasi, $hasil, 0.01);
}
// Data provider untuk edge case
public static function inputTidakValid(): array
{
return [
'harga negatif' => [-1, 0.10],
'diskon negatif' => [100_000, -0.1],
'diskon melebihi 100%' => [100_000, 1.5],
];
}
#[Test]
#[DataProvider('inputTidakValid')]
public function melemparExceptionUntukInputTidakValid(
float $harga,
float $diskon,
): void {
$this->expectException(\InvalidArgumentException::class);
$this->service->hitungTotal($harga, $diskon);
}
}
Testing Exception #
<?php
class OrderServiceTest extends TestCase
{
#[Test]
public function menolakOrderDenganKeranjangKosong(): void
{
// Cara 1: expectException() — verifikasi tipe exception
$this->expectException(\DomainException::class);
$this->service->prosesOrder([]);
}
#[Test]
public function pesanErrorHarusMenyebutAlasan(): void
{
// Cara 2: verifikasi pesan exception
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Stok tidak mencukupi');
$this->expectExceptionCode(409);
$this->service->tambahItem($produkId, qty: 999);
}
#[Test]
public function menangkapExceptionDanMemeriksakanProperty(): void
{
// Cara 3: tangkap manual jika perlu periksa property khusus
try {
$this->service->tambahItem($produkId, qty: 999);
$this->fail('Exception tidak dilempar padahal diharapkan');
} catch (\App\Exception\InsufficientStockException $e) {
$this->assertSame('Laptop', $e->getNamaProduk());
$this->assertSame(999, $e->getDiminta());
$this->assertLessThan(999, $e->getTersedia());
}
}
}
Test Doubles — Stub dan Mock #
Saat unit yang diuji bergantung pada objek lain (database, API, email), kita ganti dengan test double agar test terisolasi dan deterministik.
flowchart LR
Test[Test] --> SUT[System Under\nTest]
SUT --> TDouble[Test Double\nStub / Mock / Spy]
TDouble -.->|menggantikan| RealDep[Dependensi\nNyata\nDB / API / Email]
style TDouble fill:#fef9c3
style RealDep fill:#fee2e2Stub — Kembalikan Nilai yang Sudah Ditentukan #
Stub hanya mengembalikan nilai yang telah diprogram — tidak memverifikasi bagaimana ia dipanggil:
<?php
class OrderServiceTest extends TestCase
{
#[Test]
public function menghitungTotalOrderDariRepository(): void
{
// Buat stub untuk UserRepository
$repoStub = $this->createStub(UserRepository::class);
// Program stub untuk mengembalikan user tertentu
$repoStub->method('findById')
->willReturn(new User(id: 1, nama: 'Budi', diskon: 0.10));
// Bisa juga return berbeda untuk argumen berbeda
$repoStub->method('findById')
->willReturnMap([
[1, new User(id: 1, diskon: 0.10)],
[2, new User(id: 2, diskon: 0.20)],
]);
// Bisa juga return berurutan untuk pemanggilan berurutan
$repoStub->method('findAll')
->willReturnOnConsecutiveCalls(
[new User(id: 1)],
[], // kosong di pemanggilan kedua
);
$service = new OrderService($repoStub);
$total = $service->hitungTotalUntukUser(userId: 1, harga: 100_000);
$this->assertSame(90_000.0, $total); // diskon 10%
}
}
Mock — Verifikasi Pemanggilan #
Mock tidak hanya mengembalikan nilai tapi juga memverifikasi bahwa method dipanggil dengan cara yang benar:
<?php
class NotificationServiceTest extends TestCase
{
#[Test]
public function mengirimEmailSaatOrderSelesai(): void
{
// Buat mock untuk Mailer
$mailerMock = $this->createMock(MailerInterface::class);
// Ekspektasi: kirim() HARUS dipanggil TEPAT SEKALI
$mailerMock->expects($this->once())
->method('kirim')
->with(
$this->equalTo('[email protected]'), // argumen 1
$this->stringContains('Order #'), // argumen 2
$this->anything(), // argumen 3 (tidak dipedulikan)
);
$service = new NotificationService($mailerMock);
$service->beritahuOrderSelesai(new Order(
id: 42,
email: '[email protected]',
));
// Verifikasi terjadi secara otomatis saat test selesai
}
#[Test]
public function tidakMengirimEmailJikaOrderDibatalkan(): void
{
$mailerMock = $this->createMock(MailerInterface::class);
// Ekspektasi: kirim() TIDAK BOLEH dipanggil sama sekali
$mailerMock->expects($this->never())
->method('kirim');
$service = new NotificationService($mailerMock);
$service->beritahuOrderDibatalkan(new Order(
id: 42,
email: '[email protected]',
status: 'cancelled',
));
}
#[Test]
public function mengirimEmailKeSemua(): void
{
$mailerMock = $this->createMock(MailerInterface::class);
// Dipanggil tepat 3 kali (tidak peduli argumennya)
$mailerMock->expects($this->exactly(3))
->method('kirim');
$service = new NotificationService($mailerMock);
$service->broadcastPengumuman([
'[email protected]',
'[email protected]',
'[email protected]',
], 'Pengumuman penting');
}
}
Stub vs Mock — Kapan Mana #
Gunakan Stub jika:
✓ Dependensi perlu mengembalikan data agar SUT bisa berjalan
✓ Tidak peduli berapa kali atau bagaimana dependensi dipanggil
✓ Contoh: repository yang mengembalikan data user untuk kalkulasi
Gunakan Mock jika:
✓ Ingin verifikasi bahwa interaksi dengan dependensi terjadi dengan benar
✓ Side effect adalah inti dari yang diuji (email terkirim, log ditulis)
✓ Contoh: mailer, logger, event dispatcher
Test yang Baik vs Test yang Buruk #
Kualitas test sama pentingnya dengan kualitas kode produksi:
<?php
// ANTI-PATTERN 1: test yang terlalu banyak assertion (testing everything)
#[Test]
public function testProsesOrder(): void
{
$order = $this->service->proses($data);
$this->assertNotNull($order);
$this->assertIsArray($order);
$this->assertArrayHasKey('id', $order);
$this->assertArrayHasKey('status', $order);
$this->assertArrayHasKey('total', $order);
$this->assertSame('pending', $order['status']);
$this->assertGreaterThan(0, $order['total']);
$this->assertArrayHasKey('items', $order);
$this->assertCount(2, $order['items']);
// ... 10 assertion lagi
}
// Jika satu assertion gagal, susah tahu aspek mana yang bermasalah
// BENAR: satu test, satu perilaku
#[Test]
public function orderBaruBerstatusPending(): void
{
$order = $this->service->proses($data);
$this->assertSame('pending', $order['status']);
}
#[Test]
public function orderBaruMemilikiTotalYangBenar(): void
{
$order = $this->service->proses(['items' => [['harga' => 50000, 'qty' => 2]]]);
$this->assertSame(100_000.0, $order['total']);
}
// ANTI-PATTERN 2: test yang bergantung pada test lain
#[Test]
public function test2ButuhTest1Dulu(): void
{
// Bergantung pada state yang dibuat test1 — sangat rapuh
$this->assertNotNull($this->userDariTest1);
}
// BENAR: setiap test berdiri sendiri, buat data sendiri di setUp()
#[Test]
public function setiapTestIndependenDanSendiri(): void
{
$user = $this->buatUserBaru();
$result = $this->service->proses($user);
$this->assertTrue($result);
}
// ANTI-PATTERN 3: test yang menguji implementasi, bukan perilaku
#[Test]
public function memastikanRedisKachesDipanggilDuaKali(): void
{
$cache = $this->createMock(Redis::class);
$cache->expects($this->exactly(2))->method('get'); // terikat pada implementasi!
// Jika implementasi berubah (misal jadi 1 kali call tapi lebih efisien), test gagal
}
// BENAR: test perilaku yang terlihat dari luar
#[Test]
public function mengembalikanDataUserDariCache(): void
{
// Panggil dua kali
$user1 = $this->service->getUser(1);
$user2 = $this->service->getUser(1);
// Verifikasi hasilnya sama (bukan berapa kali cache dipanggil)
$this->assertEquals($user1, $user2);
}
Code Coverage #
Code coverage mengukur berapa persen kode sumber yang dieksekusi saat test berjalan. Berguna sebagai indikator, tapi bukan tujuan akhir:
# Jalankan test dengan code coverage
./vendor/bin/phpunit --coverage-html coverage/
./vendor/bin/phpunit --coverage-text
./vendor/bin/phpunit --coverage-clover coverage/clover.xml
# Butuh Xdebug atau PCOV aktif
# Instal PCOV (lebih cepat dari Xdebug untuk coverage)
sudo apt install php8.3-pcov
# atau via PECL: pecl install pcov
Menentukan Coverage Minimum #
<!-- phpunit.xml -->
<coverage>
<report>
<html outputDirectory="coverage"/>
</report>
<!-- Gagalkan test jika coverage kurang dari threshold -->
<coverage requireCoverageMetadata="false">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
</coverage>
# Jalankan dan gagalkan jika coverage < 80%
./vendor/bin/phpunit --coverage-text --min-coverage-statements=80
Coverage Attributes #
<?php
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\UsesClass;
// Tandai kelas mana yang di-cover oleh test class ini
#[CoversClass(PricingService::class)]
#[UsesClass(Order::class)] // kelas yang dipakai tapi bukan fokus test
class PricingServiceTest extends TestCase
{
// ...
#[CoversMethod(PricingService::class, 'hitungTotal')]
public function menghitungTotalDenganBenar(): void
{
// test...
}
}
100% coverage bukan tujuan — code coverage mengukur baris yang dieksekusi, bukan kebenaran logika. Kode dengan 100% coverage masih bisa punya bug jika assertionnya lemah. Lebih baik punya 70% coverage dengan test yang bermakna daripada 100% coverage dengan test yang hanya memanggil method tanpa assertion yang tepat.
Test Attributes PHPUnit 11 #
PHPUnit 11 menggunakan PHP Attributes menggantikan docblock annotations:
<?php
use PHPUnit\Framework\Attributes\{
Test, DataProvider, Group, Skip, Depends,
Before, After, BeforeClass, AfterClass,
CoversClass, UsesClass, RequiresPhp,
WithoutErrorHandler, RunInSeparateProcess,
};
#[CoversClass(OrderService::class)]
#[Group('order')]
class OrderServiceTest extends TestCase
{
#[Test]
#[Group('smoke')]
public function orderDapatDibuat(): void { }
#[Test]
#[Skip('Fitur sedang dalam pengembangan')]
public function fiturBaru(): void { }
#[Test]
#[Depends('orderDapatDibuat')]
public function orderDapatDiproses(): void
{
// Test ini hanya dijalankan jika orderDapatDibuat lulus
}
#[Test]
#[RequiresPhp('8.2')]
public function fiturPhp82(): void { }
#[Test]
#[RunInSeparateProcess]
public function testYangMengubahGlobalState(): void { }
}
# Jalankan hanya test dalam group tertentu
./vendor/bin/phpunit --group smoke
./vendor/bin/phpunit --group order
./vendor/bin/phpunit --exclude-group slow
Integrasi ke CI/CD #
Test harus berjalan otomatis setiap kali ada perubahan kode:
# .github/workflows/test.yml (GitHub Actions)
name: PHP Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.2', '8.3'] # test di beberapa versi PHP
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: pcov, pdo, sqlite3
coverage: pcov
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run tests
run: |
./vendor/bin/phpunit \
--coverage-clover coverage/clover.xml \
--log-junit coverage/junit.xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: coverage/clover.xml
// composer.json — shortcut untuk menjalankan test
{
"scripts": {
"test": "./vendor/bin/phpunit",
"test:coverage": "./vendor/bin/phpunit --coverage-html coverage/",
"test:unit": "./vendor/bin/phpunit --testsuite Unit",
"test:watch": "fswatch -r src tests | xargs -I{} ./vendor/bin/phpunit"
}
}
# Jalankan test
composer test
composer test:unit
composer test:coverage
Ringkasan #
- PHPUnit adalah framework testing standar PHP — instal via Composer sebagai
require-dev, konfigurasi viaphpunit.xml, dan organisasikan test ke folder Unit, Integration, Feature.- Satu test, satu perilaku — setiap test method harus memverifikasi satu aspek perilaku spesifik. Test dengan terlalu banyak assertion sulit di-debug saat gagal.
- Nama test mendeskripsikan perilaku — bukan nama method.
menolakOrderDenganKeranjangKosong()jauh lebih informatif daritestProses().- Pola Arrange-Act-Assert — pisahkan setup kondisi, eksekusi yang diuji, dan verifikasi hasilnya dengan jelas.
assertSamelebih ketat dariassertEquals— gunakanassertSamesebagai default karena membandingkan nilai dan tipe. GunakanassertEqualshanya jika konversi tipe memang tidak relevan.- Stub untuk data, Mock untuk interaksi — stub mengembalikan nilai yang diprogram; mock memverifikasi bahwa method dipanggil dengan cara yang benar.
- Data Provider untuk menguji banyak input — satu test method dengan
#[DataProvider]jauh lebih bersih dari banyak method duplikasi.- Code coverage adalah indikator, bukan tujuan — 70% coverage dengan assertion bermakna lebih baik dari 100% coverage dengan test yang tidak memverifikasi apapun.
- Integrasikan ke CI/CD — test harus berjalan otomatis di setiap push dan pull request. Test yang tidak otomatis sering tidak dijalankan.