Piramida test bertumpu pada fondasi yang murah dan cepat: unit test. Lapisan ini menguji satu class atau satu method dalam kondisi terisolasi — tanpa request HTTP, tanpa database, tanpa framework — sehingga ribuan kombinasi input bisa diverifikasi dalam hitungan detik. Pada chapter ini kita menulis unit test sungguhan untuk class perhitungan harga, melatih disiplin assertion, lalu memakai data provider untuk memetakan banyak kasus dalam satu method test.

Apa yang Layak Menjadi Unit Test #

Kandidat terbaik adalah class murni: service berisi logika bisnis, value object, helper, dan class utilitas. Ciri mereka sederhana — input masuk lewat parameter, hasil keluar lewat return value, dan status global tidak tersentuh. Sebaliknya, kode yang intinya berinteraksi dengan database, queue, atau HTTP bukan wilayah unit test; wilayah itu milik feature test yang akan kita tulis pada chapter berikutnya.

Mari kita pakai satu class nyata sepanjang chapter ini — service perhitungan total belanja:

php
<?php

namespace App\Services;

use InvalidArgumentException;

class PriceCalculator
{
    public const MAX_DISCOUNT = 50;

    /**
     * Hitung total harga: subtotal dikurangi diskon (persen).
     */
    public function total(int $hargaSatuan, int $kuantitas, int $diskonPersen = 0): int
    {
        if ($kuantitas < 1) {
            throw new InvalidArgumentException('Kuantitas minimal 1.');
        }

        if ($diskonPersen < 0 || $diskonPersen > self::MAX_DISCOUNT) {
            throw new InvalidArgumentException(
                'Diskon harus di antara 0 dan '.self::MAX_DISCOUNT.'.'
            );
        }

        $subtotal = $hargaSatuan * $kuantitas;

        return $subtotal - intdiv($subtotal * $diskonPersen, 100);
    }
}

Class ini menampung dua perilaku yang layak diuji: perhitungan aritmetika dan penolakan input tidak valid lewat exception. Karena tidak membutuhkan Laravel sama sekali, parent class test-nya adalah PHPUnit\Framework\TestCase — bukan Tests\TestCase — persis seperti tests/Unit/ExampleTest.php bawaan.

Disiplin assertEquals vs assertSame #

PHPUnit menyediakan assertion perbandingan longgar dan ketat. assertEquals memakai perbandingan longgar ala operator ==, sedangkan assertSame juga memeriksa tipe data seperti operator ===. Untuk kode yang mengembalikan angka uang, tipe yang tepat adalah bagian dari kontrak — maka assertSame adalah pilihan utama kita.

Bedanya nyata ketika kita sengaja menulis test yang gagal berikut:

php
$hasil = (new PriceCalculator)->total(10000, 1);

$this->assertEquals('10000', $hasil);   // lulus: '10000' == 10000
$this->assertSame('10000', $hasil);     // gagal: tipe berbeda

Output yang Dihasilkan:

text
  FAIL  Tests\Unit\DisiplinAssertTest
  ⨯  assert equals lulus padahal tipe berbeda
  ────────────────────────────────────────────────────────────────────────────
  FAILED  Tests\Unit\DisiplinAssertTest > assert equals lulus padahal tipe…
  Failed asserting that 10000 is identical to '10000'.

  at tests/Unit/DisiplinAssertTest.php:15

  Tests:    1 failed (2 assertions)

Baris assertEquals lulus karena string '10000' dibandingkan longgar dengan integer 10000, padahal method total() menjanjikan int. Baris assertSame menangkap kebohongan itu. Jika satu hari method berubah mengembalikan float atau string, assertSame akan langsung menandainya — itulah mengapa test pada chapter ini memakainya secara konsisten.

Data Provider untuk Banyak Kasus #

Satu method total() punya banyak kombinasi input. Menulis satu method test per kombinasi akan menggandakan boilerplate; data provider memecahkannya dengan memisahkan data dari langkah verifikasi. Berikut test lengkap untuk PriceCalculator:

php
<?php

namespace Tests\Unit;

use App\Services\PriceCalculator;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class PriceCalculatorTest extends TestCase
{
    public static function penyediaTotal(): array
    {
        return [
            'tanpa diskon, satu item' => [10000, 1, 0, 10000],
            'tanpa diskon, tiga item' => [10000, 3, 0, 30000],
            'diskon sepuluh persen' => [50000, 2, 10, 90000],
            'diskon maksimal lima puluh persen' => [60000, 1, 50, 30000],
        ];
    }

    #[DataProvider('penyediaTotal')]
    public function test_total_subtotal_dikurangi_diskon(
        int $hargaSatuan,
        int $kuantitas,
        int $diskonPersen,
        int $totalDiharapkan,
    ): void {
        $kalkulator = new PriceCalculator;

        $hasil = $kalkulator->total($hargaSatuan, $kuantitas, $diskonPersen);

        $this->assertSame($totalDiharapkan, $hasil);
    }

    public function test_kuantitas_nol_melempar_exception(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Kuantitas minimal 1.');

        (new PriceCalculator)->total(10000, 0);
    }

    public function test_diskon_diluar_rentang_melempar_exception(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Diskon harus di antara 0 dan 50.');

        (new PriceCalculator)->total(10000, 1, 51);
    }
}

Method statis penyediaTotal() mengembalikan empat dataset bernama; setiap dataset adalah array argumen untuk method test. Attribute #[DataProvider('penyediaTotal')] menghubungkan keduanya, lalu PHPUnit menjalankan test satu kali per dataset — total empat eksekusi. Nama dataset ikut tercetak saat gagal, sehingga kita langsung tahu kombinasi mana yang bermasalah.

Menguji Exception #

Dua test terakhir memverifikasi jalur gagal. Pasangan expectException() dan expectExceptionMessage() menyatakan bahwa exception dari kelas tertentu — dengan pesan tertentu — harus terlempar sebelum test selesai. PHPUnit otomatis gagal jika exception yang diharapkan tidak pernah muncul, misalnya ketika validasi kuantitas tidak sengaja dihapus dari service.

Jalankan seluruh berkasnya:

bash
php artisan test tests/Unit/PriceCalculatorTest.php

Output yang Dihasilkan:

text
  PASS  Tests\Unit\PriceCalculatorTest
  ✓  total subtotal dikurangi diskon with data set "tanpa diskon, satu item"
  ✓  total subtotal dikurangi diskon with data set "tanpa diskon, tiga item"
  ✓  total subtotal dikurangi diskon with data set "diskon sepuluh persen"
  ✓  total subtotal dikurangi diskon with data set "diskon maksimal lima puluh persen"
  ✓  kuantitas nol melempar exception
  ✓  diskon diluar rentang melempar exception

  Tests:    6 passed (8 assertions)
  Duration: 0.08s

Enam test selesai dalam 0,08 detik — inilah kecepatan yang membuat lapisan dasar piramida bisa ditumpuk tebal. Perhatikan juga pola penamaannya: test_method_skenario seperti test_kuantitas_nol_melempar_exception, satu skenario per test, sehingga nama test yang gagal langsung menceritakan aturan bisnis mana yang dilanggar. Jika suatu saat unit test butuh container Laravel, cukup ganti parent class ke Tests\TestCase seperti contoh EnvironmentTest di chapter konfigurasi — sisanya biarkan framework-free.

Logika murni kini tertutup test, tetapi aplikasi kita bukan sekadar class terisolasi. Selanjutnya kita naik satu lapisan piramida: feature test yang menembak route sungguhan sampai ke database. Rujukan assertion lain ada di dokumentasi Testing Laravel .