Enam bagian sebelumnya membuktikan sifat-sifat proyek lewat walkthrough: kita membaca output, menghitung angka, dan mengangguk. Cara itu jujur tetapi tidak skala — tidak ada yang mau mengulang tujuh walkthrough setiap kali mengubah satu baris kode, dan mata manusia cepat lelah sehingga regresi lolos diam-diam. Pada bagian penutup ini kita menyerahkan pembuktian kepada PHPUnit: lima test menjalankan alur paling rawan (order atomik, stok kritis, transisi status, pembatalan, idempotensi pembayaran) terhadap database :memory: yang segar untuk setiap test, selesai dalam hitungan milidetik. Setelah itu proyek dikemas menjadi image Docker — schema bermigrasi saat build, dan sebuah smoke test memastikan container yang berjalan benar-benar bisa menerima order.
Dari Walkthrough ke Test Otomatis #
Test otomatis memberi tiga hal yang walkthrough tidak bisa. Pertama, kecepatan: seluruh rangkaian test proyek ini selesai di bawah sepuluh milidetik, sehingga boleh dijalankan sesering apa pun — setiap simpanan berkas, setiap commit. Kedua, ketelitian mesin: assertSame(12, $stok) tidak pernah lupa membandingkan, tidak seperti mata yang lelah di walkthrough keempat. Ketiga, dokumentasi yang hidup: nama test yang baik membaca seperti daftar perilaku yang dijanjikan sistem, dan yang tidak dijanjikan tidak akan lulus.
Arah pengujian kita adalah level service: test memanggil placeOrder(), bayar(), dan ubahStatus() seperti pemanggil sungguhan, lalu memeriksa keadaan database. Ini bukan unit test dalam arti sempit (satu function terisolasi), melainkan test alur transaksional — justru lapisan tempat bug paling mahal hidup, yaitu di perbatasan antar-service dan di dalam transaksi. Konsekuensi desainnya penting: setiap test membangun database :memory: yang segar lewat setUp(). Test tidak pernah berbagi keadaan, boleh dijalankan dalam urutan apa pun, dan kegagalan satu test tidak menular ke test lain.
Bootstrap: Memuat Proyek ke PHPUnit #
PHPUnit tidak tahu apa-apa tentang struktur folder proyek kita — class tersebar di src/ milik enam bagian berbeda tanpa autoloader Composer. Opsi --bootstrap memberi kita satu titik muat: berkas ini dijalankan sebelum test mana pun, memuat seluruh class lewat path relatif, dan menyediakan helper buatDatabase():
<?php
declare(strict_types=1);
/**
* Bootstrap PHPUnit untuk proyek sistem pemesanan.
*
* Berkas ini dimuat lewat opsi --bootstrap dan melakukan dua hal:
*
* 1. Memuat seluruh class proyek dari bagian-bagian sebelumnya lewat
* path relatif antarfolder: folder tests/ berada di dalam folder
* bagian-7-testing-dan-deployment, sedangkan folder bagian lain
* (bagian-2 sampai bagian-6) berada satu tingkat di atasnya, sehingga
* dijangkau lewat ../../bagian-NAMA-BAGIAN/src/NamaClass.php.
* 2. Menyediakan helper buatDatabase() yang membangun database SQLite
* :memory: SEGAR lengkap dengan schema.sql Bagian 1 ditambah tabel
* payments (Bagian 5) dan audit_log (Bagian 6). Tiap test memanggil
* helper ini di setUp() sehingga test saling independen.
*
* Jalankan dari folder bagian-7-testing-dan-deployment:
* php /path/ke/phpunit.phar tests/OrderServiceTest.php --bootstrap tests/bootstrap.php
*/
require __DIR__ . '/../../bagian-2-order-dan-order-items/src/OrderRepository.php';
require __DIR__ . '/../../bagian-3-order-processing/src/OrderService.php';
require __DIR__ . '/../../bagian-4-transactions-dan-inventory/src/InventoryService.php';
require __DIR__ . '/../../bagian-4-transactions-dan-inventory/src/Validator.php';
require __DIR__ . '/../../bagian-5-payment-integration/src/PaymentService.php';
require __DIR__ . '/../../bagian-5-payment-integration/src/FakeGateway.php';
require __DIR__ . '/../../bagian-6-reporting-dan-audit/src/ReportService.php';
require __DIR__ . '/../../bagian-6-reporting-dan-audit/src/AuditLogger.php';
function buatDatabase(): PDO
{
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
// Schema inti (customers, products, orders, order_items,
// stock_movements + data awal) dari Bagian 1.
$pdo->exec(
file_get_contents(__DIR__ . '/../../bagian-1-model-pelanggan-dan-produk/schema.sql')
);
// Tambahan schema Bagian 5 dan 6 — sama seperti pada demo kedua bagian itu.
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL UNIQUE REFERENCES orders(id),
gateway TEXT NOT NULL,
payment_id TEXT NOT NULL,
jumlah INTEGER NOT NULL CHECK (jumlah > 0),
status TEXT NOT NULL CHECK (status IN ('pending', 'settlement', 'gagal')),
redirect_url TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
aksi TEXT NOT NULL,
entitas TEXT NOT NULL,
entity_id INTEGER NOT NULL,
data_sebelum TEXT,
data_sesudah TEXT,
waktu TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_log_entitas ON audit_log(entitas, entity_id);
SQL);
return $pdo;
}Perhatikan bahwa buatDatabase() memuat schema.sql langsung dari folder Bagian 1 — satu sumber kebenaran untuk struktur tabel, bukan salinan schema di dalam test. Bila schema berubah, test otomatis mengikuti; penyimpangan antara schema produksi dan schema test adalah kelas bug klasik yang dengan desain ini tidak punya tempat bersembunyi. Helper juga memasang tabel payments dan audit_log dengan definisi yang persis sama seperti Bagian 5 dan 6, termasuk constraint UNIQUE yang diuji test idempotensi.
Test Alur Transaksional #
Berikut isi lengkap tests/OrderServiceTest.php — lima test yang membungkus seluruh janji terpenting proyek:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Test alur transaksional OrderService dan PaymentService.
*
* Setiap test mendapat database :memory: segar dari buatDatabase()
* (bootstrap.php) lewat setUp(), sehingga test tidak pernah saling
* memengaruhi dan boleh dijalankan dalam urutan apa pun.
*/
final class OrderServiceTest extends TestCase
{
private PDO $pdo;
private OrderRepository $repo;
private OrderService $service;
protected function setUp(): void
{
$this->pdo = buatDatabase();
$this->repo = new OrderRepository($this->pdo);
$this->service = new OrderService($this->pdo, $this->repo);
}
public function testPlaceOrderMenulisOrderDanMengurangiStok(): void
{
$orderId = $this->service->placeOrder(1, [[1, 2], [2, 1]]);
$order = $this->repo->cariId($orderId);
self::assertNotNull($order);
self::assertSame('pending', $order['status']);
self::assertCount(2, $order['items']);
// 2x Keyboard (2 x 75.000.000) + 1x Mouse (25.000.000) dalam sen.
self::assertSame(175000000, $order['total']);
// Stok produk #1: 12 - 2 = 10; produk #2: 25 - 1 = 24.
$stok1 = (int) $this->pdo->query('SELECT stok FROM products WHERE id = 1')->fetchColumn();
$stok2 = (int) $this->pdo->query('SELECT stok FROM products WHERE id = 2')->fetchColumn();
self::assertSame(10, $stok1);
self::assertSame(24, $stok2);
// Setiap item meninggalkan satu baris gerakan stok keluar.
$gerakan = (int) $this->pdo->query(
"SELECT COUNT(*) FROM stock_movements WHERE order_id = $orderId AND jenis = 'keluar'"
)->fetchColumn();
self::assertSame(2, $gerakan);
}
public function testPlaceOrderStokTidakCukupTidakMengubahApaPun(): void
{
$orderSebelum = (int) $this->pdo->query('SELECT COUNT(*) FROM orders')->fetchColumn();
$stokSebelum = (int) $this->pdo->query('SELECT stok FROM products WHERE id = 4')->fetchColumn();
try {
// Meminta 6 unit monitor padahal stoknya 5.
$this->service->placeOrder(1, [[4, 6]]);
self::fail('StokTidakCukupException seharusnya dilempar.');
} catch (StokTidakCukupException $e) {
// Inilah hasil yang diharapkan; lanjut ke assertion keadaan.
}
// Kedua sisi harus benar-benar kosong dari jejak: tidak ada order
// baru DAN stok tidak berkurang sebagian.
$orderSesudah = (int) $this->pdo->query('SELECT COUNT(*) FROM orders')->fetchColumn();
$stokSesudah = (int) $this->pdo->query('SELECT stok FROM products WHERE id = 4')->fetchColumn();
self::assertSame($orderSebelum, $orderSesudah);
self::assertSame($stokSebelum, $stokSesudah);
}
public function testTransisiStatusDivalidasiOlehMesinStatus(): void
{
// Fungsi murni: peta transisi bisa diuji tanpa database.
self::assertTrue(OrderService::bolehTransisi('pending', 'dibayar'));
self::assertFalse(OrderService::bolehTransisi('pending', 'selesai'));
self::assertFalse(OrderService::bolehTransisi('batal', 'batal'));
// Jalur resmi: pending -> dibayar -> dikirim -> selesai.
$orderId = $this->service->placeOrder(2, [[6, 1]]);
self::assertTrue($this->service->ubahStatus($orderId, 'dibayar'));
self::assertTrue($this->service->ubahStatus($orderId, 'dikirim'));
self::assertTrue($this->service->ubahStatus($orderId, 'selesai'));
self::assertSame('selesai', $this->repo->cariId($orderId)['status']);
// selesai adalah status terminal: transisi berikutnya ditolak.
$this->expectException(TransisiTidakValidException::class);
$this->expectExceptionMessage('tidak diizinkan');
$this->service->ubahStatus($orderId, 'batal');
}
public function testPembatalanOrderMengembalikanStokAtomik(): void
{
$inventory = new InventoryService($this->pdo);
$orderId = $this->service->placeOrder(1, [[1, 3]]);
self::assertSame(9, $inventory->stokSaatIni(1));
// Pelepasan stok dan perubahan status dalam satu transaksi.
$this->pdo->beginTransaction();
try {
$jumlahItem = $inventory->releaseForOrder($orderId);
$this->service->ubahStatus($orderId, 'batal');
$this->pdo->commit();
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
self::assertSame(1, $jumlahItem);
self::assertSame(12, $inventory->stokSaatIni(1));
self::assertSame('batal', $this->repo->cariId($orderId)['status']);
}
public function testPembayaranIdempotenDenganFakeGateway(): void
{
$gateway = new FakeGateway();
$paymentService = new PaymentService($this->pdo, $this->repo, $gateway);
$orderId = $this->service->placeOrder(3, [[2, 2]]);
// Pembayaran pertama: pending, gateway dihubungi satu kali.
$payment = $paymentService->bayar($orderId);
self::assertSame('pending', $payment['status']);
self::assertSame('FAKE-0001', $payment['payment_id']);
self::assertSame(1, $gateway->panggilan);
// Percobaan bayar kedua: baris yang sama, gateway TIDAK dihubungi.
$paymentLagi = $paymentService->bayar($orderId);
self::assertSame($payment['id'], $paymentLagi['id']);
self::assertSame(1, $gateway->panggilan);
// Callback settlement: pembayaran final, order menjadi dibayar.
$gateway->selesaikan((string) $payment['payment_id']);
$payment = $paymentService->konfirmasi($orderId);
self::assertSame('settlement', $payment['status']);
self::assertSame('dibayar', $this->repo->cariId($orderId)['status']);
// Setelah settlement, bayar ulang tetap mengembalikan baris lama.
$paymentLagi = $paymentService->bayar($orderId);
self::assertSame('settlement', $paymentLagi['status']);
self::assertSame(1, $gateway->panggilan);
}
}Empat teknik PHPUnit pada berkas tersebut layak dibedah:
setUp()membangun dunia baru — method ini berjalan sebelum tiap test, dan karenabuatDatabase()mengembalikan koneksi:memory:baru, kelima test masing-masing memulai dari schema bersih dengan data awal yang sama. Urutan eksekusi test menjadi tidak relevan.- Pola
try/catch+self::fail()untuk assertion setelah exception — pada test stok tidak cukup, kita tidak hanya ingin tahu bahwaStokTidakCukupExceptionterlempar, tetapi juga bahwa tidak ada order tersisa dan stok utuh.expectException()akan menghentikan test tepat di titik exception, sehingga keadaan database tidak bisa diperiksa; pola tangkap-lalu-periksa mengatasi itu, danself::fail()menjaga agar test gagal bila exception justru tidak muncul. expectException()untuk penolakan sebagai akhir test — pada test transisi status, tiga baris terakhir adalah pola standar: deklarasi expectation, lalu panggilan yang diharapkan melempar. BilaubahStatus()tidak melempar, PHPUnit menandai test gagal karena ada expectation yang tidak terpenuhi.- Assertion pada efek samping, bukan pada nilai balik — hampir semua assertion memeriksa keadaan database (
stok,COUNT(*), status order) setelah operasi, bukan sekadar nilai yang dikembalikan method. Nilai balik mudah dipalsukan; keadaan database adalah yang dirasakan pengguna.
Menjalankan PHPUnit #
Dari folder bagian-7-testing-dan-deployment, jalankan PHPUnit dengan test file dan bootstrap kita:
php phpunit.phar tests/OrderServiceTest.php --bootstrap tests/bootstrap.phpHasil eksekusinya pada PHP 8.4.23 dengan PHPUnit 12.5.39:
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
..... 5 / 5 (100%)
Time: 00:00.008, Memory: 28.05 MB
OK (5 tests, 31 assertions)Lima titik adalah lima test yang lulus, dan 31 assertions berarti 31 pemeriksaan individual semuanya benar — lebih dari enam kali lipat jumlah testnya, karena satu test memeriksa banyak sisi sekaligus. Angka Time dan Memory berbeda-beda antar mesin, tetapi baris OK dan jumlahnya stabil. Tambahkan opsi --testdox untuk tampilan yang terbaca seperti daftar perilaku:
php phpunit.phar tests/OrderServiceTest.php --bootstrap tests/bootstrap.php --testdoxPHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
..... 5 / 5 (100%)
Time: 00:00.009, Memory: 28.05 MB
Order Service
✔ Place order menulis order dan mengurangi stok
✔ Place order stok tidak cukup tidak mengubah apa pun
✔ Transisi status divalidasi oleh mesin status
✔ Pembatalan order mengembalikan stok atomik
✔ Pembayaran idempoten dengan fake gateway
OK (5 tests, 31 assertions)Daftar bercentang itulah spesifikasi sistem pemesanan kita dalam bentuk paling ringkas. Setiap kali kode berubah, satu perintah memastikan kelima janji masih berlaku — dan bila salah satu rusak, nama test yang gagal langsung menunjuk janji mana.
Mengemas dengan Docker #
Test menjaga kebenaran kode; deployment menjaga kebenaran lingkungan tempat kode berjalan. Dockerfile berikut merangkum tiga langkah: pilih image PHP resmi, salin schema dan class proyek, migrasikan database saat build, lalu jalankan smoke test sebagai perintah utama container:
# Dockerfile untuk proyek sistem pemesanan.
#
# Dibangun dari folder root proyek (sistem-pemesanan/) agar seluruh
# berkas dari bagian-bagian proyek berada dalam build context:
#
# cd sistem-pemesanan
# docker build -f bagian-7-testing-dan-deployment/Dockerfile -t sistem-pemesanan .
# docker run --rm sistem-pemesanan
#
# Image dasar php:8.4-cli-alpine sudah memuat ekstensi pdo_sqlite dan
# sqlite3, jadi tidak ada langkah docker-php-ext-install yang dibutuhkan.
FROM php:8.4-cli-alpine
WORKDIR /app
# Salin schema dan seluruh class proyek ke layout /app/src yang rata,
# sesuai konvensi PSR-4 satu class per berkas.
COPY bagian-1-model-pelanggan-dan-produk/schema.sql ./schema.sql
COPY bagian-2-order-dan-order-items/src/OrderRepository.php ./src/OrderRepository.php
COPY bagian-3-order-processing/src/OrderService.php ./src/OrderService.php
COPY bagian-4-transactions-dan-inventory/src/InventoryService.php ./src/InventoryService.php
COPY bagian-4-transactions-dan-inventory/src/Validator.php ./src/Validator.php
COPY bagian-5-payment-integration/src/PaymentGatewayInterface.php ./src/PaymentGatewayInterface.php
COPY bagian-5-payment-integration/src/PaymentService.php ./src/PaymentService.php
COPY bagian-5-payment-integration/src/FakeGateway.php ./src/FakeGateway.php
COPY bagian-6-reporting-dan-audit/src/ReportService.php ./src/ReportService.php
COPY bagian-6-reporting-dan-audit/src/AuditLogger.php ./src/AuditLogger.php
# Migrasi schema saat build: database SQLite siap pakai di /app/data.
# CREATE ... IF NOT EXISTS dan INSERT OR IGNORE pada schema.sql membuat
# langkah ini aman dijalankan ulang.
RUN mkdir -p data \
&& php -r '$pdo = new PDO("sqlite:/app/data/pemesanan.sqlite"); $pdo->exec(file_get_contents("/app/schema.sql")); printf("Migrasi: %d produk, %d pelanggan terpasang\n", $pdo->query("SELECT COUNT(*) FROM products")->fetchColumn(), $pdo->query("SELECT COUNT(*) FROM customers")->fetchColumn());'
# Smoke test saat container berjalan: PHP hidup, ekstensi SQLite ada,
# database bermigrasi, dan satu order end-to-end berhasil ditempatkan
# beserta pengurangan stoknya. Gagal salah satu = container exit non-zero.
CMD ["sh", "-c", "php -r 'require \"/app/src/OrderRepository.php\"; require \"/app/src/OrderService.php\"; $pdo = new PDO(\"sqlite:/app/data/pemesanan.sqlite\"); $service = new OrderService($pdo, new OrderRepository($pdo)); $id = $service->placeOrder(1, [[1, 1]]); $stok = $pdo->query(\"SELECT stok FROM products WHERE id = 1\")->fetchColumn(); echo \"smoke: order #$id ditempatkan, stok KB-100 kini $stok\n\";'"]Saat membangun, langkah RUN mencetak Migrasi: 6 produk, 5 pelanggan terpasang — bukti schema dan data awal terpasang di dalam image. Saat container berjalan, smoke test memuat class sungguhan, membuka database yang dimigrasi, menempatkan satu order untuk Budi, dan mencetak smoke: order #1 ditempatkan, stok KB-100 kini 11. Karena setiap docker run memulai dari lapisan image yang sama (perubahan pada container yang berjalan dibuang saat --rm), smoke test ini juga deterministik: selalu order #1, selalu stok 11. Perintah membangun dan menjalankannya:
cd sistem-pemesanan
docker build -f bagian-7-testing-dan-deployment/Dockerfile -t sistem-pemesanan .
docker run --rm sistem-pemesananUntuk kebutuhan produksi, dua penyesuaian lazim dilakukan. Pertama, database sebaiknya tidak dibakar di dalam image melainkan dipasang sebagai volume (docker run -v pemesanan-data:/app/data ...) agar data bertukar antar rilis tanpa membangun ulang. Kedua, migrasi sering dipindah ke saat deploy (entrypoint script yang menjalankan migrasi lalu menyalakan proses aplikasi) supaya satu image yang sama bisa naik ke beberapa lingkungan dengan data berbeda — pola IF NOT EXISTS pada schema.sql kita sudah membuat migrasi semacam itu aman diulang.
Cara Menjalankan Contoh #
Susunan folder yang dibutuhkan test dan Dockerfile — struktur antarbagian harus utuh:
sistem-pemesanan/
├── bagian-1-model-pelanggan-dan-produk/
│ └── schema.sql
├── bagian-2-order-dan-order-items/
│ └── src/
│ └── OrderRepository.php
├── bagian-3-order-processing/
│ └── src/
│ └── OrderService.php
├── bagian-4-transactions-dan-inventory/
│ └── src/
│ ├── InventoryService.php
│ └── Validator.php
├── bagian-5-payment-integration/
│ └── src/
│ ├── PaymentGatewayInterface.php
│ ├── PaymentService.php
│ └── FakeGateway.php
├── bagian-6-reporting-dan-audit/
│ └── src/
│ ├── ReportService.php
│ └── AuditLogger.php
└── bagian-7-testing-dan-deployment/
├── Dockerfile
└── tests/
├── OrderServiceTest.php
└── bootstrap.phpTest dijalankan dari folder bagian ketujuh (sesuaikan path phpunit.phar dengan lokasi berkasnya di mesin Anda):
cd bagian-7-testing-dan-deployment
php phpunit.phar tests/OrderServiceTest.php --bootstrap tests/bootstrap.phpDocker membangun dari root proyek karena build context harus memuat seluruh bagian:
docker build -f bagian-7-testing-dan-deployment/Dockerfile -t sistem-pemesanan .
docker run --rm sistem-pemesananTest memakai :memory: sehingga tidak meninggalkan berkas apa pun; image Docker menyimpan satu berkas data/pemesanan.sqlite di dalamnya sebagaimana dirancang.
Apa yang Berubah #
Dibandingkan Bagian 6 yang membaca data, proyek kini punya jaring pengaman dan kemasan:
- Berkas
tests/bootstrap.phpbaru: satu titik muat seluruh class proyek plus helperbuatDatabase()— schema inti darischema.sqlBagian 1 ditambah tabelpaymentsdanaudit_logdengan definisi identik Bagian 5-6. - Berkas
tests/OrderServiceTest.phpbaru: lima test, 31 assertion — order berhasil (stok berkurang, gerakan tercatat), stok tidak cukup (tidak ada jejak sama sekali), transisi status (murni + terminal ditolak), pembatalan atomik (stok kembali), dan idempotensi pembayaran denganFakeGateway(penghitung panggilan tetap satu). - Berkas
Dockerfilebaru: imagephp:8.4-cli-alpine, layout/app/srcrata sesuai PSR-4, migrasi schema saat build, dan smoke test end-to-end sebagaiCMD. - Seluruh output PHPUnit pada halaman ini adalah hasil eksekusi nyata, bukan tulisan tangan.
Ringkasan #
Proyek sistem pemesanan genap tujuh bagian. Kita mulai dari schema yang menjaga dirinya sendiri — uang sebagai integer sen, stok yang tidak boleh negatif, status yang dibatasi CHECK — lalu menaikinya lapis demi lapis: OrderRepository yang menstrukturkan akses data, OrderService yang menyatukan order dan stok dalam satu transaksi dengan mesin status, InventoryService dan Validator yang membuat setiap gerakan stok berjejak dan setiap masukan terperiksa, PaymentService yang idempoten di balik kontrak gateway yang bisa ditukar, sampai ReportService dan AuditLogger yang mengubah jejak menjadi laporan dan sejarah yang bisa diputar ulang. Bagian ini menutupnya dengan dua kata kunci: terbukti dan siap kirim — lima test yang berjalan dalam milidetik menjaga seluruh janji itu tetap hidup, dan sebuah image Docker membawa semuanya ke mesin mana pun. Pola yang Anda kuasai di sini — constraint di schema, transaksi di service, abstraksi di perbatasan sistem luar, test di pusat kepercayaan — berlaku jauh melampaui toko buku fiktif ini.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
tests/bootstrap.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Bootstrap PHPUnit untuk proyek sistem pemesanan.
*
* Berkas ini dimuat lewat opsi --bootstrap dan melakukan dua hal:
*
* 1. Memuat seluruh class proyek dari bagian-bagian sebelumnya lewat
* path relatif antarfolder: folder tests/ berada di dalam folder
* bagian-7-testing-dan-deployment, sedangkan folder bagian lain
* (bagian-2 sampai bagian-6) berada satu tingkat di atasnya, sehingga
* dijangkau lewat ../../bagian-NAMA-BAGIAN/src/NamaClass.php.
* 2. Menyediakan helper buatDatabase() yang membangun database SQLite
* :memory: SEGAR lengkap dengan schema.sql Bagian 1 ditambah tabel
* payments (Bagian 5) dan audit_log (Bagian 6). Tiap test memanggil
* helper ini di setUp() sehingga test saling independen.
*
* Jalankan dari folder bagian-7-testing-dan-deployment:
* php /path/ke/phpunit.phar tests/OrderServiceTest.php --bootstrap tests/bootstrap.php
*/
require __DIR__ . '/../../bagian-2-order-dan-order-items/src/OrderRepository.php';
require __DIR__ . '/../../bagian-3-order-processing/src/OrderService.php';
require __DIR__ . '/../../bagian-4-transactions-dan-inventory/src/InventoryService.php';
require __DIR__ . '/../../bagian-4-transactions-dan-inventory/src/Validator.php';
require __DIR__ . '/../../bagian-5-payment-integration/src/PaymentService.php';
require __DIR__ . '/../../bagian-5-payment-integration/src/FakeGateway.php';
require __DIR__ . '/../../bagian-6-reporting-dan-audit/src/ReportService.php';
require __DIR__ . '/../../bagian-6-reporting-dan-audit/src/AuditLogger.php';
function buatDatabase(): PDO
{
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
// Schema inti (customers, products, orders, order_items,
// stock_movements + data awal) dari Bagian 1.
$pdo->exec(
file_get_contents(__DIR__ . '/../../bagian-1-model-pelanggan-dan-produk/schema.sql')
);
// Tambahan schema Bagian 5 dan 6 — sama seperti pada demo kedua bagian itu.
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL UNIQUE REFERENCES orders(id),
gateway TEXT NOT NULL,
payment_id TEXT NOT NULL,
jumlah INTEGER NOT NULL CHECK (jumlah > 0),
status TEXT NOT NULL CHECK (status IN ('pending', 'settlement', 'gagal')),
redirect_url TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
aksi TEXT NOT NULL,
entitas TEXT NOT NULL,
entity_id INTEGER NOT NULL,
data_sebelum TEXT,
data_sesudah TEXT,
waktu TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_log_entitas ON audit_log(entitas, entity_id);
SQL);
return $pdo;
}
tests/OrderServiceTest.php
Unduh berkas<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Test alur transaksional OrderService dan PaymentService.
*
* Setiap test mendapat database :memory: segar dari buatDatabase()
* (bootstrap.php) lewat setUp(), sehingga test tidak pernah saling
* memengaruhi dan boleh dijalankan dalam urutan apa pun.
*/
final class OrderServiceTest extends TestCase
{
private PDO $pdo;
private OrderRepository $repo;
private OrderService $service;
protected function setUp(): void
{
$this->pdo = buatDatabase();
$this->repo = new OrderRepository($this->pdo);
$this->service = new OrderService($this->pdo, $this->repo);
}
public function testPlaceOrderMenulisOrderDanMengurangiStok(): void
{
$orderId = $this->service->placeOrder(1, [[1, 2], [2, 1]]);
$order = $this->repo->cariId($orderId);
self::assertNotNull($order);
self::assertSame('pending', $order['status']);
self::assertCount(2, $order['items']);
// 2x Keyboard (2 x 75.000.000) + 1x Mouse (25.000.000) dalam sen.
self::assertSame(175000000, $order['total']);
// Stok produk #1: 12 - 2 = 10; produk #2: 25 - 1 = 24.
$stok1 = (int) $this->pdo->query('SELECT stok FROM products WHERE id = 1')->fetchColumn();
$stok2 = (int) $this->pdo->query('SELECT stok FROM products WHERE id = 2')->fetchColumn();
self::assertSame(10, $stok1);
self::assertSame(24, $stok2);
// Setiap item meninggalkan satu baris gerakan stok keluar.
$gerakan = (int) $this->pdo->query(
"SELECT COUNT(*) FROM stock_movements WHERE order_id = $orderId AND jenis = 'keluar'"
)->fetchColumn();
self::assertSame(2, $gerakan);
}
public function testPlaceOrderStokTidakCukupTidakMengubahApaPun(): void
{
$orderSebelum = (int) $this->pdo->query('SELECT COUNT(*) FROM orders')->fetchColumn();
$stokSebelum = (int) $this->pdo->query('SELECT stok FROM products WHERE id = 4')->fetchColumn();
try {
// Meminta 6 unit monitor padahal stoknya 5.
$this->service->placeOrder(1, [[4, 6]]);
self::fail('StokTidakCukupException seharusnya dilempar.');
} catch (StokTidakCukupException $e) {
// Inilah hasil yang diharapkan; lanjut ke assertion keadaan.
}
// Kedua sisi harus benar-benar kosong dari jejak: tidak ada order
// baru DAN stok tidak berkurang sebagian.
$orderSesudah = (int) $this->pdo->query('SELECT COUNT(*) FROM orders')->fetchColumn();
$stokSesudah = (int) $this->pdo->query('SELECT stok FROM products WHERE id = 4')->fetchColumn();
self::assertSame($orderSebelum, $orderSesudah);
self::assertSame($stokSebelum, $stokSesudah);
}
public function testTransisiStatusDivalidasiOlehMesinStatus(): void
{
// Fungsi murni: peta transisi bisa diuji tanpa database.
self::assertTrue(OrderService::bolehTransisi('pending', 'dibayar'));
self::assertFalse(OrderService::bolehTransisi('pending', 'selesai'));
self::assertFalse(OrderService::bolehTransisi('batal', 'batal'));
// Jalur resmi: pending -> dibayar -> dikirim -> selesai.
$orderId = $this->service->placeOrder(2, [[6, 1]]);
self::assertTrue($this->service->ubahStatus($orderId, 'dibayar'));
self::assertTrue($this->service->ubahStatus($orderId, 'dikirim'));
self::assertTrue($this->service->ubahStatus($orderId, 'selesai'));
self::assertSame('selesai', $this->repo->cariId($orderId)['status']);
// selesai adalah status terminal: transisi berikutnya ditolak.
$this->expectException(TransisiTidakValidException::class);
$this->expectExceptionMessage('tidak diizinkan');
$this->service->ubahStatus($orderId, 'batal');
}
public function testPembatalanOrderMengembalikanStokAtomik(): void
{
$inventory = new InventoryService($this->pdo);
$orderId = $this->service->placeOrder(1, [[1, 3]]);
self::assertSame(9, $inventory->stokSaatIni(1));
// Pelepasan stok dan perubahan status dalam satu transaksi.
$this->pdo->beginTransaction();
try {
$jumlahItem = $inventory->releaseForOrder($orderId);
$this->service->ubahStatus($orderId, 'batal');
$this->pdo->commit();
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
self::assertSame(1, $jumlahItem);
self::assertSame(12, $inventory->stokSaatIni(1));
self::assertSame('batal', $this->repo->cariId($orderId)['status']);
}
public function testPembayaranIdempotenDenganFakeGateway(): void
{
$gateway = new FakeGateway();
$paymentService = new PaymentService($this->pdo, $this->repo, $gateway);
$orderId = $this->service->placeOrder(3, [[2, 2]]);
// Pembayaran pertama: pending, gateway dihubungi satu kali.
$payment = $paymentService->bayar($orderId);
self::assertSame('pending', $payment['status']);
self::assertSame('FAKE-0001', $payment['payment_id']);
self::assertSame(1, $gateway->panggilan);
// Percobaan bayar kedua: baris yang sama, gateway TIDAK dihubungi.
$paymentLagi = $paymentService->bayar($orderId);
self::assertSame($payment['id'], $paymentLagi['id']);
self::assertSame(1, $gateway->panggilan);
// Callback settlement: pembayaran final, order menjadi dibayar.
$gateway->selesaikan((string) $payment['payment_id']);
$payment = $paymentService->konfirmasi($orderId);
self::assertSame('settlement', $payment['status']);
self::assertSame('dibayar', $this->repo->cariId($orderId)['status']);
// Setelah settlement, bayar ulang tetap mengembalikan baris lama.
$paymentLagi = $paymentService->bayar($orderId);
self::assertSame('settlement', $paymentLagi['status']);
self::assertSame(1, $gateway->panggilan);
}
}