Enam bagian sebelumnya selalu menutup pekerjaan dengan ritual yang sama: nyalakan web server bawaan, kirim request lewat curl, lalu periksa hasilnya dengan mata. Cara itu jujur dan tepat untuk belajar, tetapi tidak skala — tidak ada yang mau mengulang enam walkthrough setiap kali satu baris kode berubah, dan mata yang lelah mudah meloloskan regresi masuk diam-diam. Bagian penutup ini menyerahkan pembuktian kepada PHPUnit: delapan test mengunci perilaku PostRepository, class yang dilalui seluruh fitur blog, terhadap database SQLite sungguhan yang dibangun ulang untuk setiap test. Setelah itu proyek ditutup dengan kemasan Docker — satu image yang memuat PHP, kode, dan prosedur migrasi database, sehingga blog berjalan identik di mesin mana pun. Dua kata kunci penutupnya: test memberi kepercayaan bahwa kode masih benar setelah diubah, image memberi kepastian bahwa lingkungan berjalan sama di mana pun.

Dari Uji Manual ke Test Otomatis #

Test otomatis memberi tiga hal yang walkthrough manual tidak bisa. Pertama, kecepatan: seluruh rangkaian test blog ini selesai dalam hitungan milidetik, sehingga boleh dijalankan sesering apa pun — setiap simpan berkas, setiap commit. Kedua, ketelitian mesin: assertCount(3, $semua) tidak pernah lupa menghitung dan membandingkan, tidak seperti mata yang mulai lelah di pemeriksaan kelima. Ketiga, dokumentasi yang hidup: nama test yang baik terbaca seperti daftar perilaku yang dijanjikan kode, dan perilaku yang tidak dijanjikan tidak akan lulus.

Arah pengujian kita adalah PostRepository — keputusan yang tidak acak. Class versi Bagian 3 itu adalah jantung persistensi blog: beranda membacanya lewat terbaru(), halaman detail lewat cariSlug(), panel admin Bagian 4 menulis lewat simpan() dan hapus(), dan Validator Bagian 6 memeriksa keunikan slug lewat cariSlug(). Dengan mengunci repository, kita mengunci kontrak yang disandarkan seluruh fitur di atasnya. Auth dan Validator menyusul dengan pola yang sama — itulah latihan alami setelah menyelesaikan bagian ini.

composer.json: Kontrak Proyek #

Sebelum menulis test, proyek diberi berkas yang mendeskripsikan dirinya. composer.json berikut berada di bundle bagian ini:

json
{
    "name": "kursus/website-blog",
    "description": "Blog utuh dengan PHP murni — proyek kursus PHP: routing, template, PDO, admin CRUD, authentication, CSRF, pengujian PHPUnit, dan kemasan Docker.",
    "type": "project",
    "license": "MIT",
    "require": {
        "php": ">=8.4",
        "ext-pdo_sqlite": "*"
    },
    "require-dev": {
        "phpunit/phpunit": "^12.5"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\": "tests/"
        }
    },
    "scripts": {
        "test": "phpunit --testdox tests"
    },
    "config": {
        "sort-packages": true
    }
}

Enam bagian proyek berjalan tanpa Composer sama sekali — dan itu memang benar: tidak satu pun kode kita membutuhkan pustaka eksternal. Berkas ini justru menyatakan persyaratan platform dan peta autoload. Bagian require menuntut PHP 8.4 ke atas beserta ekstensi pdo_sqlite; Composer memeriksa keduanya saat composer install sehingga lingkungan yang tidak memenuhi gagal cepat dengan pesan yang jelas. Bagian require-dev menaruh PHPUnit sebagai dependensi pengembangan — dengan composer install --no-dev di server produksi, PHPUnit tidak ikut terpasang. Skrip test membuat composer test cukup satu perintah untuk menjalankan phpunit --testdox tests.

Dua blok autoload adalah intinya. Standar PSR-4 memetakan prefix App\ ke folder src/, artinya class App\PostRepository dicari di src/PostRepository.php — persis tata letak yang dipakai proyek ini sejak Bagian 2. Blok autoload-dev melakukan hal yang sama untuk prefix App\Tests\ ke folder tests/, tempat class PostRepositoryTest hidup.

Test Suite: PostRepositoryTest #

Satu test PHPUnit yang benar dimulai dari dunia yang bersih. Method setUp() berjalan sebelum setiap test, dan tugasnya membangun dunia itu: koneksi PDO ke SQLite :memory: — database yang hidup hanya di RAM proses — lalu menjalankan schema.sql milik Bagian 3 terhadapnya. Cuplikan berikut adalah inti setUp() versi ringkas; simpan di dalam folder bagian ini untuk menjalankannya:

php
<?php

// Inti setUp() versi ringkas: database RAM + schema.sql Bagian 3.
// Path relatif mengikuti tata letak kursus: folder bagian-7 ini adalah
// saudara dari folder bagian-3 di dalam website-blog/, jadi satu tingkat
// naik dari sini (../) lalu masuk ke folder bagian saudaranya.
$pdo = new PDO('sqlite::memory:', null, null, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec((string) file_get_contents(
    __DIR__ . '/../bagian-3-database-dan-persistensi/database/schema.sql'
));

// Inilah dunia yang dilihat setiap test sebelum menyentuh method apa pun.
foreach ($pdo->query('SELECT id, slug, dibuat_pada FROM posts ORDER BY id') as $baris) {
    printf("%d  %-34s %s\n", $baris['id'], $baris['slug'], $baris['dibuat_pada']);
}

Output yang Dihasilkan:

text
1  kenapa-php-masih-relevan           2026-01-03 09:15:00
2  membangun-router-sederhana         2026-01-10 14:40:00
3  tips-aman-menampilkan-data-pengguna 2026-01-17 20:05:00

Tiga artikel contoh itulah seluruh isi dunia setiap test — tanggalnya beku, slug-nya tetap, jumlahnya tiga. Dua keputusan desain bekerja di sini. Pertama, :memory: berarti test tidak pernah menyentuh disk dan tidak mungkin merusak database pengembangan; database lenyap saat proses PHP berakhir. Kedua, schema dimuat dari berkas yang sama dengan produksi (schema.sql Bagian 3), bukan salinan di dalam test — bila schema berubah, test otomatis mengikuti, dan penyimpangan schema produksi versus schema test tidak punya tempat bersembunyi. Berikut isi lengkap tests/PostRepositoryTest.php:

php
<?php

declare(strict_types=1);

namespace App\Tests;

use App\PostRepository;
use PDO;
use PDOException;
use PHPUnit\Framework\TestCase;

/**
 * Test PostRepository — jantung persistensi website blog.
 *
 * PostRepository versi Bagian 3 (PDO + SQLite) adalah titik temu seluruh
 * fitur: beranda membacanya, halaman detail mencarinya, panel admin pada
 * Bagian 4 menulis lewat simpan()/hapus(), dan Validator Bagian 6
 * memeriksa keunikan slug lewat cariSlug(). Test pada berkas ini
 * mengunci perilaku method tersebut terhadap database sungguhan —
 * bukan tiruan.
 *
 * Tata letak kursus: folder tiap bagian adalah saudara di dalam folder
 * website-blog/, sehingga class dimuat lewat path relatif
 * ../../bagian-3-database-dan-persistensi/src/PostRepository.php —
 * dua tingkat naik dari tests/ lalu masuk ke folder bagian saudara.
 * Pada proyek rakitan (satu root src/ seperti dideklarasikan composer.json),
 * baris require_once ini digantikan autoloader Composer.
 */
require_once __DIR__ . '/../../bagian-3-database-dan-persistensi/src/PostRepository.php';

final class PostRepositoryTest extends TestCase
{
    private PostRepository $posts;

    protected function setUp(): void
    {
        // Database segar di RAM untuk SETIAP test: schema.sql Bagian 3
        // dijalankan ulang dari nol sehingga test tidak saling
        // meninggalkan jejak dan boleh dijalankan urutan apa pun.
        $pdo = new PDO('sqlite::memory:', null, null, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
        $pdo->exec((string) file_get_contents(
            __DIR__ . '/../../bagian-3-database-dan-persistensi/database/schema.sql'
        ));

        $this->posts = new PostRepository($pdo);
    }

    public function testSemuaMengembalikanSeluruhArtikelTerbaruDuluan(): void
    {
        $semua = $this->posts->semua();

        $this->assertCount(3, $semua);
        $this->assertSame('tips-aman-menampilkan-data-pengguna', $semua[0]['slug']);
        $this->assertSame('kenapa-php-masih-relevan', $semua[2]['slug']);
    }

    public function testTerbaruMembatasiHasilDenganUrutanTerbaruDuluan(): void
    {
        $dua = $this->posts->terbaru(2);

        $this->assertCount(2, $dua);
        $this->assertSame('tips-aman-menampilkan-data-pengguna', $dua[0]['slug']);
        $this->assertSame('membangun-router-sederhana', $dua[1]['slug']);
        $this->assertCount(0, $this->posts->terbaru(0));
    }

    public function testCariSlugMengembalikanArtikelYangCocok(): void
    {
        $post = $this->posts->cariSlug('membangun-router-sederhana');

        $this->assertNotNull($post);
        $this->assertSame(2, $post['id']);
        $this->assertSame('Membangun Router Sederhana Tanpa Framework', $post['judul']);
    }

    public function testCariSlugTidakDikenalMengembalikanNull(): void
    {
        $this->assertNull($this->posts->cariSlug('tidak-ada'));
    }

    public function testSimpanInsertMengembalikanIdBaru(): void
    {
        $id = $this->posts->simpan([
            'slug' => 'uji-phpunit-dari-nol',
            'judul' => 'Uji PHPUnit dari Nol',
            'konten' => 'Test yang menguji repository sungguhan, bukan tiruan.',
            'dibuat_pada' => '2026-01-29 08:00:00',
        ]);

        $this->assertSame(4, $id);

        $tersimpan = $this->posts->cariId($id);
        $this->assertNotNull($tersimpan);
        $this->assertSame('Uji PHPUnit dari Nol', $tersimpan['judul']);
    }

    public function testSimpanUpdateMengubahDataLama(): void
    {
        $this->posts->simpan([
            'id' => 1,
            'slug' => 'kenapa-php-masih-relevan',
            'judul' => 'Kenapa PHP Masih Relevan (edisi revisi)',
            'konten' => 'Konten revisi.',
            'dibuat_pada' => '2026-01-03 09:15:00',
        ]);

        $this->assertSame(
            'Kenapa PHP Masih Relevan (edisi revisi)',
            $this->posts->cariId(1)['judul']
        );
        // UPDATE tidak menambah baris baru.
        $this->assertCount(3, $this->posts->semua());
    }

    public function testSimpanSlugDuplikatMelemparPdoException(): void
    {
        $this->expectException(PDOException::class);
        $this->expectExceptionMessage('UNIQUE constraint failed: posts.slug');

        $this->posts->simpan([
            'slug' => 'kenapa-php-masih-relevan',
            'judul' => 'Artikel Kembar',
            'konten' => 'Slug sudah dipakai artikel id 1.',
            'dibuat_pada' => '2026-01-29 09:00:00',
        ]);
    }

    public function testHapusMenghapusArtikelDariDatabase(): void
    {
        $this->posts->hapus(2);

        $this->assertNull($this->posts->cariSlug('membangun-router-sederhana'));
        $this->assertCount(2, $this->posts->semua());
    }
}

Delapan test itu masing-masing menjaga satu janji repository:

TestJanji yang dijaga
testSemuaMengembalikanSeluruhArtikelTerbaruDuluansemua() memuat seluruh artikel, terbaru duluan
testTerbaruMembatasiHasilDenganUrutanTerbaruDuluanterbaru(n) memangkas hasil tanpa merusak urutan
testCariSlugMengembalikanArtikelYangCocokcariSlug() menemukan artikel yang tepat
testCariSlugTidakDikenalMengembalikanNullslug asing membalas null, bukan error
testSimpanInsertMengembalikanIdBaruINSERT mengembalikan id baru dan datanya tersimpan utuh
testSimpanUpdateMengubahDataLamaUPDATE mengubah baris lama tanpa menambah baris
testSimpanSlugDuplikatMelemparPdoExceptionconstraint UNIQUE tetap menjadi benteng terakhir
testHapusMenghapusArtikelDariDatabaseDELETE benar-benar menghilangkan artikel

Beberapa teknik pada berkas tersebut layak dibedah. assertSame() dipilih bukan assertEquals() karena perbandingannya ketat (===): id 4 lulus, string "4" gagal — dan itu memang aman dipakai karena PDO driver SQLite mengembalikan tipe asli sejak PHP 8.1, sehingga kolom INTEGER datang sebagai int. Test INSERT memilih assertSame(4, $id) karena tiga baris seed memakai id 1-3 dan AUTOINCREMENT melanjutkan ke 4 — angka beku, bukan kebetulan. Adapun expectException() adalah adegan penutup test: setelah dua baris deklarasi expectation, panggilan simpan() dengan slug kembar memang harus melempar PDOException; bila tidak melempar, PHPUnit menandai test gagal karena expectation tak terpenuhi. Janji ini mengunci benteng paling dasar yang dipasang sejak Bagian 3 — validasi aplikatif pada Bagian 6 boleh menolak duluan, tetapi database tetap tidak mungkin menyimpan slug ganda.

Menjalankan PHPUnit #

Dari folder bagian-7-testing-dan-deployment, jalankan PHPUnit langsung pada berkas testnya (sesuaikan path phpunit.phar dengan lokasinya di mesin Anda — atau jalankan composer test setelah composer install):

bash
php phpunit.phar tests/PostRepositoryTest.php

Hasil eksekusinya pada PHP 8.4.23 dengan PHPUnit 12.5.39:

text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

........                                                            8 / 8 (100%)

Time: 00:00.006, Memory: 28.05 MB

OK (8 tests, 20 assertions)

Delapan titik adalah delapan test yang lulus, dan 20 assertions berarti 20 pemeriksaan individual semuanya benar — lebih dari dua kali jumlah testnya, karena satu test memeriksa banyak sisi sekaligus. Baris Time dan Memory berbeda-beda antar mesin, tetapi baris OK beserta jumlahnya stabil. Tambahkan --testdox untuk tampilan yang terbaca seperti daftar perilaku:

bash
php phpunit.phar tests/PostRepositoryTest.php --testdox
text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

........                                                            8 / 8 (100%)

Time: 00:00.006, Memory: 28.05 MB

Post Repository (App\Tests\PostRepository)
 ✔ Semua mengembalikan seluruh artikel terbaru duluan
 ✔ Terbaru membatasi hasil dengan urutan terbaru duluan
 ✔ Cari slug mengembalikan artikel yang cocok
 ✔ Cari slug tidak dikenal mengembalikan null
 ✔ Simpan insert mengembalikan id baru
 ✔ Simpan update mengubah data lama
 ✔ Simpan slug duplikat melempar pdo exception
 ✔ Hapus menghapus artikel dari database

OK (8 tests, 20 assertions)

Daftar bercentang itulah spesifikasi repository blog dalam bentuk paling ringkas. Setiap kali kode berubah, satu perintah memastikan kedelapan janji masih berlaku — dan bila satu rusak, nama test yang gagal langsung menunjuk janji mana yang dilanggar. Bandingkan dengan Bagian 1-6: ritual curl dan mata manusia kini menjadi pemeriksaan penutup yang sesekali, bukan satu-satunya jaring pengaman.

Dockerfile: Blog yang Berjalan di Mana Saja #

Test menjaga kebenaran kode; deployment menjaga kebenaran lingkungan tempat kode berjalan. Kalimat klasik “di mesin saya normal” lahir dari tiga sumber perbedaan antar mesin: versi PHP, ekstensi yang terpasang, dan konfigurasi server. Docker menyapu ketiganya dengan satu perintah docker build: image hasilnya memuat PHP versi tertentu beserta ekstensinya, kode blog, dan prosedur menyiapkan database — sehingga docker run di mesin mana pun menghasilkan blog yang identik. Dockerfile kita membangun dari susunan rakitan proyek, yaitu folder website-blog/ setelah seluruh bagian digabungkan (tiap class diambil dari bagian versi terakhirnya, dan schema.sql memuat tabel posts Bagian 3 plus tabel users yang digabungkan pada Bagian 5):

text
website-blog/
├── public/                 front controller — evolusi Bagian 1 → 6
├── templates/              layout, home, post, login, admin/
├── src/                    App\: PostRepository, Database, AdminController,
│                           Auth, UserRepository, Validator, Csrf
├── database/
│   └── schema.sql          posts (Bagian 3) + users (Bagian 5)
├── tests/
│   └── PostRepositoryTest.php   Bagian 7 — tidak ikut disalin ke image
├── composer.json           Bagian 7
└── Dockerfile              Bagian 7

Berikut isi lengkap Dockerfile pada bundle bagian ini:

dockerfile
# Dockerfile — mengemas website blog hasil rakitan Bagian 1-6.
#
# Build context adalah folder proyek rakitan (susunan final), bukan folder
# per bagian:
#
#   website-blog/
#   ├── public/       front controller — satu pintu masuk semua request
#   ├── templates/    layout, halaman, formulir admin
#   ├── src/          class App\ (satu root, sesuai PSR-4 composer.json)
#   ├── database/     schema.sql (tabel posts + users)
#   ├── tests/        test PHPUnit (tidak ikut disalin)
#   └── Dockerfile
#
#   docker build -t website-blog .
#   docker run --rm -p 8080:8080 -v blog-data:/data website-blog

FROM php:8.4-cli-alpine

WORKDIR /app

# Salin selektif: hanya kode yang dibutuhkan runtime. Folder tests/ dan
# berkas database lokal tidak ikut — test milik mesin pengembang, data
# milik volume produksi.
COPY public/ public/
COPY templates/ templates/
COPY src/ src/
COPY database/ database/

# Database hidup di /data — DI LUAR /app — supaya bisa dipasang sebagai
# volume dan selamat saat image diganti. Nama variabelnya sengaja BLOG_DB
# karena itulah yang dibaca Database::koneksi() sejak Bagian 3.
ENV BLOG_DB=/data/blog.sqlite

RUN mkdir -p /data

EXPOSE 8080

# Migrasi dijalankan saat CONTAINER MULAI, bukan saat build: /data nantinya
# berupa volume yang menimpa apa pun yang dibakar ke dalam image, jadi
# migrasi saat build akan sia-sia. schema.sql memakai CREATE IF NOT EXISTS
# dan INSERT OR IGNORE sehingga start kedua dan seterusnya aman.
CMD ["sh", "-c", "php -r '$pdo = new PDO(\"sqlite:\" . getenv(\"BLOG_DB\")); $pdo->exec((string) file_get_contents(\"database/schema.sql\")); echo \"Migrasi: \", $pdo->query(\"SELECT COUNT(*) FROM posts\")->fetchColumn(), \" artikel contoh\", PHP_EOL;' && exec php -S 0.0.0.0:8080 public/index.php"]

Mari bedah Dockerfile ini baris demi baris. FROM php:8.4-cli-alpine memilih image PHP resmi versi CLI berbasis Alpine Linux — varian paling ringkas — yang sudah memuat ekstensi pdo_sqlite, jadi tidak ada langkah instalasi ekstensi. Empat baris COPY menyalin selektif hanya folder yang dibutuhkan runtime; tests/, composer.json, dan berkas database lokal tidak ikut, sehingga image membawa aplikasi saja. ENV BLOG_DB=/data/blog.sqlite menunjuk lokasi database ke luar /app — nama variabelnya sama persis dengan yang dibaca Database::koneksi() sejak Bagian 3, jadi tidak ada satu baris pun kode aplikasi yang perlu diubah untuk pindah ke container. RUN mkdir -p /data menyiapkan foldernya; EXPOSE 8080 mendokumentasikan port yang dipakai.

Baris CMD adalah jantungnya dan memuat dua keputusan. Pertama, migrasi dijalankan saat container mulai — bukan saat build — karena /data kelak dipasang sebagai volume yang menimpa apa pun yang dibakar ke dalam image; migrasi build-time akan percuma. Perintah php -r membuka PDO ke path dari BLOG_DB, menjalankan database/schema.sql, lalu melaporkan jumlah artikel contoh. Kedua, exec php -S 0.0.0.0:8080 public/index.php menyalakan server web bawaan PHP dengan front controller sebagai router — pola persis Bagian 1, kini di dalam container. Kata exec membuat proses PHP menggantikan sh sebagai proses utama, sehingga sinyal berhenti sampai ke server tanpa perantara.

Perintah membangun dan menjalankannya:

bash
# dari folder proyek rakitan (susunan final di atas)
docker build -t website-blog .
docker run --rm -p 8080:8080 -v blog-data:/data website-blog

Flag -p 8080:8080 meneruskan port container ke host, dan -v blog-data:/data memasang volume bernama blog-data ke /data — artikel yang ditulis lewat panel admin bertahan di volume itu, aman dari pergantian image. Untuk mencoba panel admin di dalam container, tanam admin pertama dengan pola buat-admin Bagian 5 (misalnya lewat docker exec), karena pengguna tidak ikut di-seed oleh schema.

Checklist Deployment #

Rangkuman seluruh proyek dalam bentuk daftar periksa — pakai ini sebelum blog dikirim ke mana pun:

  • Seluruh test hijau: php phpunit.phar tests/PostRepositoryTest.php berakhir OK; bila tidak, perbaiki dulu, jangan kirim.
  • Susunan rakitan beres: public/, templates/, src/ (versi final tiap class), database/schema.sql (posts + users), composer.json — tanpa berkas sampah seperti kuki.txt atau .sqlite lokal di build context.
  • Lokasi database lewat variabel lingkungan BLOG_DB ke luar folder aplikasi, dan dipasang sebagai volume (-v blog-data:/data) — uji dengan mematikan lalu menyalakan container: artikel harus tetap ada.
  • Document root menunjuk ke public/ (atau seluruh request melewati front controller) — berkas src/, templates/, dan database/ tidak boleh bisa diminta langsung lewat HTTP.
  • Admin pertama ditanam sekali (pola buat-admin Bagian 5); kata sandi tidak pernah ditulis di berkas yang ikut image.
  • Produksi: php-fpm + nginx menggantikan server bawaan, HTTPS aktif di depan, dan backup rutin untuk berkas blog.sqlite (berkas tunggal — salin saat server berhenti atau lewat sqlite3 blog.sqlite ".backup ...").

Apa yang Berubah #

Dibandingkan Bagian 6 yang memasang pertahanan terakhir, tahap ini tidak menambah fitur blog sama sekali — dan memang begitulah seharusnya bagian penutup:

  • composer.json — kontrak proyek: platform (php >=8.4, ext-pdo_sqlite), PHPUnit sebagai dependensi dev, autoload PSR-4 App\ → src/ dan App\Tests\ → tests/, serta skrip test.
  • tests/PostRepositoryTest.php — delapan test, 20 assertion: setUp() membangun database :memory: segar dari schema.sql Bagian 3 untuk setiap test; seluruh operasi baca-tulis repository diuji terhadap SQLite sungguhan, termasuk benteng UNIQUE pada slug.
  • Dockerfile — image php:8.4-cli-alpine dengan COPY selektif, database di /data lewat BLOG_DB, migrasi idempoten saat container mulai, lalu server bawaan PHP sebagai perintah utama.

Blog yang tadinya hanya bisa dibuktikan lewat enam walkthrough manual kini punya jaring pengaman milidetik dan kemasan yang berjalan di mana saja.

Cara Menjalankan #

Dari direktori bagian-7-testing-dan-deployment/ (folder bundle halaman ini), dengan tetangga bagian 1-6 utuh di sampingnya:

bash
# 1. Test suite (sesuaikan path phpunit.phar, atau: composer test
#    setelah composer install)
php phpunit.phar tests/PostRepositoryTest.php
php phpunit.phar tests/PostRepositoryTest.php --testdox

# 2. Pemeriksaan sintaks berkas bundle
php -l tests/PostRepositoryTest.php

Untuk kemasan Docker, rakit proyek final sesuai struktur di atas terlebih dahulu (salin tiap class dari bagian versi terakhirnya), lalu dari folder rakitan:

bash
docker build -t website-blog .
docker run --rm -p 8080:8080 -v blog-data:/data website-blog

Tiga berkas pendukung halaman ini — composer.json, tests/PostRepositoryTest.php, dan Dockerfile — tersedia di bagian bawah halaman. Untuk pendalaman, baca dokumentasi resmi PHPUnit 12 , penggunaan dasar Composer , serta antarmuka baris perintah PHP .

Ringkasan #

Tujuh bagian yang baru kita jalani membentuk satu cerita utuh tentang membangun web tanpa framework. Bagian 1 mendirikan disiplin arsitektur: satu pintu masuk di public/index.php, tabel rute, layout yang membungkus konten. Bagian 2 memperkenalkan repository — kontrak baca data yang tetap utuh ketika Bagian 3 mengganti isinya dari array memori menjadi query PDO ke SQLite, dengan schema yang dirancang sejak awal untuk aman dijalankan ulang. Bagian 4 mengangkat controller murni dan pola PRG, Bagian 5 menambah authentication dan authorization berbasis sesi, dan Bagian 6 menaikkan validasi ke class mandiri sembari memasang token CSRF di setiap formulir. Bagian 7 ini menutup dengan dua kebiasaan yang membedakan proyek latihan dari proyek hidup: pembuktian otomatis dan kemasan yang bisa dikirim.

Benang merahnya layak dibawa pergi. Kontrak yang tidak berubah saat isi berganti (repository Bagian 2 ke Bagian 3) membuat penggantian jantung persistensi tidak merusak satu baris pemanggil pun. Keputusan kecil yang diambil lebih awal — IF NOT EXISTS pada schema, variabel lingkungan BLOG_DB, seluruh request lewat satu front controller — terbayar justru di bagian akhir, ketika migrasi container dan volume produksi memakai semuanya tanpa perubahan. Keamanan tumbuh sebagai lapisan yang saling menopang, dan test mengunci seluruhnya. Proyek berikutnya, REST API manajemen tugas , membawa keterampilan ini ke medium yang berbeda: HTTP kembali menjadi pusat, tetapi responsnya bukan lagi HTML — melainkan JSON yang dikonsumsi program lain.

Berkas Sumber

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

composer.json

Unduh berkas
json
{
    "name": "kursus/website-blog",
    "description": "Blog utuh dengan PHP murni — proyek kursus PHP: routing, template, PDO, admin CRUD, authentication, CSRF, pengujian PHPUnit, dan kemasan Docker.",
    "type": "project",
    "license": "MIT",
    "require": {
        "php": ">=8.4",
        "ext-pdo_sqlite": "*"
    },
    "require-dev": {
        "phpunit/phpunit": "^12.5"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\": "tests/"
        }
    },
    "scripts": {
        "test": "phpunit --testdox tests"
    },
    "config": {
        "sort-packages": true
    }
}

tests/PostRepositoryTest.php

Unduh berkas
php
<?php

declare(strict_types=1);

namespace App\Tests;

use App\PostRepository;
use PDO;
use PDOException;
use PHPUnit\Framework\TestCase;

/**
 * Test PostRepository — jantung persistensi website blog.
 *
 * PostRepository versi Bagian 3 (PDO + SQLite) adalah titik temu seluruh
 * fitur: beranda membacanya, halaman detail mencarinya, panel admin pada
 * Bagian 4 menulis lewat simpan()/hapus(), dan Validator Bagian 6
 * memeriksa keunikan slug lewat cariSlug(). Test pada berkas ini
 * mengunci perilaku method tersebut terhadap database sungguhan —
 * bukan tiruan.
 *
 * Tata letak kursus: folder tiap bagian adalah saudara di dalam folder
 * website-blog/, sehingga class dimuat lewat path relatif
 * ../../bagian-3-database-dan-persistensi/src/PostRepository.php —
 * dua tingkat naik dari tests/ lalu masuk ke folder bagian saudara.
 * Pada proyek rakitan (satu root src/ seperti dideklarasikan composer.json),
 * baris require_once ini digantikan autoloader Composer.
 */
require_once __DIR__ . '/../../bagian-3-database-dan-persistensi/src/PostRepository.php';

final class PostRepositoryTest extends TestCase
{
    private PostRepository $posts;

    protected function setUp(): void
    {
        // Database segar di RAM untuk SETIAP test: schema.sql Bagian 3
        // dijalankan ulang dari nol sehingga test tidak saling
        // meninggalkan jejak dan boleh dijalankan urutan apa pun.
        $pdo = new PDO('sqlite::memory:', null, null, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
        $pdo->exec((string) file_get_contents(
            __DIR__ . '/../../bagian-3-database-dan-persistensi/database/schema.sql'
        ));

        $this->posts = new PostRepository($pdo);
    }

    public function testSemuaMengembalikanSeluruhArtikelTerbaruDuluan(): void
    {
        $semua = $this->posts->semua();

        $this->assertCount(3, $semua);
        $this->assertSame('tips-aman-menampilkan-data-pengguna', $semua[0]['slug']);
        $this->assertSame('kenapa-php-masih-relevan', $semua[2]['slug']);
    }

    public function testTerbaruMembatasiHasilDenganUrutanTerbaruDuluan(): void
    {
        $dua = $this->posts->terbaru(2);

        $this->assertCount(2, $dua);
        $this->assertSame('tips-aman-menampilkan-data-pengguna', $dua[0]['slug']);
        $this->assertSame('membangun-router-sederhana', $dua[1]['slug']);
        $this->assertCount(0, $this->posts->terbaru(0));
    }

    public function testCariSlugMengembalikanArtikelYangCocok(): void
    {
        $post = $this->posts->cariSlug('membangun-router-sederhana');

        $this->assertNotNull($post);
        $this->assertSame(2, $post['id']);
        $this->assertSame('Membangun Router Sederhana Tanpa Framework', $post['judul']);
    }

    public function testCariSlugTidakDikenalMengembalikanNull(): void
    {
        $this->assertNull($this->posts->cariSlug('tidak-ada'));
    }

    public function testSimpanInsertMengembalikanIdBaru(): void
    {
        $id = $this->posts->simpan([
            'slug' => 'uji-phpunit-dari-nol',
            'judul' => 'Uji PHPUnit dari Nol',
            'konten' => 'Test yang menguji repository sungguhan, bukan tiruan.',
            'dibuat_pada' => '2026-01-29 08:00:00',
        ]);

        $this->assertSame(4, $id);

        $tersimpan = $this->posts->cariId($id);
        $this->assertNotNull($tersimpan);
        $this->assertSame('Uji PHPUnit dari Nol', $tersimpan['judul']);
    }

    public function testSimpanUpdateMengubahDataLama(): void
    {
        $this->posts->simpan([
            'id' => 1,
            'slug' => 'kenapa-php-masih-relevan',
            'judul' => 'Kenapa PHP Masih Relevan (edisi revisi)',
            'konten' => 'Konten revisi.',
            'dibuat_pada' => '2026-01-03 09:15:00',
        ]);

        $this->assertSame(
            'Kenapa PHP Masih Relevan (edisi revisi)',
            $this->posts->cariId(1)['judul']
        );
        // UPDATE tidak menambah baris baru.
        $this->assertCount(3, $this->posts->semua());
    }

    public function testSimpanSlugDuplikatMelemparPdoException(): void
    {
        $this->expectException(PDOException::class);
        $this->expectExceptionMessage('UNIQUE constraint failed: posts.slug');

        $this->posts->simpan([
            'slug' => 'kenapa-php-masih-relevan',
            'judul' => 'Artikel Kembar',
            'konten' => 'Slug sudah dipakai artikel id 1.',
            'dibuat_pada' => '2026-01-29 09:00:00',
        ]);
    }

    public function testHapusMenghapusArtikelDariDatabase(): void
    {
        $this->posts->hapus(2);

        $this->assertNull($this->posts->cariSlug('membangun-router-sederhana'));
        $this->assertCount(2, $this->posts->semua());
    }
}