Empat bagian terakhir menghasilkan program prosedural yang berfungsi penuh — dan justru karena berfungsi, inilah saat terbaik untuk merombaknya. Pada bagian ini kita memindahkan logika tugas ke dua class dalam namespace App: Task sebagai model data dan TaskRepository sebagai penjaga penyimpanan JSON, lengkap dengan autoload PSR-4 lewat Composer dan test suite PHPUnit yang sesungguhnya. Tidak ada main.php di bagian ini; lapisan repository inilah deliverable-nya, dan setiap perintah CLI dari Bagian 4 tinggal diganti implementasinya menjadi panggilan method.

Mengapa Refactor ke Class #

Kode prosedural kita sehat, tetapi punya tiga titik lemah yang membesar seiring pertumbuhan program. Pertama, kontrak fungsi hanya terjaga oleh kebiasaan — tidak ada yang melarang tambahTugas() dipanggil dengan array yang salah bentuk. Kedua, jalur berkas tertanam sebagai nilai bawaan konstanta, sehingga pengujian harus memindahkan berkas sungguhan atau mengubah kode. Ketiga, menguji satu operasi berarti menjalankan seluruh main.php. Refactor ke class menjawab ketiganya sekaligus:

Kode prosedural (Bagian 1-4)Versi class (bagian ini)
Array asosiatif ['id' => ..., 'judul' => ..., 'selesai' => ...]Task readonly dengan properti bertipe
tambahTugas(), tandaiSelesai(), hapusTugas() globalMethod pada TaskRepository
muatTugas()/simpanTugas() dengan jalur bawaanJalur berkas disuntik ke konstruktor
Menguji lewat jalankan(['main.php', ...])Test PHPUnit dengan fixture per-test

Task: Value Object Immutable #

Class Task kami tandai final readonly — sejak PHP 8.2, seluruh properti class demikian tidak bisa diubah setelah konstruktor selesai. Akibatnya, method tandaiSelesai() tidak bisa memermak objek lama; ia wajib mengembalikan instance baru yang meniru semua properti lama kecuali statusnya:

php
<?php
use App\Task;

require_once __DIR__ . '/src/Task.php';

$asli = new Task(id: 1, judul: 'Belajar PHP dasar');
$baru = $asli->tandaiSelesai();

printf("asli : [%s] %s\n", $asli->selesai ? 'x' : ' ', $asli->judul);
printf("baru : [%s] %s\n", $baru->selesai ? 'x' : ' ', $baru->judul);
var_dump($asli === $baru);

Output yang Dihasilkan:

text
asli : [ ] Belajar PHP dasar
baru : [x] Belajar PHP dasar
bool(false)

Perbandingan === pada objek memeriksa identitas instance, dan hasilnya false — dua objek benar-benar terpisah. Desain ini pilihan sadar, bukan keterbatasan:

AspekMutasi (setSelesai(true))Instance baru (tandaiSelesai())
Objek lama yang dipegang kode lainIkut berubah diam-diamTetap utuh
Risiko spooky action at a distanceAdaTidak ada
Biaya memoriNolSalinan kecil tiga properti

Untuk objek sekecil Task, biaya salinan bisa diabaikan, sedangkan jaminan “objek yang saya pegang tidak berubah sendiri” berharga terus. Perhatikan pula pasangan method dariArray()/toArray() — jembatan dua arah antara objek bertipe dan bentuk array polos yang dibaca serta disimpan TaskRepository ke JSON.

TaskRepository dengan Dependency Injection #

TaskRepository mengambil alih seluruh tanggung jawab storage.php plus operasi array dari main.php. Kuncinya ada di konstruktor: jalur berkas disuntikkan dari luar (dependency injection), bukan dihitung di dalam class:

php
final class TaskRepository
{
    public function __construct(
        private readonly string $berkas,
    ) {
    }

    // ... method semua(), tambah(), tandaiSelesai(), hapus()
}

Promosi constructor dengan private readonly sekaligus mendeklarasikan properti, menandainya tak bisa diubah, dan mengisinya dari argumen. Berkat injeksi ini, test suite bebas memakai berkas di direktori temporari tanpa menyentuh data mana pun. Empat method publiknya memetakan satu-ke-satu pada perintah CLI: semua() untuk list, tambah() untuk tambah, tandaiSelesai() untuk selesai, dan hapus() untuk hapus. Mari kita lihat keduanya bekerja:

php
<?php
use App\TaskRepository;

require_once __DIR__ . '/src/Task.php';
require_once __DIR__ . '/src/TaskRepository.php';

$berkas = sys_get_temp_dir() . '/tugas-repo-demo.json';
if (is_file($berkas)) {
    unlink($berkas);
}

$repository = new TaskRepository($berkas);
$repository->tambah('Belajar PHP dasar');
$repository->tambah('Belajar OOP');
$selesai = $repository->tandaiSelesai(1);

printf("Ditandai selesai: [%d] %s\n", $selesai->id, $selesai->judul);
echo "Daftar Tugas:\n";
foreach ($repository->semua() as $tugas) {
    printf("  %d. [%s] %s\n", $tugas->id, $tugas->selesai ? 'x' : ' ', $tugas->judul);
}

unlink($berkas);

Output yang Dihasilkan:

text
Ditandai selesai: [1] Belajar PHP dasar
Daftar Tugas:
  1. [x] Belajar PHP dasar
  2. [ ] Belajar OOP

Kontrak error dari Bagian 4 tetap hidup, kini sebagai exception yang dilempar method: InvalidArgumentException untuk judul kosong, RuntimeException untuk ID tak ditemukan maupun berkas JSON rusak. Pemanggil bebas menangkapnya dan mencetak pesan sesuai konteks:

php
<?php
use App\TaskRepository;

require_once __DIR__ . '/src/Task.php';
require_once __DIR__ . '/src/TaskRepository.php';

$berkas = sys_get_temp_dir() . '/tugas-repo-error-demo.json';
if (is_file($berkas)) {
    unlink($berkas);
}

$repository = new TaskRepository($berkas);

try {
    $repository->hapus(99);
} catch (RuntimeException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

if (is_file($berkas)) {
    unlink($berkas);
}

Output yang Dihasilkan:

text
Error: Tugas dengan ID 99 tidak ditemukan.

Dengan begini, main.php versi masa depan cukup menjadi lapisan tipis: baca $argv, panggil method repository, tangkap exception, cetak hasil — persis pola error boundary Bagian 4 tanpa logika bisnis di dalamnya.

Autoload PSR-4 dengan Composer #

Namespace App dan struktur folder src/ kita ikat lewat aturan autoload PSR-4 di composer.json. Aturan "App\\": "src/" berarti class App\Task dicari di src/Task.php — satu class satu berkas, nama berkas mengikuti nama class:

json
{
    "name": "budi/pengelola-daftar-tugas",
    "description": "Pengelola daftar tugas berbasis CLI dengan penyimpanan JSON dan test suite PHPUnit.",
    "type": "project",
    "license": "MIT",
    "require": {
        "php": ">=8.3"
    },
    "require-dev": {
        "phpunit/phpunit": "^12.0"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\": "tests/"
        }
    },
    "scripts": {
        "test": "phpunit"
    }
}

Bagian require-dev dan scripts bersifat ilustratif untuk lingkungan pengembangan normal: setelah composer install, folder vendor/ berisi PHPUnit dan composer test tinggal menjalankannya. Lingkungan kursus ini memakai PHPUnit dalam bentuk berkas phar, sehingga test suite kita memuat class lewat require_once langsung — keduanya menghasilkan eksekusi yang sama.

Test Suite PHPUnit #

Berkas tests/TaskRepositoryTest.php berisi 13 test yang meng-cover alur inti repository. Setiap test mendapat fixture segar: setUp() menyiapkan berkas unik di direktori temporari (nama berkas memakai uniqid() agar test tidak saling berebut), dan tearDown() menghapusnya kembali:

php
final class TaskRepositoryTest extends TestCase
{
    protected function setUp(): void
    {
        $this->berkas = sys_get_temp_dir() . '/tugas-test-' . uniqid('', true) . '.json';
        $this->repository = new TaskRepository($this->berkas);
    }

    protected function tearDown(): void
    {
        if (is_file($this->berkas)) {
            unlink($this->berkas);
        }
    }

    // ... method test lainnya
}

Method test mengonsumsi kontrak repository dalam berbagai skenario: ID berurutan saat menambah dua tugas, persistensi lintas instance TaskRepository pada berkas yang sama, penolakan judul kosong, exception untuk ID tak dikenal, laporan berkas JSON rusak, sampai keimutabilan Task — assertNotSame($asli, $baru) memastikan tandaiSelesai() benar-benar mengembalikan objek berbeda. Pola asersi exception memakai expectException() sebelum panggilan yang diharapkan melempar:

php
final class TaskRepositoryTest extends TestCase
{
    public function testHapusIdTakDikenalMelemparException(): void
    {
        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('ID 99');

        $this->repository->hapus(99);
    }
}

Menjalankan Test #

Dari direktori bagian ini, jalankan PHPUnit terhadap berkas test-nya:

bash
$ php /home/z/my-project/bin/phpunit.phar tests/TaskRepositoryTest.php
text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

.............                                                     13 / 13 (100%)

Time: 00:00.007, Memory: 28.05 MB

OK (13 tests, 32 assertions)

Tiga belas titik pada baris progres mewakili tiga belas test yang lulus, dan baris OK menegaskan tidak ada kegagalan maupun warning — angka waktu dan memori akan sedikit berbeda di setiap eksekusi. Di lingkungan pengembangan normal dengan Composer, perintah yang setara adalah composer install sekali di awal, lalu composer test setiap kali menguji.

Apa yang Berubah dari Bagian 4 #

Seluruh fungsi global lenyap digantikan dua class: Task readonly sebagai pengganti array asosiatif berkontrak lepas, dan TaskRepository sebagai gabungan storage.php plus operasi array dengan jalur berkas yang disuntikkan. Pesan error tidak lagi dicetak langsung oleh fungsi — method melempar exception dan pemanggil yang memutuskan cara melaporkannya. Yang paling terasa: perilaku program kini bisa diverifikasi tanpa menjalankan CLI sama sekali, cukup phpunit terhadap fixture sementara.

Ringkasan #

Refactoring mengubah bentuk program tanpa mengubah perilakunya: kontrak perintah, format JSON, dan aturan validasi dari empat bagian sebelumnya hidup kembali sebagai method TaskRepository yang teruji. Task readonly memberi jaminan keimutabilan di level bahasa, dependency injection membuat penyimpanan bisa ditukar fixture, dan PSR-4 menyatukan namespace dengan struktur folder. Dengan test suite hijau, proyek pengelola daftar tugas tuntas — dan pola repository yang Anda kuasai di sini adalah pola yang akan terus muncul di framework dan aplikasi PHP skala besar.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

composer.json

Unduh berkas
json
{
    "name": "budi/pengelola-daftar-tugas",
    "description": "Pengelola daftar tugas berbasis CLI dengan penyimpanan JSON dan test suite PHPUnit.",
    "type": "project",
    "license": "MIT",
    "require": {
        "php": ">=8.3"
    },
    "require-dev": {
        "phpunit/phpunit": "^12.0"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\": "tests/"
        }
    },
    "scripts": {
        "test": "phpunit"
    }
}

src/Task.php

Unduh berkas
php
<?php

declare(strict_types=1);

namespace App;

/**
 * Satu tugas sebagai value object immutable.
 *
 * Class ini readonly: seluruh properti tidak bisa diubah setelah
 * konstruktor selesai. Operasi "mengubah" tugas berarti membuat
 * instance baru, sehingga tidak ada kode lain yang bisa memermak
 * tugas secara diam-diam lewat referensi yang sama.
 */
final readonly class Task
{
    public function __construct(
        public int $id,
        public string $judul,
        public bool $selesai = false,
    ) {
    }

    /**
     * Kembalikan salinan tugas ini dengan status selesai.
     *
     * Instance asli tidak tersentuh — pemanggil memperoleh objek
     * baru, persis pola operasi array pada Bagian 2 dan 3.
     */
    public function tandaiSelesai(): self
    {
        return new self($this->id, $this->judul, true);
    }

    /** Bangun ulang tugas dari array hasil json_decode. */
    public static function dariArray(array $data): self
    {
        return new self(
            id: (int) ($data['id'] ?? 0),
            judul: (string) ($data['judul'] ?? ''),
            selesai: (bool) ($data['selesai'] ?? false),
        );
    }

    /** Bentuk array untuk disimpan ke JSON. */
    public function toArray(): array
    {
        return [
            'id' => $this->id,
            'judul' => $this->judul,
            'selesai' => $this->selesai,
        ];
    }
}

src/TaskRepository.php

Unduh berkas
php
<?php

declare(strict_types=1);

namespace App;

/**
 * Penyimpanan dan pengambilan tugas dari berkas JSON.
 *
 * Jalur berkas disuntikkan lewat konstruktor (dependency injection),
 * sehingga pengujian bisa memakai berkas sementara tanpa menyentuh
 * data sungguhan — tidak ada lagi jalur yang tertanam di dalam fungsi.
 */
final class TaskRepository
{
    public function __construct(
        private readonly string $berkas,
    ) {
    }

    /**
     * Kembalikan seluruh tugas tersimpan.
     *
     * @return list<Task>
     */
    public function semua(): array
    {
        return array_map(
            static fn (array $data): Task => Task::dariArray($data),
            $this->bacaBerkas(),
        );
    }

    /**
     * Tambah tugas baru dan simpan; kembalikan tugas yang tersimpan.
     */
    public function tambah(string $judul): Task
    {
        $judul = trim($judul);

        if ($judul === '') {
            throw new \InvalidArgumentException('Judul tugas tidak boleh kosong.');
        }

        $baris = $this->bacaBerkas();
        $baru = new Task($this->idBerikutnya($baris), $judul);

        $baris[] = $baru->toArray();
        $this->tulisBerkas($baris);

        return $baru;
    }

    /**
     * Tandai tugas selesai dan simpan; kembalikan versi terbarunya.
     */
    public function tandaiSelesai(int $id): Task
    {
        $baris = $this->bacaBerkas();

        foreach ($baris as $i => $data) {
            if ((int) $data['id'] === $id) {
                $baris[$i] = Task::dariArray($data)->tandaiSelesai()->toArray();
                $this->tulisBerkas($baris);

                return Task::dariArray($baris[$i]);
            }
        }

        throw new \RuntimeException("Tugas dengan ID {$id} tidak ditemukan.");
    }

    /**
     * Hapus tugas dan simpan.
     */
    public function hapus(int $id): void
    {
        $baris = $this->bacaBerkas();

        foreach ($baris as $i => $data) {
            if ((int) $data['id'] === $id) {
                array_splice($baris, $i, 1);
                $this->tulisBerkas($baris);

                return;
            }
        }

        throw new \RuntimeException("Tugas dengan ID {$id} tidak ditemukan.");
    }

    /**
     * ID berikutnya: selalu terbesar yang ada ditambah satu.
     *
     * @param list<array> $baris
     */
    private function idBerikutnya(array $baris): int
    {
        $terbesar = 0;

        foreach ($baris as $data) {
            $terbesar = max($terbesar, (int) $data['id']);
        }

        return $terbesar + 1;
    }

    /**
     * Baca berkas JSON mentah sebagai daftar baris tugas.
     *
     * @return list<array>
     */
    private function bacaBerkas(): array
    {
        if (!is_file($this->berkas)) {
            return [];
        }

        $isi = file_get_contents($this->berkas);

        if ($isi === false) {
            throw new \RuntimeException("Gagal membaca berkas tugas: {$this->berkas}");
        }

        if (!json_validate($isi)) {
            throw new \RuntimeException('Berkas tugas rusak: JSON tidak valid.');
        }

        $baris = json_decode($isi, true);

        if (!is_array($baris)) {
            throw new \RuntimeException('Berkas tugas rusak: format tidak dikenal.');
        }

        return $baris;
    }

    /**
     * Tulis seluruh baris tugas ke berkas JSON.
     *
     * @param list<array> $baris
     */
    private function tulisBerkas(array $baris): void
    {
        $json = json_encode(
            $baris,
            JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
        );

        if (file_put_contents($this->berkas, $json . "\n", LOCK_EX) === false) {
            throw new \RuntimeException("Gagal menulis berkas tugas: {$this->berkas}");
        }
    }
}

tests/TaskRepositoryTest.php

Unduh berkas
php
<?php

declare(strict_types=1);

namespace App\Tests;

use App\Task;
use App\TaskRepository;
use PHPUnit\Framework\TestCase;

// Tanpa vendor/ hasil `composer install`, kita muat class secara manual.
// Setelah composer install, autoload PSR-4 yang mengambil alih peran ini.
require_once __DIR__ . '/../src/Task.php';
require_once __DIR__ . '/../src/TaskRepository.php';

final class TaskRepositoryTest extends TestCase
{
    private string $berkas;

    private TaskRepository $repository;

    protected function setUp(): void
    {
        // Fixture segar untuk tiap test: berkas unik di direktori
        // temporari, saling tidak mengganggu antar-test.
        $this->berkas = sys_get_temp_dir() . '/tugas-test-' . uniqid('', true) . '.json';
        $this->repository = new TaskRepository($this->berkas);
    }

    protected function tearDown(): void
    {
        if (is_file($this->berkas)) {
            unlink($this->berkas);
        }
    }

    public function testTambahMemberiIdBerurutan(): void
    {
        $pertama = $this->repository->tambah('Belajar PHP dasar');
        $kedua = $this->repository->tambah('Belajar OOP');

        $this->assertSame(1, $pertama->id);
        $this->assertSame(2, $kedua->id);
        $this->assertSame('Belajar PHP dasar', $pertama->judul);
        $this->assertFalse($pertama->selesai);
    }

    public function testSemuaMengembalikanSeluruhTugasTersimpan(): void
    {
        $this->repository->tambah('Belajar PHP dasar');
        $this->repository->tambah('Belajar OOP');

        $semua = $this->repository->semua();

        $this->assertCount(2, $semua);
        $this->assertSame('Belajar PHP dasar', $semua[0]->judul);
        $this->assertSame('Belajar OOP', $semua[1]->judul);
    }

    public function testSemuaMengembalikanKosongBilaBerkasBelumAda(): void
    {
        $this->assertSame([], $this->repository->semua());
    }

    public function testTandaiSelesaiMenyimpanStatusBaru(): void
    {
        $this->repository->tambah('Belajar PHP dasar');

        $hasil = $this->repository->tandaiSelesai(1);

        $this->assertTrue($hasil->selesai);
        $this->assertSame(1, $hasil->id);
        $this->assertTrue($this->repository->semua()[0]->selesai);
    }

    public function testHapusMengeluarkanTugasDariPenyimpanan(): void
    {
        $this->repository->tambah('Belajar PHP dasar');
        $this->repository->tambah('Belajar OOP');

        $this->repository->hapus(1);

        $semua = $this->repository->semua();
        $this->assertCount(1, $semua);
        $this->assertSame('Belajar OOP', $semua[0]->judul);
    }

    public function testTambahJudulKosongMelemparException(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Judul tugas tidak boleh kosong.');

        $this->repository->tambah('   ');
    }

    public function testHapusIdTakDikenalMelemparException(): void
    {
        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('ID 99');

        $this->repository->hapus(99);
    }

    public function testTandaiSelesaiIdTakDikenalMelemparException(): void
    {
        $this->expectException(\RuntimeException::class);

        $this->repository->tandaiSelesai(42);
    }

    public function testBerkasRusakMelemparException(): void
    {
        file_put_contents($this->berkas, '[{"id":1,"judul":"Belajar PHP dasar"');

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('JSON tidak valid');

        $this->repository->semua();
    }

    public function testPenyimpananBertahanAntarInstanceRepository(): void
    {
        $this->repository->tambah('Belajar PHP dasar');

        // Instance baru pada jalur berkas yang sama harus melihat
        // data yang sama — inilah arti persistensi.
        $repositoryBaru = new TaskRepository($this->berkas);

        $semua = $repositoryBaru->semua();
        $this->assertCount(1, $semua);
        $this->assertSame('Belajar PHP dasar', $semua[0]->judul);
    }

    public function testBerkasDisimpanSebagaiJsonMudahDibaca(): void
    {
        $this->repository->tambah('Belajar PHP dasar');

        $isi = (string) file_get_contents($this->berkas);

        $this->assertStringContainsString('"judul": "Belajar PHP dasar"', $isi);
        $this->assertStringContainsString('"selesai": false', $isi);
    }

    public function testTaskImmutableSaatTandaiSelesai(): void
    {
        $asli = new Task(id: 7, judul: 'Tugas contoh');
        $baru = $asli->tandaiSelesai();

        $this->assertNotSame($asli, $baru);
        $this->assertFalse($asli->selesai);
        $this->assertTrue($baru->selesai);
        $this->assertSame(7, $baru->id);
        $this->assertSame('Tugas contoh', $baru->judul);
    }

    public function testTaskRoundTripMelaluiArray(): void
    {
        $asli = new Task(id: 3, judul: 'Belajar PHP dasar', selesai: true);

        $hasil = Task::dariArray($asli->toArray());

        $this->assertSame(3, $hasil->id);
        $this->assertSame('Belajar PHP dasar', $hasil->judul);
        $this->assertTrue($hasil->selesai);
    }
}