Kalkulator kita kini ramah terhadap pengguna, tetapi kodenya belum ramah terhadap pengujian: operasi aritmatika hidup di dalam function tambah() sampai bagi() yang terikat dictionary OPERASI, dan bagi() masih melakukan dua pekerjaan sekaligus — menghitung sekaligus mencetak pesan galat lalu mengembalikan None. Pada bagian penutup ini kita melakukan refactor: seluruh logika hitung dipadatkan menjadi satu fungsi murni hitung(), error dilaporkan lewat exception alih-alih nilai None, dan kebenaran logika dibuktikan dengan rangkaian test pytest.

Memisahkan Logika dari Input/Output #

Fungsi murni — materi Chapter Pure Functions — hanya menghitung hasil dari argument yang diterima, tanpa membaca input() dan tanpa mencetak apa pun. Fungsi semacam ini gampang diuji: panggil dengan nilai yang diketahui, bandingkan hasilnya. Rancangan fungsinya:

python
def hitung(a: float, op: str, b: float) -> float:

Function hitung() menerima dua angka dan satu simbol operator, lalu mengembalikan hasilnya. Dua aturan galat melekat pada rancangan ini:

  • op yang bukan salah satu dari + - * / membangkitkan ValueError,
  • pembagian dengan nol membangkitkan ZeroDivisionError.

Menariknya, untuk aturan kedua kita tidak perlu menulis pemeriksaan apa pun: ekspresi a / b dengan b bernilai 0 sudah otomatis membangkitkan ZeroDivisionError di dalam interpreter Python. Mengapa memilih exception, bukan lagi None? Karena keputusan atas galat kini berpindah ke pemanggil: main() menangkap exception itu dan mencetak pesan ramah untuk pengguna, sementara test menangkap exception yang sama untuk membuktikan perilakunya. Satu sumber kebenaran, dua cara merespons.

Kode Lengkap Bagian 4 #

python
"""Kalkulator Terminal - penanganan error dan testing.

Bagian 4: logika hitung dipisahkan sebagai fungsi murni,
error ditangani pada satu lapisan, dan seluruh logika diuji dengan pytest.
"""


def hitung(a: float, op: str, b: float) -> float:
    """Kembalikan hasil a <op> b untuk op salah satu dari + - * /.

    Membangkitkan ZeroDivisionError bila op adalah "/" dan b bernilai 0,
    serta ValueError bila op tidak dikenal.
    """
    if op == "+":
        return a + b
    if op == "-":
        return a - b
    if op == "*":
        return a * b
    if op == "/":
        return a / b
    raise ValueError(f"operator tidak dikenal: {op!r}")


def tampilkan_menu():
    """Cetak daftar operasi yang tersedia."""
    print("=== Kalkulator Terminal ===")
    print("1. Tambah")
    print("2. Kurang")
    print("3. Kali")
    print("4. Bagi")
    print("5. Keluar")


PILIHAN_KE_OPERATOR = {
    "1": "+",
    "2": "-",
    "3": "*",
    "4": "/",
}


def baca_pilihan():
    """Baca nomor operasi; ulangi sampai pengguna memasukkan 1-5."""
    while True:
        pilihan = input("Pilih operasi (1-5): ").strip()
        if pilihan in ("1", "2", "3", "4", "5"):
            return pilihan
        print(f"Pilihan '{pilihan}' tidak dikenal. Masukkan angka 1-5.")


def baca_angka(label):
    """Baca satu angka; ulangi sampai input dapat dikonversi menjadi float."""
    while True:
        teks = input(f"{label}: ").strip()
        try:
            return float(teks)
        except ValueError:
            print(f"'{teks}' bukan angka yang valid. Coba lagi.")


def tanya_lagi():
    """Tanyakan apakah pengguna ingin menghitung lagi; terima hanya y atau t."""
    while True:
        jawaban = input("Hitung lagi? (y/t): ").strip().lower()
        if jawaban in ("y", "t"):
            return jawaban == "y"
        print("Jawaban harus 'y' atau 't'.")


def main():
    """Titik masuk program: koordinasi input, hitung, dan output."""
    while True:
        tampilkan_menu()
        pilihan = baca_pilihan()

        if pilihan == "5":
            break

        op = PILIHAN_KE_OPERATOR[pilihan]
        a = baca_angka("Angka pertama")
        b = baca_angka("Angka kedua")

        try:
            hasil = hitung(a, op, b)
        except ZeroDivisionError:
            print("Error: pembagian dengan nol tidak diperbolehkan.")
        else:
            print(f"Hasil: {a:g} {op} {b:g} = {hasil:g}")

        if not tanya_lagi():
            break

    print("Terima kasih telah menggunakan Kalkulator Terminal.")


if __name__ == "__main__":
    main()

Empat function tambah() sampai bagi() beserta dictionary OPERASI lenyap, digantikan rancangan baru. Mari kita bedah perubahannya:

  1. Baris 8-22 (def hitung(...)): type hints pada signature mengikuti konvensi Chapter Type Hints pada Fungsi. Rantai if memeriksa satu per satu operator yang sah; cabang / langsung menjalankan a / b tanpa pemeriksaan nol — ZeroDivisionError dibangkitkan interpreter Python sendiri ketika b bernilai 0.
  2. Baris 22 (raise ValueError(...)): operator yang tidak dikenal adalah kesalahan pemrograman, jadi dilaporkan dengan raise — materi Chapter raise. Format {op!r} menampilkan nilai dengan tanda kutipnya, sehingga pesan galat terbaca operator tidak dikenal: '%'.
  3. Baris 35-40 (PILIHAN_KE_OPERATOR): dictionary menyusut menjadi pemetaan nomor menu ke simbol operator; logika hitung sudah tidak lagi tinggal di sini.
  4. Baris 71-94 (def main():): fungsi koordinator kini bernama main() — konvensi penamaan titik masuk program Python. Blok try/except/else di baris 84-89 mengikuti pola Chapter else dan finally: hasil hanya dicetak di blok else yang berjalan ketika tidak ada exception. Seluruh penanganan error pengguna kini hidup di satu lapisan ini; hitung() tetap bersih.
  5. Baris 97-98 (if __name__ == "__main__":): baris ini menjadi syarat testing — tanpa penjaga ini, mengimpor main akan menjalankan loop interaktif.

Test dengan pytest #

Buat berkas test_main.py di folder yang sama dengan main.py:

python
"""Test untuk fungsi murni hitung() pada Kalkulator Terminal."""

import pytest

from main import hitung


def test_tambah():
    assert hitung(10, "+", 5) == 15


def test_kurang():
    assert hitung(10, "-", 4) == 6


def test_kali():
    assert hitung(6, "*", 7) == 42


def test_bagi():
    assert hitung(10, "/", 4) == 2.5


def test_bagi_dengan_nol():
    with pytest.raises(ZeroDivisionError):
        hitung(5, "/", 0)


def test_operator_tidak_dikenal():
    with pytest.raises(ValueError):
        hitung(5, "%", 2)


def test_bilangan_negatif():
    assert hitung(-4, "+", 9) == 5
    assert hitung(-3, "*", -2) == 6


def test_bilangan_desimal():
    assert hitung(2.5, "*", 4) == 10
    assert hitung(7.5, "-", 0.5) == 7

Struktur test ini mengikuti Chapter Menulis Test dengan pytest: setiap function berawalan test_ menjadi satu test mandiri, dan statement assert membandingkan hasil nyata dengan hasil yang diharapkan. Dua test terakhir memakai dua assertion sekaligus untuk memeriksa satu perilaku yang sama — bilangan negatif dan bilangan desimal — bukan dua perilaku berbeda.

Dua test pertengahan berkas memakai pytest.raises dari Chapter Testing Exceptions:

  1. Baris 24-26 (test_bagi_dengan_nol): context manager pytest.raises(ZeroDivisionError) menggagalkan test justru ketika exception yang diharapkan tidak muncul. Memanggil hitung(5, "/", 0) di dalam blok with berarti menuntut ZeroDivisionError terjadi.
  2. Baris 29-31 (test_operator_tidak_dikenal): pola yang sama untuk ValueError — operator % harus ditolak hitung().

Jalankan test dari folder yang berisi kedua berkas:

bash
python3 -m pytest test_main.py -v

Output yang Dihasilkan:

text
============================= test session starts ==============================
platform linux -- Python 3.12.15, pytest-9.0.2, pluggy-1.6.0
rootdir: /tmp/kalkulator-terminal
collected 8 items

test_main.py::test_tambah PASSED                                         [ 12%]
test_main.py::test_kurang PASSED                                         [ 25%]
test_main.py::test_kali PASSED                                           [ 37%]
test_main.py::test_bagi PASSED                                           [ 50%]
test_main.py::test_bagi_dengan_nol PASSED                               [ 62%]
test_main.py::test_operator_tidak_dikenal PASSED                         [ 75%]
test_main.py::test_bilangan_negatif PASSED                               [ 87%]
test_main.py::test_bilangan_desimal PASSED                               [100%]

============================== 8 passed in 0.01s ===============================

Baris platform dan rootdir mengikuti mesin serta lokasi folder proyek masing-masing. Delapan test lulus dalam sepersekian detik — perbandingan hitung(10, "+", 5) == 15 bernilai True karena Python menganggap 15.0 sama dengan 15.

Sebagai bukti terakhir bahwa logika kini dapat dipakai tanpa menjalankan program interaktif, impor langsung dari interpreter:

bash
python3 -c "from main import hitung; print(hitung(8, '*', 3))"

Output yang Dihasilkan:

text
24

Mencoba Program Akhir #

Sesi lengkap program akhir — pembagian lalu penjumlahan:

bash
printf '4\n10\n4\ny\n1\n2.5\n1.5\nt\n' | python3 main.py

Output yang Dihasilkan:

text
=== Kalkulator Terminal ===
1. Tambah
2. Kurang
3. Kali
4. Bagi
5. Keluar
Pilih operasi (1-5): Angka pertama: Angka kedua: Hasil: 10 / 4 = 2.5
Hitung lagi? (y/t): === Kalkulator Terminal ===
1. Tambah
2. Kurang
3. Kali
4. Bagi
5. Keluar
Pilih operasi (1-5): Angka pertama: Angka kedua: Hasil: 2.5 + 1.5 = 4
Hitung lagi? (y/t): Terima kasih telah menggunakan Kalkulator Terminal.

Pembagian dengan nol kini ditangani lapisan main(), bukan lagi oleh function hitung:

bash
printf '4\n10\n0\nt\n' | python3 main.py

Output yang Dihasilkan:

text
=== Kalkulator Terminal ===
1. Tambah
2. Kurang
3. Kali
4. Bagi
5. Keluar
Pilih operasi (1-5): Angka pertama: Angka kedua: Error: pembagian dengan nol tidak diperbolehkan.
Hitung lagi? (y/t): Terima kasih telah menggunakan Kalkulator Terminal.

Ringkasan dan Penutup Proyek #

Empat bagian proyek kini utuh: kerangka menu dan struktur function dari Bagian 1, dispatch dictionary dan operasi aritmatika dari Bagian 2, validasi input dengan coba-ulang dari Bagian 3, serta fungsi murni hitung(), penanganan error terpusat, dan delapan test pytest dari bagian ini. Program yang dihasilnya kecil, tetapi arsitekturnya mengikuti pola proyek Python sungguhan: logika terpisah dari I/O, error dilaporkan lewat exception, dan perubahan apa pun tinggal dijaga dengan menjalankan python3 -m pytest.

Sebagai latihan lanjutan, coba tambahkan operasi pangkat (**) atau akar, daftar riwayat perhitungan yang disimpan ke list, atau parsing ekspresi lengkap seperti 2 + 3 * 4 — kandidat alami berikutnya adalah proyek dengan argparse pada Section CLI dan Automation. Berkas lengkap bagian ini dapat diunduh di bagian bawah halaman.

Berkas Sumber

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

main.py

Unduh berkas
python
"""Kalkulator Terminal - penanganan error dan testing.

Bagian 4: logika hitung dipisahkan sebagai fungsi murni,
error ditangani pada satu lapisan, dan seluruh logika diuji dengan pytest.
"""


def hitung(a: float, op: str, b: float) -> float:
    """Kembalikan hasil a <op> b untuk op salah satu dari + - * /.

    Membangkitkan ZeroDivisionError bila op adalah "/" dan b bernilai 0,
    serta ValueError bila op tidak dikenal.
    """
    if op == "+":
        return a + b
    if op == "-":
        return a - b
    if op == "*":
        return a * b
    if op == "/":
        return a / b
    raise ValueError(f"operator tidak dikenal: {op!r}")


def tampilkan_menu():
    """Cetak daftar operasi yang tersedia."""
    print("=== Kalkulator Terminal ===")
    print("1. Tambah")
    print("2. Kurang")
    print("3. Kali")
    print("4. Bagi")
    print("5. Keluar")


PILIHAN_KE_OPERATOR = {
    "1": "+",
    "2": "-",
    "3": "*",
    "4": "/",
}


def baca_pilihan():
    """Baca nomor operasi; ulangi sampai pengguna memasukkan 1-5."""
    while True:
        pilihan = input("Pilih operasi (1-5): ").strip()
        if pilihan in ("1", "2", "3", "4", "5"):
            return pilihan
        print(f"Pilihan '{pilihan}' tidak dikenal. Masukkan angka 1-5.")


def baca_angka(label):
    """Baca satu angka; ulangi sampai input dapat dikonversi menjadi float."""
    while True:
        teks = input(f"{label}: ").strip()
        try:
            return float(teks)
        except ValueError:
            print(f"'{teks}' bukan angka yang valid. Coba lagi.")


def tanya_lagi():
    """Tanyakan apakah pengguna ingin menghitung lagi; terima hanya y atau t."""
    while True:
        jawaban = input("Hitung lagi? (y/t): ").strip().lower()
        if jawaban in ("y", "t"):
            return jawaban == "y"
        print("Jawaban harus 'y' atau 't'.")


def main():
    """Titik masuk program: koordinasi input, hitung, dan output."""
    while True:
        tampilkan_menu()
        pilihan = baca_pilihan()

        if pilihan == "5":
            break

        op = PILIHAN_KE_OPERATOR[pilihan]
        a = baca_angka("Angka pertama")
        b = baca_angka("Angka kedua")

        try:
            hasil = hitung(a, op, b)
        except ZeroDivisionError:
            print("Error: pembagian dengan nol tidak diperbolehkan.")
        else:
            print(f"Hasil: {a:g} {op} {b:g} = {hasil:g}")

        if not tanya_lagi():
            break

    print("Terima kasih telah menggunakan Kalkulator Terminal.")


if __name__ == "__main__":
    main()

test_main.py

Unduh berkas
python
"""Test untuk fungsi murni hitung() pada Kalkulator Terminal."""

import pytest

from main import hitung


def test_tambah():
    assert hitung(10, "+", 5) == 15


def test_kurang():
    assert hitung(10, "-", 4) == 6


def test_kali():
    assert hitung(6, "*", 7) == 42


def test_bagi():
    assert hitung(10, "/", 4) == 2.5


def test_bagi_dengan_nol():
    with pytest.raises(ZeroDivisionError):
        hitung(5, "/", 0)


def test_operator_tidak_dikenal():
    with pytest.raises(ValueError):
        hitung(5, "%", 2)


def test_bilangan_negatif():
    assert hitung(-4, "+", 9) == 5
    assert hitung(-3, "*", -2) == 6


def test_bilangan_desimal():
    assert hitung(2.5, "*", 4) == 10
    assert hitung(7.5, "-", 0.5) == 7