Bagian 3 ditutup dengan tabel kompatibilitas empat baris — dan pengakuan jujur bahwa tabel itu belum bisa disebut jaminan. Pada Bagian 4 kita mengubahnya menjadi sesuatu yang tidak bisa berbohong: test suite PHPUnit yang membekukan seluruh public API SlugGenerator menjadi spesifikasi yang dapat dijalankan. Hasil akhirnya 30 test dengan 45 assertion — setiap method publik, setiap opsi konfigurasi, setiap penolakan input, sampai janji immutabilitas wither, punya penjaga masing-masing yang berjalan dalam hitungan milidetik.
Dari Tabel ke Test Suite #
Library membutuhkan test lebih daripada aplikasi, dan alasannya sederhana: penggunanya jauh. Aplikasi kita mengelola sendiri kodenya; library dipanggil oleh kode orang lain yang tidak kita lihat, di proyek yang tidak kita ketahui, dengan kombinasi opsi yang tidak kita bayangkan. Tanpa test, setiap rilis adalah tebakan. Tabel kompatibilitas di Bagian 3 hanya ingatan yang ditulis ulang — mudah usang, mustahil diperiksa mesin.
Sebelum memakai framework, mari kita rasakan dulu bentuk paling primitifnya: bandingkan hasil dengan harapan, cetak laporan sendiri.
<?php
require __DIR__ . '/../bagian-3-public-api-dan-validation/src/SlugGenerator.php';
require __DIR__ . '/../bagian-3-public-api-dan-validation/src/Exception/InvalidInputException.php';
$generator = new App\Util\SlugGenerator();
// "Test suite" buatan sendiri: daftar kasus + perbandingan manual.
$kasus = [
['Belajar PHP Dasar', 'belajar-php-dasar'],
['Tips & Trik -- Untuk Pemula', 'tips-trik-untuk-pemula'],
['Résumé Terbaru 2026', 'resume-terbaru-2026'],
];
foreach ($kasus as [$judul, $harapan]) {
$hasil = $generator->generate($judul);
if ($hasil !== $harapan) {
echo 'GAGAL: ', $judul, ' -> ', $hasil, ' (harapan: ', $harapan, ")\n";
} else {
echo 'lolos: ', $judul, ' -> ', $hasil, "\n";
}
}Output yang Dihasilkan:
lolos: Belajar PHP Dasar -> belajar-php-dasar
lolos: Tips & Trik -- Untuk Pemula -> tips-trik-untuk-pemula
lolos: Résumé Terbaru 2026 -> resume-terbaru-2026Tiga kasus lolos — tetapi perhatikan apa yang tidak kita dapatkan: tidak ada pemisahan antar kasus yang independen, tidak ada diff yang menjelaskan bedanya, tidak ada penghitungan total, tidak ada exit code yang bisa dipakai CI, dan setiap kebutuhan baru berarti menulis lagi loop-nya. PHPUnit mengambil alih semua pekerjaan itu dan menyisakan satu tugas bagi kita: mendeskripsikan perilaku yang diharapkan. Konsep unit testing sendiri sudah dibahas tuntas pada chapter Unit Testing ; bagian ini fokus pada penerapannya untuk sebuah library.
Anatomi Satu Test #
Setiap test di suite kita mengikuti satu bentuk: siapkan objek, jalankan method, bandingkan hasil — pola arrange-act-assert. Method testGeneratesSlugFromTitle di listing paling bawah adalah contoh terkecilnya: new SlugGenerator() (arrange), generate($input) (act), assertSame($expected, ...) (assert). Nama method dibaca sebagai kalimat pernyataan — testMaxLengthTruncatesAtWordBoundary — sehingga saat gagal, namanya sendiri yang melaporkan perilaku mana yang patah.
Pilihan assertSame dan bukan assertEquals adalah keputusan sadar. PHPUnit memetakan keduanya ke operator perbandingan PHP: assertEquals bekerja seperti ==, assertSame seperti ===. Untuk library yang produknya string, perbedaan ini nyata:
<?php
// Kelalaian klasik: harapan tertulis sebagai int, hasil berupa string.
$hasil = '2026';
$harapan = 2026;
echo 'perbandingan == (loose) : ', var_export($hasil == $harapan, true), "\n";
echo 'perbandingan === (strict): ', var_export($hasil === $harapan, true), "\n";Output yang Dihasilkan:
perbandingan == (loose) : true
perbandingan === (strict): falseKalau harapan tanpa sengaja tertulis 2026 alih-alih '2026', assertEquals tetap meloloskannya — test hijau padahal kontrak tipe bocor. assertSame menolaknya karena tipe ikut diperiksa. Karena itu seluruh suite ini memakai assertSame sebagai assertion bawaan; assertEquals hanya untuk kasus yang memang menghendaki perbandingan longgar, dan kasus semacam itu tidak ada pada slug.
Tabel Kasus dengan Data Provider #
Sembilan kasus slug di Bagian 2-3 kini menjadi satu tabel data. Method slugProvider() mengembalikan array asosiatif: kunci string menjadi nama kasus, nilai menjadi argumen untuk test. Atribut #[DataProvider('slugProvider')] di atas method test memberi tahu PHPUnit untuk menjalankan test sekali per baris — beginilah pasangannya di dalam kelas test, cangkang kelas dipertahankan supaya potongan ini berdiri sendiri:
<?php
// Potongan tests/SlugGeneratorTest.php: method test + atribut providernya.
final class SlugGeneratorTest extends TestCase
{
#[DataProvider('slugProvider')]
public function testGeneratesSlugFromTitle(string $input, string $expected): void
{
$generator = new SlugGenerator();
self::assertSame($expected, $generator->generate($input));
}
}Satu method, sembilan eksekusi. Nama barisnya ikut terbawa ke laporan — jalankan dengan --testdox dan setiap kasus tampil sebagai baris bercentang:
Slug Generator (App\Util\Tests\SlugGenerator)
✔ Generates slug from title with data set "judul sederhana"
✔ Generates slug from title with data set "spasi dan tanda baca runtuh"
✔ Generates slug from title with data set "simbol beruntun menjadi satu"
✔ Generates slug from title with data set "aksen Latin ditransliterasi"
✔ Generates slug from title with data set "huruf besar beraksen"
✔ Generates slug from title with data set "aksen dan angka tahun"
✔ Generates slug from title with data set "dua kata beraksen"
✔ Generates slug from title with data set "tanda hubung di tepi dibuang"
✔ Generates slug from title with data set "titik desimal dan simbol"
✔ Underscore preset uses underscore separator
✔ Separator can be set in constructor
✔ Max length truncates at word boundary
✔ Max length cuts hard when no separator in range
✔ Max length leaves short slug alone
✔ With max length null removes limit
✔ Lowercase can be disabled
✔ Empty input is rejected with data set "string kosong"
✔ Empty input is rejected with data set "hanya spasi"
✔ Empty input is rejected with data set "spasi tab dan newline"
✔ Input without alphanumerics returns empty string
✔ With separator returns new instance and keeps original
✔ Wither chain combines options immutably
✔ Invalid separator is rejected by constructor with data set "string kosong"
✔ Invalid separator is rejected by constructor with data set "satu huruf"
✔ Invalid separator is rejected by constructor with data set "mengandung huruf"
✔ Invalid separator is rejected by constructor with data set "dolar berarti khusus"
✔ Invalid separator is rejected by wither
✔ Invalid max length is rejected by constructor with data set "nol"
✔ Invalid max length is rejected by constructor with data set "negatif"
✔ Invalid max length is rejected by witherMenambah kasus baru kini berarti menambah satu baris data, bukan menyalin satu method. Docblock @return array<string, array{string, string}> di atas provider mendokumentasikan bentuk barisnya — [judul, slug yang diharapkan] — dan akan terbukti menyelamatkan kita lagi pada Bagian 5. Pembahasan lengkap pola ini ada di chapter Data Providers
.
Menguji Exception dan Validasi Konfigurasi #
Exception adalah bagian dari kontrak publik — pengguna menangkapnya, jadi kita harus mengujinya. Polanya tiga baris: expectException() menyatakan kelas yang diharapkan, expectExceptionMessage() menyatakan teks yang harus terkandung dalam pesan, lalu baris yang memicu pelemparan ditulis paling akhir. PHPUnit menandai test gagal bila exception tidak pernah terlempar, salah kelas, atau pesannya tidak cocok.
Sebelas test exception kita menutup seluruh gerbang validasi: tiga variasi input kosong pada generate(), empat separator terlarang dan dua maxLength tidak valid pada constructor, plus satu jalur masing-masing lewat wither — membuktikan validasi di withSeparator() dan withMaxLength() benar-benar dijalankan ulang, bukan warisan kebetulan dari constructor. Satu kasus sengaja berbeda arah: testInputWithoutAlphanumericsReturnsEmptyString memastikan '!!!' tidak melempar apa pun dan mengembalikan string kosong — batas antara “input ditolak” dan “input sah tapi tanpa isi bermakna” ikut menjadi kontrak yang dibekukan.
Menguji Immutabilitas Wither #
Janji “instance lama tidak berubah” dari Bagian 3 diuji dengan dua assertion berpasangan. assertNotSame($asli, $baru) membuktikan wither mengembalikan objek berbeda (perbandingan identitas === pada object, bukan ==). Lalu kedua instance diperintah menghasilkan slug dari judul yang sama: $asli wajib tetap bertanda hubung, $baru wajib bertanda garis bawah. Tanpa pasangan kedua, assertNotSame bisa dipuaskan oleh implementasi yang mengembalikan instance baru berkonfigurasi salah — imutabilitas berarti keadaan lama terpelihara, bukan sekadar objek lain. Test testWitherChainCombinesOptionsImmutably menaikkan taruhannya: rantai withSeparator('_')->withMaxLength(15) dibuktikan mengombinasikan kedua opsi tanpa menyentuh $asli.
Test Suite Lengkap #
Berikut isi penuh tests/SlugGeneratorTest.php — berkas yang sama tersedia di bagian bawah halaman:
<?php
declare(strict_types=1);
namespace App\Util\Tests;
use App\Util\Exception\InvalidInputException;
use App\Util\SlugGenerator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
// Bootstrap mini: PHPUnit perlu tahu cara memuat kelas yang sedang diuji.
// Di proyek nyata tugas ini dipegang autoloader Composer (kita rangkai di
// Bagian 6); di bundle materi ini kita require manual, dengan src/ milik
// bundle Bagian 3 yang berdiri sebagai folder kembar.
$srcDir = dirname(__DIR__) . '/src';
if (!is_dir($srcDir)) {
$srcDir = dirname(__DIR__, 2) . '/bagian-3-public-api-dan-validation/src';
}
require_once $srcDir . '/SlugGenerator.php';
require_once $srcDir . '/Exception/InvalidInputException.php';
/**
* Test suite untuk seluruh public API SlugGenerator.
*
* Suite ini hanya menyentuh permukaan publik — constructor, named
* constructor, wither, dan generate() — persis seperti yang dipakai
* pengguna library. Detail privat seperti urutan langkah di dalam
* generate() sengaja tidak diuji supaya refaktor internal bebas
* dilakukan tanpa menyentuh test.
*/
final class SlugGeneratorTest extends TestCase
{
#[DataProvider('slugProvider')]
public function testGeneratesSlugFromTitle(string $input, string $expected): void
{
$generator = new SlugGenerator();
self::assertSame($expected, $generator->generate($input));
}
/**
* Baris data: [judul masukan, slug yang diharapkan].
*
* @return array<string, array{string, string}>
*/
public static function slugProvider(): array
{
return [
'judul sederhana' => ['Belajar PHP Dasar', 'belajar-php-dasar'],
'spasi dan tanda baca runtuh' => [' Rilis v2.0: Perbaikan Bug! ', 'rilis-v2-0-perbaikan-bug'],
'simbol beruntun menjadi satu' => ['Tips & Trik -- Untuk Pemula', 'tips-trik-untuk-pemula'],
'aksen Latin ditransliterasi' => ['Rahasia Crème Brûlée yang Gampang', 'rahasia-creme-brulee-yang-gampang'],
'huruf besar beraksen' => ['Ångström & Über Modes', 'angstrom-uber-modes'],
'aksen dan angka tahun' => ['Résumé Terbaru 2026', 'resume-terbaru-2026'],
'dua kata beraksen' => ['naïve café', 'naive-cafe'],
'tanda hubung di tepi dibuang' => ['--- Belajar ---', 'belajar'],
'titik desimal dan simbol' => ['PHP 8.4 & MySQL 9', 'php-8-4-mysql-9'],
];
}
public function testUnderscorePresetUsesUnderscoreSeparator(): void
{
$generator = SlugGenerator::underscore();
self::assertSame('belajar_php_dasar', $generator->generate('Belajar PHP Dasar'));
}
public function testSeparatorCanBeSetInConstructor(): void
{
$generator = new SlugGenerator(separator: '_');
self::assertSame('belajar_php_dasar', $generator->generate('Belajar PHP Dasar'));
}
public function testMaxLengthTruncatesAtWordBoundary(): void
{
$generator = (new SlugGenerator())->withMaxLength(15);
self::assertSame('framework', $generator->generate('Framework Laravel Untuk Pemula'));
}
public function testMaxLengthCutsHardWhenNoSeparatorInRange(): void
{
$generator = (new SlugGenerator())->withMaxLength(5);
self::assertSame('solid', $generator->generate('Solidaritas'));
}
public function testMaxLengthLeavesShortSlugAlone(): void
{
$generator = (new SlugGenerator())->withMaxLength(50);
self::assertSame('belajar-php-dasar', $generator->generate('Belajar PHP Dasar'));
}
public function testWithMaxLengthNullRemovesLimit(): void
{
$generator = (new SlugGenerator(maxLength: 10))->withMaxLength(null);
self::assertSame('belajar-php-dasar', $generator->generate('Belajar PHP Dasar'));
}
public function testLowercaseCanBeDisabled(): void
{
$generator = new SlugGenerator(lowercase: false);
self::assertSame('Belajar-PHP-Dasar', $generator->generate('Belajar PHP Dasar'));
}
#[DataProvider('emptyInputProvider')]
public function testEmptyInputIsRejected(string $input): void
{
$generator = new SlugGenerator();
$this->expectException(InvalidInputException::class);
$this->expectExceptionMessage('Input tidak boleh kosong atau hanya berisi spasi.');
$generator->generate($input);
}
/**
* Baris data: [input yang harus ditolak].
*
* @return array<string, array{string}>
*/
public static function emptyInputProvider(): array
{
return [
'string kosong' => [''],
'hanya spasi' => [' '],
'spasi tab dan newline' => ["\t\n"],
];
}
public function testInputWithoutAlphanumericsReturnsEmptyString(): void
{
$generator = new SlugGenerator();
self::assertSame('', $generator->generate('!!!'));
}
public function testWithSeparatorReturnsNewInstanceAndKeepsOriginal(): void
{
$asli = new SlugGenerator();
$baru = $asli->withSeparator('_');
self::assertNotSame($asli, $baru);
self::assertSame('belajar-php-dasar', $asli->generate('Belajar PHP Dasar'));
self::assertSame('belajar_php_dasar', $baru->generate('Belajar PHP Dasar'));
}
public function testWitherChainCombinesOptionsImmutably(): void
{
$asli = new SlugGenerator();
$baru = $asli->withSeparator('_')->withMaxLength(15);
self::assertNotSame($asli, $baru);
self::assertSame('framework-laravel-untuk-pemula', $asli->generate('Framework Laravel Untuk Pemula'));
self::assertSame('framework', $baru->generate('Framework Laravel Untuk Pemula'));
}
#[DataProvider('invalidSeparatorProvider')]
public function testInvalidSeparatorIsRejectedByConstructor(string $separator, string $message): void
{
$this->expectException(InvalidInputException::class);
$this->expectExceptionMessage($message);
new SlugGenerator(separator: $separator);
}
/**
* Baris data: [separator tidak valid, pesan exception yang diharapkan].
*
* @return array<string, array{string, string}>
*/
public static function invalidSeparatorProvider(): array
{
return [
'string kosong' => ['', 'Separator tidak boleh berupa string kosong.'],
'satu huruf' => ['x', 'Separator tidak boleh mengandung huruf, angka, "$", atau "\\": "x"'],
'mengandung huruf' => ['a-b', 'Separator tidak boleh mengandung huruf, angka, "$", atau "\\": "a-b"'],
'dolar berarti khusus' => ['$', 'Separator tidak boleh mengandung huruf, angka, "$", atau "\\": "$"'],
];
}
public function testInvalidSeparatorIsRejectedByWither(): void
{
$generator = new SlugGenerator();
$this->expectException(InvalidInputException::class);
$this->expectExceptionMessage('Separator tidak boleh mengandung huruf, angka, "$", atau "\\": "x"');
$generator->withSeparator('x');
}
#[DataProvider('invalidMaxLengthProvider')]
public function testInvalidMaxLengthIsRejectedByConstructor(int $maxLength, string $message): void
{
$this->expectException(InvalidInputException::class);
$this->expectExceptionMessage($message);
new SlugGenerator(maxLength: $maxLength);
}
/**
* Baris data: [maxLength tidak valid, pesan exception yang diharapkan].
*
* @return array<string, array{int, string}>
*/
public static function invalidMaxLengthProvider(): array
{
return [
'nol' => [0, 'maxLength harus bernilai 1 atau lebih, diberikan: 0'],
'negatif' => [-5, 'maxLength harus bernilai 1 atau lebih, diberikan: -5'],
];
}
public function testInvalidMaxLengthIsRejectedByWither(): void
{
$generator = new SlugGenerator();
$this->expectException(InvalidInputException::class);
$this->expectExceptionMessage('maxLength harus bernilai 1 atau lebih, diberikan: 0');
$generator->withMaxLength(0);
}
}Tata letaknya bisa dibaca sebagai daftar janji. Blok bootstrap paling atas memuat kelas yang diuji — pilihan yang kita dokumentasikan: di proyek nyata tugas ini lebih pantas dipegang autoloader Composer, tetapi require_once manual dengan pengecekan lokasi src/ membuat suite tetap bisa berjalan di bundle materi ini tanpa pemasangan apa pun. Empat provider (slugProvider, emptyInputProvider, invalidSeparatorProvider, invalidMaxLengthProvider) menyuplai data bagi test yang memakainya, dan selebihnya adalah test satu-perilaku dengan assertSame. Perhatikan juga bahwa suite tidak pernah menyentuh method privat truncate() maupun konstanta ACCENT_MARKS — hanya API publik. Refaktor internal bebas dilakukan selama 30 test ini tetap hijau.
Menjalankan Suite dan Membaca Kegagalannya #
Dari direktori bagian-4-unit-testing/ pada bundle halaman ini, suite berjalan lewat PHPUnit phar:
$ php phpunit.phar tests/SlugGeneratorTest.phpPHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
.............................. 30 / 30 (100%)
Time: 00:00.006, Memory: 28.05 MB
OK (30 tests, 45 assertions)Di proyek nyata, PHPUnit dipasang lewat composer require --dev phpunit/phpunit lalu dijalankan dari vendor/bin/phpunit — setelah berkas konfigurasi phpunit.xml.dist (tersedia di bundle bagian ini) diletakkan di akar package:
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
Configuration: /tmp/slugify-sa/phpunit.xml.dist
.............................. 30 / 30 (100%)
Time: 00:00.007, Memory: 14.00 MB
OK (30 tests, 45 assertions)Angka 45 asalnya bisa dihitung: sembilan kasus provider slug plus delapan test satu-assertion menyumbang 17, sebelas test exception menyumbang 22 (expectException dan expectExceptionMessage masing-masing dihitung), dan enam assertion immutabilitas menutup sisanya. Lalu bagaimana wujud kegagalannya? Mari suntikkan regresi sungguhan — salin src/ dan tests/ ke /tmp/slugify-fail, hapus blok penyesuaian batas kata di truncate(), lalu jalankan ulang:
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
...........F.........F........ 30 / 30 (100%)
Time: 00:00.006, Memory: 28.05 MB
There were 2 failures:
1) App\Util\Tests\SlugGeneratorTest::testMaxLengthTruncatesAtWordBoundary
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'framework'
+'framework-larav'
/tmp/slugify-fail/tests/SlugGeneratorTest.php:82
2) App\Util\Tests\SlugGeneratorTest::testWitherChainCombinesOptionsImmutably
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'framework'
+'framework_larav'
/tmp/slugify-fail/tests/SlugGeneratorTest.php:162
FAILURES!
Tests: 30, Assertions: 45, Failures: 2.Dua test gagal, 28 lainnya tetap dijalankan — kegagalan tidak menghentikan suite. Diff -'framework' / +'framework-larav' langsung menunjukkan apa yang berubah, nama test menunjukkan janji mana yang dilanggar, dan nomor baris menunjuk assertion yang menangkapnya. PHPUnit keluar dengan exit code 1 (exit code 2 bila ada error), itulah yang nanti dipakai pipeline CI untuk menghentikan rilis. Sesi lengkapnya dijelaskan di chapter Menulis PHPUnit Tests
.
Apa yang Berubah #
Dibandingkan Bagian 3 yang menguji lewat demo manual dan tabel:
tests/SlugGeneratorTest.phpbaru — 30 test, 45 assertion: 9 kasus slug lewat data provider bernama, 6 test opsi (separator, preset,maxLengthbatas kata/potongan keras/tanpa batas,lowercase), 8 test exception lewat 3 provider, 1 test input tanpa alfanumerik, dan 2 test immutabilitas wither.phpunit.xml.distbaru — konfigurasi minimal: satu testsuiteunitpada direktoritests/, cache hasil di.phpunit-cache/.- Tabel kompatibilitas Bagian 3 kini punya penjaga mesin: baris
résumédan''yang dulu dibandingkan manual kini baris data provider yang diperiksa otomatis.
API tidak berubah satu byte pun — suite ini membekukannya. Kualitas yang tersisa tidak lagi soal perilaku, melainkan soal keyakinan statis: bisakah kita membuktikan bahwa tipe-tipe di seluruh kode konsisten tanpa menjalankannya? Itu pekerjaan Bagian 5.
Cara Menjalankan #
Dari direktori bagian-4-unit-testing/ pada bundle halaman ini:
# 1. Suite via PHPUnit phar (cara buku ini)
php phpunit.phar tests/SlugGeneratorTest.php
# 2. Laporan per perilaku dengan nama kasus
php phpunit.phar tests/SlugGeneratorTest.php --testdox
# 3. Sesi proyek nyata: rakit package lengkap di /tmp
mkdir -p /tmp/slugify-sa/tests
cp -r ../bagian-3-public-api-dan-validation/src /tmp/slugify-sa/
cp tests/SlugGeneratorTest.php /tmp/slugify-sa/tests/
cp phpunit.xml.dist /tmp/slugify-sa/
cp ../bagian-1-struktur-package/composer.json /tmp/slugify-sa/
cd /tmp/slugify-sa
composer require --dev phpunit/phpunit:^12.5
vendor/bin/phpunitBerkas tests/SlugGeneratorTest.php dan phpunit.xml.dist yang dipakai seluruh sesi tersedia di bagian bawah halaman. Sebagai rujukan resmi: dokumentasi PHPUnit 12.5
untuk anatomi test dan opsi CLI, menulis test untuk PHPUnit
untuk pola exception dan data provider, serta laporan testdox
untuk format agregat yang kita pakai di atas. Suite hijau di 30 titik — sekarang kita undang pemeriksa yang tidak pernah menjalankan kode sekalipun: PHPStan di Bagian 5.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
tests/SlugGeneratorTest.php
Unduh berkas<?php
declare(strict_types=1);
namespace App\Util\Tests;
use App\Util\Exception\InvalidInputException;
use App\Util\SlugGenerator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
// Bootstrap mini: PHPUnit perlu tahu cara memuat kelas yang sedang diuji.
// Di proyek nyata tugas ini dipegang autoloader Composer (kita rangkai di
// Bagian 6); di bundle materi ini kita require manual, dengan src/ milik
// bundle Bagian 3 yang berdiri sebagai folder kembar.
$srcDir = dirname(__DIR__) . '/src';
if (!is_dir($srcDir)) {
$srcDir = dirname(__DIR__, 2) . '/bagian-3-public-api-dan-validation/src';
}
require_once $srcDir . '/SlugGenerator.php';
require_once $srcDir . '/Exception/InvalidInputException.php';
/**
* Test suite untuk seluruh public API SlugGenerator.
*
* Suite ini hanya menyentuh permukaan publik — constructor, named
* constructor, wither, dan generate() — persis seperti yang dipakai
* pengguna library. Detail privat seperti urutan langkah di dalam
* generate() sengaja tidak diuji supaya refaktor internal bebas
* dilakukan tanpa menyentuh test.
*/
final class SlugGeneratorTest extends TestCase
{
#[DataProvider('slugProvider')]
public function testGeneratesSlugFromTitle(string $input, string $expected): void
{
$generator = new SlugGenerator();
self::assertSame($expected, $generator->generate($input));
}
/**
* Baris data: [judul masukan, slug yang diharapkan].
*
* @return array<string, array{string, string}>
*/
public static function slugProvider(): array
{
return [
'judul sederhana' => ['Belajar PHP Dasar', 'belajar-php-dasar'],
'spasi dan tanda baca runtuh' => [' Rilis v2.0: Perbaikan Bug! ', 'rilis-v2-0-perbaikan-bug'],
'simbol beruntun menjadi satu' => ['Tips & Trik -- Untuk Pemula', 'tips-trik-untuk-pemula'],
'aksen Latin ditransliterasi' => ['Rahasia Crème Brûlée yang Gampang', 'rahasia-creme-brulee-yang-gampang'],
'huruf besar beraksen' => ['Ångström & Über Modes', 'angstrom-uber-modes'],
'aksen dan angka tahun' => ['Résumé Terbaru 2026', 'resume-terbaru-2026'],
'dua kata beraksen' => ['naïve café', 'naive-cafe'],
'tanda hubung di tepi dibuang' => ['--- Belajar ---', 'belajar'],
'titik desimal dan simbol' => ['PHP 8.4 & MySQL 9', 'php-8-4-mysql-9'],
];
}
public function testUnderscorePresetUsesUnderscoreSeparator(): void
{
$generator = SlugGenerator::underscore();
self::assertSame('belajar_php_dasar', $generator->generate('Belajar PHP Dasar'));
}
public function testSeparatorCanBeSetInConstructor(): void
{
$generator = new SlugGenerator(separator: '_');
self::assertSame('belajar_php_dasar', $generator->generate('Belajar PHP Dasar'));
}
public function testMaxLengthTruncatesAtWordBoundary(): void
{
$generator = (new SlugGenerator())->withMaxLength(15);
self::assertSame('framework', $generator->generate('Framework Laravel Untuk Pemula'));
}
public function testMaxLengthCutsHardWhenNoSeparatorInRange(): void
{
$generator = (new SlugGenerator())->withMaxLength(5);
self::assertSame('solid', $generator->generate('Solidaritas'));
}
public function testMaxLengthLeavesShortSlugAlone(): void
{
$generator = (new SlugGenerator())->withMaxLength(50);
self::assertSame('belajar-php-dasar', $generator->generate('Belajar PHP Dasar'));
}
public function testWithMaxLengthNullRemovesLimit(): void
{
$generator = (new SlugGenerator(maxLength: 10))->withMaxLength(null);
self::assertSame('belajar-php-dasar', $generator->generate('Belajar PHP Dasar'));
}
public function testLowercaseCanBeDisabled(): void
{
$generator = new SlugGenerator(lowercase: false);
self::assertSame('Belajar-PHP-Dasar', $generator->generate('Belajar PHP Dasar'));
}
#[DataProvider('emptyInputProvider')]
public function testEmptyInputIsRejected(string $input): void
{
$generator = new SlugGenerator();
$this->expectException(InvalidInputException::class);
$this->expectExceptionMessage('Input tidak boleh kosong atau hanya berisi spasi.');
$generator->generate($input);
}
/**
* Baris data: [input yang harus ditolak].
*
* @return array<string, array{string}>
*/
public static function emptyInputProvider(): array
{
return [
'string kosong' => [''],
'hanya spasi' => [' '],
'spasi tab dan newline' => ["\t\n"],
];
}
public function testInputWithoutAlphanumericsReturnsEmptyString(): void
{
$generator = new SlugGenerator();
self::assertSame('', $generator->generate('!!!'));
}
public function testWithSeparatorReturnsNewInstanceAndKeepsOriginal(): void
{
$asli = new SlugGenerator();
$baru = $asli->withSeparator('_');
self::assertNotSame($asli, $baru);
self::assertSame('belajar-php-dasar', $asli->generate('Belajar PHP Dasar'));
self::assertSame('belajar_php_dasar', $baru->generate('Belajar PHP Dasar'));
}
public function testWitherChainCombinesOptionsImmutably(): void
{
$asli = new SlugGenerator();
$baru = $asli->withSeparator('_')->withMaxLength(15);
self::assertNotSame($asli, $baru);
self::assertSame('framework-laravel-untuk-pemula', $asli->generate('Framework Laravel Untuk Pemula'));
self::assertSame('framework', $baru->generate('Framework Laravel Untuk Pemula'));
}
#[DataProvider('invalidSeparatorProvider')]
public function testInvalidSeparatorIsRejectedByConstructor(string $separator, string $message): void
{
$this->expectException(InvalidInputException::class);
$this->expectExceptionMessage($message);
new SlugGenerator(separator: $separator);
}
/**
* Baris data: [separator tidak valid, pesan exception yang diharapkan].
*
* @return array<string, array{string, string}>
*/
public static function invalidSeparatorProvider(): array
{
return [
'string kosong' => ['', 'Separator tidak boleh berupa string kosong.'],
'satu huruf' => ['x', 'Separator tidak boleh mengandung huruf, angka, "$", atau "\\": "x"'],
'mengandung huruf' => ['a-b', 'Separator tidak boleh mengandung huruf, angka, "$", atau "\\": "a-b"'],
'dolar berarti khusus' => ['$', 'Separator tidak boleh mengandung huruf, angka, "$", atau "\\": "$"'],
];
}
public function testInvalidSeparatorIsRejectedByWither(): void
{
$generator = new SlugGenerator();
$this->expectException(InvalidInputException::class);
$this->expectExceptionMessage('Separator tidak boleh mengandung huruf, angka, "$", atau "\\": "x"');
$generator->withSeparator('x');
}
#[DataProvider('invalidMaxLengthProvider')]
public function testInvalidMaxLengthIsRejectedByConstructor(int $maxLength, string $message): void
{
$this->expectException(InvalidInputException::class);
$this->expectExceptionMessage($message);
new SlugGenerator(maxLength: $maxLength);
}
/**
* Baris data: [maxLength tidak valid, pesan exception yang diharapkan].
*
* @return array<string, array{int, string}>
*/
public static function invalidMaxLengthProvider(): array
{
return [
'nol' => [0, 'maxLength harus bernilai 1 atau lebih, diberikan: 0'],
'negatif' => [-5, 'maxLength harus bernilai 1 atau lebih, diberikan: -5'],
];
}
public function testInvalidMaxLengthIsRejectedByWither(): void
{
$generator = new SlugGenerator();
$this->expectException(InvalidInputException::class);
$this->expectExceptionMessage('maxLength harus bernilai 1 atau lebih, diberikan: 0');
$generator->withMaxLength(0);
}
}