Satu kasus crash masih tersisa dari Bagian 3: php main.php hitung 10 / 0 berakhir sebagai fatal error dengan exit code 255. Bagian penutup proyek ini menyelesaikannya lewat try-catch, kemudian melangkah lebih jauh — memisahkan logika perhitungan dari eksekusi CLI supaya logika itu bisa diuji secara langsung, lalu menutup proyek dengan test PHPUnit yang sesungguhnya. Di akhir bagian, kalkulator berstatus 1.0.0: seluruh input salah ditolak dengan pesan ramah, pembagian nol ditangani tanpa crash, dan setiap janji perilakunya dikunci oleh dua belas test yang otomatis. Pola yang kita pakai di sini — validasi di gerbang, logika murni di dalam, test sebagai penjaga — adalah pola yang akan Anda temui di proyek PHP nyata skala apa pun.
Menangkap DivisionByZeroError dengan try-catch #
Di PHP 8, pembagian angka dengan nol bukan peringatan melainkan DivisionByZeroError — objek Error yang kalau tidak ditangkap akan menghentikan program saat itu juga. Jembatannya adalah blok try-catch: kode yang berpotensi melempar ditaruh di try, dan cara menanggapinya dideklarasikan di catch dengan tipe exception yang spesifik:
<?php
function bagi(int|float $a, int|float $b): int|float
{
return $a / $b;
}
try {
echo bagi(10, 0) . "\n";
} catch (DivisionByZeroError $e) {
echo "Error: pembagian dengan nol tidak diperbolehkan.\n";
}
echo "program tetap berjalan sampai selesai\n";Output yang Dihasilkan:
Error: pembagian dengan nol tidak diperbolehkan.
program tetap berjalan sampai selesaiPerhatikan urutannya: baris echo bagi(10, 0) tidak pernah mencetak apa pun karena lemparan terjadi sebelum echo sempat dieksekusi, aliran lompat langsung ke blok catch, dan setelah itu program berlanjut normal sampai baris terakhir. Objek $e yang tertangkap membawa informasi diagnostik yang bisa kita baca saat perlu:
<?php
try {
intdiv(10, 0);
} catch (DivisionByZeroError $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}Output yang Dihasilkan:
DivisionByZeroError: Division by zeroDalam main.php versi final, penangkapan ini dipasang di commandHitung() — satu-satunya tempat yang tepat, karena dialah pemanggil fungsi hitung() sekaligus penjaga exit code. Fungsi hitung() sendiri sengaja dibiarkan melempar DivisionByZeroError apa adanya; handler-nya yang menerjemahkan lemparan itu menjadi pesan ramah plus return 1. Pemisahan tanggung jawab ini penting: logika murni tidak perlu tahu bagaimana sebuah program CLI melaporkan kesalahan.
Memisahkan Eksekusi agar Logika Bisa Diuji #
Sampai Bagian 3, baris terakhir main.php adalah exit(main($argv)); tanpa syarat. Bentuk itu menyulitkan pengujian: berkas yang di-require dari test akan langsung menjalankan program utama, padahal test hanya ingin memanggil fungsi hitung(). Solusinya adalah gerbang eksekusi — main.php hanya menjalankan main($argv) ketika berkas itu sendiri yang dieksekusi langsung. Prinsipnya sama dengan if __name__ == "__main__" di Python: bandingkan berkas yang sedang berjalan ($argv[0], nama skrip yang diketik pengguna) dengan berkas tempat kode ini berada (__FILE__):
<?php
// Simulasi dua konteks pemanggilan berkas main.php.
$berkasIni = '/proyek/kalkulator-terminal/main.php';
$dieksekusiLangsung = ['/proyek/kalkulator-terminal/main.php', 'hitung', '5', '+', '3'];
$diRequireOlehTest = ['/alat/phpunit.phar', 'tests/CalculatorTest.php'];
foreach ([$dieksekusiLangsung, $diRequireOlehTest] as $argv) {
echo ($argv[0] === $berkasIni)
? "argv[0] adalah berkas ini → jalankan program utama\n"
: "argv[0] bukan berkas ini → cukup definisikan fungsi, jangan jalankan program\n";
}Output yang Dihasilkan:
argv[0] adalah berkas ini → jalankan program utama
argv[0] bukan berkas ini → cukup definisikan fungsi, jangan jalankan programDi berkas sungguhan, perbandingan string di atas diganti realpath() kedua sisi agar path yang tampak berbeda (main.php vs /absolut/main.php) tetap dikenali sebagai berkas yang sama:
<?php
// fragmen akhir main.php — versi final
if (isset($argv) && realpath($argv[0]) === realpath(__FILE__)) {
exit(main($argv));
}Dengan gerbang ini, php main.php hitung 5 + 3 tetap berjalan seperti biasa, tetapi require 'main.php' dari sebuah test hanya mendefinisikan fungsi-fungsi tanpa mengeksekusi apa pun. Ditambah satu perbaikan pada hitung() — arm default yang melempar InvalidArgumentException sebagai pertahanan terakhir untuk operator asing — logika kalkulator kini berbentuk fungsi murni dengan kontrak yang tegas: operator dikenali mengembalikan hasil int|float, pembagian nol melempar DivisionByZeroError, operator asing melempar InvalidArgumentException.
Menulis Test PHPUnit #
Test untuk proyek ini tinggal di tests/CalculatorTest.php pada bagian bawah halaman. Kelas test-nya mewarisi TestCase PHPUnit, me-require main.php (yang berkat gerbang di atas hanya mendefinisikan fungsi), lalu menguji hitung() kasus per kasus:
<?php
// fragmen tests/CalculatorTest.php
final class CalculatorTest extends TestCase
{
public function testPembagianTidakHabisMengembalikanPecahan(): void
{
self::assertSame(3.5, hitung(7, '/', 2));
}
public function testPembagianDenganNolMelemparDivisionByZeroError(): void
{
$this->expectException(DivisionByZeroError::class);
hitung(10, '/', 0);
}
}Pilihan assertion-nya disengaja: assertSame membandingkan nilai dan tipe sekaligus — semangat yang sama dengan operator ===:
<?php
var_dump(12 === 12.0, 12 == 12.0);Output yang Dihasilkan:
bool(false)
bool(true)Bila toNumber() keliru mengembalikan float(12.0) alih-alih int(12), assertSame(12, ...) akan gagal, sedangkan assertEquals(12, ...) akan lolos karena == menganggap keduanya sama. Untuk program yang menjanjikan 10 / 5 bernilai 2 (integer) dan 10 / 4 bernilai 2.5 (pecahan), ketelitian tipe ini adalah bagian dari kontrak yang mau kita jaga. Adapun pengujian exception memakai pola expectException(): tandai dulu tipe lemparan yang diharapkan, lalu baris terakhir method memanggil kode pemicunya — method dinyatakan lulus hanya bila lemparan yang diharapkan benar-benar terjadi.
Menjalankan PHPUnit #
Test dijalankan dengan PHPUnit 12 dari direktori bagian ini (di lingkungan course ini phpunit.phar berada di /home/z/my-project/bin/):
cd content/php/proyek-php/kalkulator-terminal/bagian-4-error-handling-dan-testing
php /home/z/my-project/bin/phpunit.phar tests/CalculatorTest.phpPHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
............ 12 / 12 (100%)
Time: 00:00.002, Memory: 28.05 MB
OK (12 tests, 12 assertions)Dua belas test, dua belas assertion, semuanya hijau — baris OK itulah yang kami dapatkan dari eksekusi sungguhan pada lingkungan course ini (angka Time dan Memory di mesin Anda akan sedikit berbeda). Rangkaian dua belas titik pada baris progres mewakili dua belas test yang lulus; tidak ada yang di-skip. Untuk tampilan yang lebih mudah dibaca manusia, tambahkan opsi --testdox:
php /home/z/my-project/bin/phpunit.phar tests/CalculatorTest.php --testdoxPHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
............ 12 / 12 (100%)
Time: 00:00.003, Memory: 28.05 MB
Calculator
✔ Penjumlahan mengembalikan bilangan bulat
✔ Pengurangan
✔ Perkalian
✔ Pembagian yang habis dibagi mengembalikan bilangan bulat
✔ Pembagian tidak habis mengembalikan pecahan
✔ Modulo
✔ Intdiv membulatkan ke arah nol
✔ Pangkat
✔ Pembagian dengan nol melempar division by zero error
✔ Modulo dengan nol melempar division by zero error
✔ Intdiv dengan nol melempar division by zero error
✔ Operator tidak dikenal melempar invalid argument exception
OK (12 tests, 12 assertions)Nama method test yang panjang tadi ternyata berubah jadi kalimat periksa yang rapi — PHPUnit menurunkannya dari nama method. Menjalankan satu test saja juga bisa lewat --filter testPangkat, berguna saat Anda sedang memperbaiki satu kasus tanpa menunggu sisanya. Medan lengkap pengujian manual dan otomatis program final dirangkum pada tabel berikut:
| Pemanggilan | Keluaran | Exit code |
|---|---|---|
php main.php | Panduan pemakaian | 0 |
php main.php versi | Kalkulator Terminal versi 1.0.0 | 0 |
php main.php hitung 5 + 3 | 8 | 0 |
php main.php hitung 10 / 4 | 2.5 | 0 |
php main.php hitung 2 '**' 10 | 1024 | 0 |
php main.php hitung 5 + | Error jumlah argumen + panduan | 1 |
php main.php hitung abc + 1 | Error angka tidak valid | 1 |
php main.php hitung 5 pangkat 3 | Error operator tidak dikenal | 1 |
php main.php hitung 7.5 % 2 | Error hanya bilangan bulat | 1 |
php main.php hitung 10 / 0 | Error pembagian nol (via try-catch) | 1 |
Kasus terakhir itulah yang membedakan bagian ini dari Bagian 3 — mari kita lihat hasilnya langsung:
php main.php hitung 10 / 0; echo "Exit code: $?"Error: pembagian dengan nol tidak diperbolehkan.
Exit code: 1Apa yang Berubah dari Bagian 3 #
Bagian ini menutup evolusi main.php dan menambahkan lapisan test. Ringkasan perubahannya:
commandHitung()membungkus pemanggilanhitung()dengantry-catch (DivisionByZeroError); pesan ramah dikirim ke STDERR dan exit code 1 menggantikan fatal error 255.hitung()mendapat armdefaultyang melemparInvalidArgumentException— pertahanan terakhir bila validasi CLI suatu saat bolong.- Baris
exit(main($argv));diganti gerbang eksekusirealpath($argv[0]) === realpath(__FILE__), sehinggamain.phpaman untuk di-require dari test. - Berkas baru
tests/CalculatorTest.phpmengujihitung()denganassertSame(nilai sekaligus tipe) danexpectExceptionuntuk tiga kasus pembagian nol plus satu kasus operator asing. APP_VERSIONnaik menjadi1.0.0— kontrak program utuh, teruji, dan bebas crash yang diketahui.
Referensi #
- try-catch
— sintaks penangkapan
Throwablebeserta aturan urutan blok. - DivisionByZeroError
— kelas Error yang dilempar operator
/,%, danintdiv()saat bertemu nol. - PHPUnit 12 Documentation
— panduan resmi
assertSame,expectException, dan opsi CLI seperti--testdox.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
main.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Kalkulator Terminal — Bagian 4: Error Handling dan Testing (versi final).
*
* Pemakaian:
* php main.php hitung <angka1> <operator> <angka2>
* php main.php versi
*
* Dipanggil tanpa argumen, program mencetak panduan pemakaian lalu
* keluar dengan exit code 0.
*
* Berkas ini aman untuk di-require dari test: eksekusi program utama
* hanya terjadi ketika berkas ini dijalankan langsung lewat `php main.php`.
*/
const APP_NAME = 'Kalkulator Terminal';
const APP_VERSION = '1.0.0';
/** Daftar operator yang dikenali oleh perintah hitung. */
const KNOWN_OPERATORS = ['+', '-', '*', '/', '%', '**', 'intdiv'];
/** Cetak panduan pemakaian program ke STDOUT. */
function printUsage(): void
{
echo "Pemakaian:\n";
echo " php main.php hitung <angka1> <operator> <angka2>\n";
echo " php main.php versi\n";
echo "\n";
echo "Operasi yang didukung:\n";
echo ' ' . implode(' ', KNOWN_OPERATORS) . "\n";
}
/** Tangani perintah `versi`: cetak nama dan versi program. */
function commandVersi(): int
{
echo APP_NAME . ' versi ' . APP_VERSION . "\n";
return 0;
}
/**
* Ubah string argumen menjadi angka int atau float.
*
* Bentuk bilangan bulat ("5", "-3", "+7") dikonversi ke int; bentuk
* lain yang lolos pemeriksaan is_numeric() ("7.5", "1e3") dikonversi
* ke float supaya bagian pecahannya tidak hilang.
*/
function toNumber(string $value): int|float
{
if (isIntegerLiteral($value)) {
return (int) $value;
}
return (float) $value;
}
/** Apakah string ini bentuk harfiah bilangan bulat, mis. "5" atau "-3"? */
function isIntegerLiteral(string $value): bool
{
return preg_match('/^[+-]?\d+$/', $value) === 1;
}
/**
* Validasi isi tiga argumen perintah hitung.
*
* Mengembalikan string pesan error bila ada masalah, atau null bila
* seluruh argumen lolos validasi.
*/
function validateOperands(array $args): ?string
{
[$aRaw, $operator, $bRaw] = $args;
foreach ([$aRaw, $bRaw] as $operand) {
if (!is_numeric($operand)) {
return "Error: '{$operand}' bukan angka yang valid.";
}
}
if (!in_array($operator, KNOWN_OPERATORS, true)) {
return "Error: operator tidak dikenal: '{$operator}'. "
. 'Operator yang didukung: ' . implode(' ', KNOWN_OPERATORS) . '.';
}
if ($operator === '%' || $operator === 'intdiv') {
foreach ([$aRaw, $bRaw] as $operand) {
if (!isIntegerLiteral($operand)) {
return "Error: operator '{$operator}' hanya menerima bilangan bulat, "
. "diberikan '{$operand}'.";
}
}
}
return null;
}
/**
* Hitung satu operasi aritmatika dan kembalikan hasilnya.
*
* Kontrak fungsi murni ini:
* - operator yang dikenali (+ - * / % ** intdiv) mengembalikan hasil
* berupa int atau float;
* - pembagian, modulo, atau intdiv dengan nol melempar DivisionByZeroError;
* - operator di luar daftar melempar InvalidArgumentException sebagai
* pertahanan terakhir, meskipun validasi CLI seharusnya sudah menyaringnya.
*/
function hitung(int|float $a, string $operator, int|float $b): int|float
{
return match ($operator) {
'+' => $a + $b,
'-' => $a - $b,
'*' => $a * $b,
'/' => $a / $b,
'%' => $a % $b,
'**' => $a ** $b,
'intdiv' => intdiv($a, $b),
default => throw new InvalidArgumentException(
"Operator tidak dikenal: '{$operator}'"
),
};
}
/** Tangani perintah `hitung <angka1> <operator> <angka2>`. */
function commandHitung(array $args): int
{
// Gerbang pertama: bentuk panggilan (jumlah argumen).
if (count($args) !== 3) {
fwrite(STDERR, "Error: perintah hitung membutuhkan tepat 3 argumen "
. '(angka1 operator angka2), diberikan ' . count($args) . ".\n\n");
printUsage();
return 1;
}
// Gerbang kedua: isi setiap argumen.
$error = validateOperands($args);
if ($error !== null) {
fwrite(STDERR, $error . "\n");
return 1;
}
[$aRaw, $operator, $bRaw] = $args;
try {
$hasil = hitung(toNumber($aRaw), $operator, toNumber($bRaw));
} catch (DivisionByZeroError $e) {
fwrite(STDERR, "Error: pembagian dengan nol tidak diperbolehkan.\n");
return 1;
}
echo $hasil . "\n";
return 0;
}
/** Laporkan perintah yang tidak dikenal, lalu cetak panduan pemakaian. */
function unknownCommand(string $command): int
{
fwrite(STDERR, "Error: perintah tidak dikenal: {$command}\n");
printUsage();
return 1;
}
/**
* Titik masuk program: terima $argv, tentukan perintah yang diminta,
* dan kembalikan exit code ke pemanggil.
*/
function main(array $argv): int
{
// Buang nama skrip (elemen pertama) — sisanya adalah argumen pengguna.
$args = array_slice($argv, 1);
// Tanpa argumen: pengguna hanya butuh tahu cara memakai program.
if ($args === []) {
printUsage();
return 0;
}
$command = $args[0];
$commandArgs = array_slice($args, 1);
return match ($command) {
'versi' => commandVersi(),
'hitung' => commandHitung($commandArgs),
default => unknownCommand($command),
};
}
// Jalankan program hanya bila berkas ini dieksekusi langsung
// (php main.php ...), bukan saat di-require dari berkas test lain.
if (isset($argv) && realpath($argv[0]) === realpath(__FILE__)) {
exit(main($argv));
}
tests/CalculatorTest.php
Unduh berkas<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require __DIR__ . '/../main.php';
final class CalculatorTest extends TestCase
{
public function testPenjumlahanMengembalikanBilanganBulat(): void
{
self::assertSame(12, hitung(7, '+', 5));
}
public function testPengurangan(): void
{
self::assertSame(3, hitung(10, '-', 7));
}
public function testPerkalian(): void
{
self::assertSame(150000, hitung(500, '*', 300));
}
public function testPembagianYangHabisDibagiMengembalikanBilanganBulat(): void
{
self::assertSame(2, hitung(10, '/', 5));
}
public function testPembagianTidakHabisMengembalikanPecahan(): void
{
self::assertSame(3.5, hitung(7, '/', 2));
}
public function testModulo(): void
{
self::assertSame(1, hitung(7, '%', 3));
}
public function testIntdivMembulatkanKeArahNol(): void
{
self::assertSame(-3, hitung(-7, 'intdiv', 2));
}
public function testPangkat(): void
{
self::assertSame(1024, hitung(2, '**', 10));
}
public function testPembagianDenganNolMelemparDivisionByZeroError(): void
{
$this->expectException(DivisionByZeroError::class);
hitung(10, '/', 0);
}
public function testModuloDenganNolMelemparDivisionByZeroError(): void
{
$this->expectException(DivisionByZeroError::class);
hitung(10, '%', 0);
}
public function testIntdivDenganNolMelemparDivisionByZeroError(): void
{
$this->expectException(DivisionByZeroError::class);
hitung(10, 'intdiv', 0);
}
public function testOperatorTidakDikenalMelemparInvalidArgumentException(): void
{
$this->expectException(InvalidArgumentException::class);
hitung(5, 'pangkat', 2);
}
}