Bab 18 — Testing Dasar: Memeriksa Kode Otomatis dengan pytest

Seri I1 — Kode yang Rapi dan Terawat

CatatanDi bab ini, Anda akan:
  • Memahami mengapa tes otomatis menyelamatkan Anda saat kode diubah
  • Menulis pernyataan assert dan fungsi tes pertama
  • Menjalankan pytest, membaca laporan lulus dan gagal
  • Memakai fixture (bahan siap pakai), parametrize, dan pytest.raises
  • Menata folder tes dan menguji paket inventaris dari proyek bersambung

Semua skrip bab ini ada di folder kode/bab18/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Bayangkan Anda mengganti rumus biomassa di kelas Kolam. Apakah kandang, sawah, dan plot hutan masih menghasilkan angka yang benar? Memeriksa semuanya dengan mata tiap kali ada perubahan itu melelahkan dan pasti ada yang terlewat. Solusinya: tulis tes otomatis, yaitu kode kecil yang memanggil kode Anda lalu membandingkan hasilnya dengan jawaban yang diharapkan. Seperti timbangan ikan yang dikalibrasi tiap pagi: sekali disiapkan, ia memeriksa dalam sekejap.

Tes tinggal di folder sendiri, memanggil kode proyek, dan pytest melaporkan lulus atau gagal.

assert: “pastikan ini benar”

Dasar semua tes di Python adalah assert. Bila syaratnya benar, program lanjut diam-diam. Bila salah, muncul AssertionError (error “pernyataan tidak terpenuhi”).

01_assert_dasar.py
# Bab 18 - assert: "pastikan ini benar, kalau tidak, berhenti"
biomassa_kg = 800 * 540.5 / 1000

assert biomassa_kg > 0
print("assert pertama lolos: biomassa", biomassa_kg, "kg")

try:
    assert biomassa_kg == 400, "biomassa seharusnya 400 kg"
except AssertionError as e:
    print("assert kedua gagal:", e)

jumlah = 0
try:
    assert jumlah > 0
except AssertionError:
    print("assert tanpa pesan juga bisa gagal")
assert pertama lolos: biomassa 432.4 kg
assert kedua gagal: biomassa seharusnya 400 kg
assert tanpa pesan juga bisa gagal

Pernyataan pertama lolos tanpa suara selain print sesudahnya. Pada yang kedua, tanda koma menambahkan pesan yang muncul saat gagal. Skrip menangkapnya dengan try/except (Bab 12) supaya program tidak berhenti.

pytest dan tes pertama

pytest adalah pustaka tes yang paling banyak dipakai pemrogram Python. Ia sudah terpasang di environment buku ini; di komputer Anda, pasang dengan pip install pytest di dalam venv (Bab 17). Aturannya sederhana:

  • Berkas tes bernama test_*.py (atau *_test.py).
  • Fungsi tes bernama test_....
  • Di dalamnya, tulis assert.

(Sumber: dokumentasi resmi pytest, halaman “Good Integration Practices”, dibaca 2026-10-10.)

02_tes_pertama.py
# Bab 18 - Tes pytest pertama: berkas test_*.py berisi fungsi test_*
from pathlib import Path

from bantu import FOLDER, jalankan_pytest

print(Path(FOLDER / "contoh_sederhana" / "test_hitung.py").read_text(encoding="utf-8"))
print("--- hasil pytest ---")
teks, kode = jalankan_pytest(["-v"], FOLDER / "contoh_sederhana")
print(teks)
print("kode keluar:", kode)
def biomassa_kg(jumlah, bobot_g):
    return jumlah * bobot_g / 1000


def test_biomassa_sederhana():
    assert biomassa_kg(800, 500) == 400


def test_biomassa_nol():
    assert biomassa_kg(0, 540.5) == 0

--- hasil pytest ---
============================= test session starts =============================
collecting ... collected 2 items

test_hitung.py::test_biomassa_sederhana PASSED                           [ 50%]
test_hitung.py::test_biomassa_nol PASSED                                 [100%]

============================== 2 passed ==============================
kode keluar: 0

Skrip ini menjalankan pytest lewat subprocess pada folder contoh_sederhana/. Pada keluaran, test_hitung.py memuat dua fungsi tes, keduanya PASSED (lulus), dan ringkasannya “2 passed”. Kode keluar 0 artinya semuanya beres. pytest memberi kode keluar 1 bila ada tes gagal, sehingga bisa dipakai oleh skrip otomatis dan CI (Bab 20). Pada keluaran buku ini, waktu proses di ringkasan sengaja dibuang supaya hasilnya tetap sama setiap kali dibuat.

Saat tes gagal

Tes yang selalu lulus belum membuktikan apa-apa; Anda perlu tahu seperti apa laporannya bila gagal. Berkas contoh_gagal/test_gagal.py sengaja berisi harapan yang salah (400 kg, padahal hitungannya 432,4 kg).

04_tes_gagal.py
# Bab 18 - Membaca laporan tes yang gagal
from bantu import FOLDER, jalankan_pytest

teks, kode = jalankan_pytest(["-q"], FOLDER / "contoh_gagal")
print(teks)
print("kode keluar:", kode)
F                                                                        [100%]
================================== FAILURES ===================================
_________________________ test_biomassa_salah_harapan _________________________

    def test_biomassa_salah_harapan():
        ikan = Ikan("lele", 800, 540.5, 7.2)
>       assert ikan.biomassa_kg() == 400.0
E       AssertionError: assert 432.4 == 400.0
E        +  where 432.4 = biomassa_kg()
E        +    where biomassa_kg = Ikan(jenis='lele', jumlah=800, bobot_rata_g=540.5, ph_air=7.2).biomassa_kg

test_gagal.py:6: AssertionError
=========================== short test summary info ===========================
FAILED test_gagal.py::test_biomassa_salah_harapan - AssertionError: assert 43...
1 failed
kode keluar: 1

Cara membacanya dari atas:

  • Huruf F menandai satu tes gagal (tes lulus berupa titik).
  • Tanda panah > menunjuk baris assert yang gagal.
  • Baris E (error) menjelaskan: nilai sebenarnya 432,4, harapannya 400,0. pytest bahkan menguraikan dari mana 432,4 berasal (biomassa_kg() pada objek Ikan itu).
  • Terakhir ada ringkasan FAILED ... dan kode keluar 1.
TipTIPS

Saat tes gagal, jangan langsung mengubah tesnya agar lulus. Tanyakan dulu: yang salah kodenya atau harapannya? Di contoh ini yang salah adalah harapannya. Kadang yang salah justru kodenya, dan itulah gunanya tes.

Menata folder tes

Paket inventaris dari Bab 16 sekarang jadi folder proyek/. Tes ditaruh di folder tes/ di samping paketnya, tidak dicampur. pytest menyarankan pemisahan seperti ini (dokumentasi resmi, 2026-10-10).

proyek/
  pyproject.toml
  inventaris/
    __init__.py   unit.py   barang.py   contoh.py   laporan.py
  tes/
    conftest.py
    test_barang.py   test_unit.py   test_laporan.py

Aturan pytest ditulis di pyproject.toml (Bab 17):

[tool.pytest.ini_options]
testpaths = ["tes"]
pythonpath = ["."]

testpaths memberi tahu pytest untuk mencari tes hanya di tes/. pythonpath = ["."] menambahkan folder proyek ke jalur impor, sehingga import inventaris berhasil tanpa memasangnya dulu.

03_daftar_tes.py
# Bab 18 - Daftar tes yang ditemukan pytest di proyek (tanpa menjalankannya)
from bantu import FOLDER, jalankan_pytest

teks, kode = jalankan_pytest(["--collect-only", "-q"], FOLDER / "proyek")
print(teks)
tes/test_barang.py::test_biomassa_ikan
tes/test_barang.py::test_kategori_pohon[28.0-kecil]
tes/test_barang.py::test_kategori_pohon[32.5-sedang]
tes/test_barang.py::test_kategori_pohon[45.2-besar]
tes/test_barang.py::test_kategori_pohon[40.0-besar]
tes/test_barang.py::test_jumlah_ikan_negatif_ditolak
tes/test_laporan.py::test_simpan_ringkasan
tes/test_unit.py::test_luas_nol_ditolak
tes/test_unit.py::test_induk_tidak_bisa_menghitung
tes/test_unit.py::test_hasil_kolam
tes/test_unit.py::test_ringkasan_kolam
tes/test_unit.py::test_ringkasan_lima_unit
tes/test_unit.py::test_isi_tidak_bisa_diubah_dari_luar

13 tests collected

Ini mode --collect-only: pytest hanya mengumpulkan tes tanpa menjalankannya. Dari tiga berkas, ditemukan 13 tes. Tes test_kategori_pohon muncul empat kali, dengan tanda [28.0-kecil] dan seterusnya. Sebabnya ada di bagian berikut.

Fixture, parametrize, dan raises

Tiga alat pytest yang paling sering dipakai, semuanya ada di proyek kita.

Fixture adalah “bahan siap pakai”: fungsi bertanda @pytest.fixture yang dibuat sekali dan dipinjamkan ke tes lewat nama. Letakkan di conftest.py agar semua berkas tes bisa memakainya.

conftest.py
"""Fixture bersama: bahan siap pakai untuk semua berkas tes."""

import pytest

from inventaris import Ikan, Kolam
from inventaris.contoh import buat_contoh


@pytest.fixture
def unit_contoh():
    """Lima unit contoh, dibuat baru untuk setiap tes."""
    return buat_contoh()


@pytest.fixture
def kolam_a():
    kolam = Kolam("A", 0.04)
    kolam.tambah(Ikan("lele", 800, 540.5, 7.2))
    return kolam

Tes cukup menulis nama fixture sebagai parameter, dan pytest mengisinya. Setiap tes mendapat objek baru, sehingga tes tidak saling mengganggu.

test_barang.py
import pytest

from inventaris import Ikan, Pohon


def test_biomassa_ikan():
    ikan = Ikan("lele", 800, 540.5, 7.2)
    assert ikan.biomassa_kg() == pytest.approx(432.4)


@pytest.mark.parametrize(
    ("diameter", "harapan"),
    [(28.0, "kecil"), (32.5, "sedang"), (45.2, "besar"), (40.0, "besar")],
)
def test_kategori_pohon(diameter, harapan):
    assert Pohon("Jati", diameter, 15.0).kategori() == harapan


def test_jumlah_ikan_negatif_ditolak():
    with pytest.raises(ValueError, match="negatif"):
        Ikan("lele", -1, 500.0, 7.0)
  • @pytest.mark.parametrize menjalankan satu fungsi tes untuk beberapa pasang data. Empat pasangan diameter menjadi empat tes (itulah [28.0-kecil] tadi). Diameter 40,0 sengaja dimasukkan untuk menguji batas: kategori besar dimulai tepat di 40.
  • pytest.approx(432.4) membandingkan bilangan desimal dengan toleransi kecil, supaya kesalahan pembulatan tidak menggagalkan tes (ingat Bab 6: bilangan desimal bisa sedikit meleset).
  • pytest.raises(ValueError, match="negatif") menyatakan: “kode ini harus melempar error”. Bila tidak melempar, tes gagal. Inilah cara menguji bahwa penjaga data bekerja.
test_unit.py
import pytest

from inventaris import Kolam, Unit


def test_luas_nol_ditolak():
    with pytest.raises(ValueError):
        Kolam("X", 0)


def test_induk_tidak_bisa_menghitung():
    with pytest.raises(NotImplementedError):
        Unit("Polos", 1.0).hasil()


def test_hasil_kolam(kolam_a):
    assert kolam_a.hasil() == pytest.approx(432.4)


def test_ringkasan_kolam(kolam_a):
    assert kolam_a.ringkasan() == "Kolam A: 1 catatan, 432.4 kg biomassa"


def test_ringkasan_lima_unit(unit_contoh):
    teks = [unit.ringkasan() for unit in unit_contoh]
    assert teks[0] == "Plot hutan A: 3 catatan, 2.0 pohon sehat"
    assert teks[2] == "Sawah Hilir: 1 catatan, 1250.0 kg (2500 kg/ha)"
    assert len(teks) == 5


def test_isi_tidak_bisa_diubah_dari_luar(kolam_a):
    assert isinstance(kolam_a.isi, tuple)
    with pytest.raises(AttributeError):
        kolam_a.isi.append("x")

Tes test_ringkasan_lima_unit memakai fixture unit_contoh dan membandingkan teks ringkasan persis. Ada pula tes test_induk_tidak_bisa_menghitung untuk perilaku polimorfisme Bab 16.

test_laporan.py
from inventaris.laporan import simpan_ringkasan


def test_simpan_ringkasan(tmp_path, unit_contoh):
    jalur = tmp_path / "ringkasan.txt"
    jumlah = simpan_ringkasan(unit_contoh, jalur)
    isi = jalur.read_text(encoding="utf-8").splitlines()
    assert jumlah == 5
    assert isi[3] == "Kolam A: 1 catatan, 432.4 kg biomassa"

Fixture bawaan tmp_path memberi folder sementara yang dibuang sesudah tes, sehingga tes yang menulis berkas tidak mengotori komputer Anda.

CatatanINGAT

Tes yang baik: kecil (satu hal per tes), mandiri (tidak bergantung pada tes lain), dan namanya menjelaskan harapan. test_luas_nol_ditolak langsung memberi tahu apa yang rusak bila ia gagal.

Menjalankan sebagian tes

Pada proyek besar, Anda sering hanya ingin menjalankan tes yang berkaitan dengan bagian yang sedang diubah. Opsi -k memilih tes berdasarkan potongan nama, dan -v (verbose) menampilkan tiap tes.

05_pilih_tes.py
# Bab 18 - Menjalankan sebagian tes: -k memilih berdasarkan nama
from bantu import FOLDER, jalankan_pytest

teks, kode = jalankan_pytest(["-v", "-k", "kolam"], FOLDER / "proyek")
print(teks)
============================= test session starts =============================
testpaths: tes
collecting ... collected 13 items / 11 deselected / 2 selected

tes/test_unit.py::test_hasil_kolam PASSED                                [ 50%]
tes/test_unit.py::test_ringkasan_kolam PASSED                            [100%]

====================== 2 passed, 11 deselected =======================

Dari 13 tes yang terkumpul, 11 dikesampingkan (deselected) dan hanya 2 yang namanya memuat “kolam” dijalankan.

Tata letak yang disarankan pytest untuk proyek yang akan dipasang adalah src/ layout: paket di src/inventaris/ dan tes di tes/, lalu dipasang dengan pip install -e . (“editable”: perubahan kode langsung terbaca tanpa memasang ulang). Buku ini memakai tata letak datar (paket langsung di proyek/) dengan pythonpath = ["."] karena lebih sederhana untuk pemula. Dokumentasi pytest juga menyarankan --import-mode=importlib untuk proyek baru; opsi itu tidak dipakai di sini.

Proyek Bersambung I1: tes untuk paket inventaris

Sekarang seluruh tes proyek dijalankan sekaligus. Lihat kembali unit_contoh dari Bab 16: kelima unit itu kini dijaga oleh tes.

06_proyek_semua_tes.py
# Bab 18 - Proyek bersambung I1: seluruh tes paket inventaris
from bantu import FOLDER, jalankan_pytest

teks, kode = jalankan_pytest(["-v"], FOLDER / "proyek")
print(teks)
print("kode keluar:", kode)
============================= test session starts =============================
testpaths: tes
collecting ... collected 13 items

tes/test_barang.py::test_biomassa_ikan PASSED                            [  7%]
tes/test_barang.py::test_kategori_pohon[28.0-kecil] PASSED               [ 15%]
tes/test_barang.py::test_kategori_pohon[32.5-sedang] PASSED              [ 23%]
tes/test_barang.py::test_kategori_pohon[45.2-besar] PASSED               [ 30%]
tes/test_barang.py::test_kategori_pohon[40.0-besar] PASSED               [ 38%]
tes/test_barang.py::test_jumlah_ikan_negatif_ditolak PASSED              [ 46%]
tes/test_laporan.py::test_simpan_ringkasan PASSED                        [ 53%]
tes/test_unit.py::test_luas_nol_ditolak PASSED                           [ 61%]
tes/test_unit.py::test_induk_tidak_bisa_menghitung PASSED                [ 69%]
tes/test_unit.py::test_hasil_kolam PASSED                                [ 76%]
tes/test_unit.py::test_ringkasan_kolam PASSED                            [ 84%]
tes/test_unit.py::test_ringkasan_lima_unit PASSED                        [ 92%]
tes/test_unit.py::test_isi_tidak_bisa_diubah_dari_luar PASSED            [100%]

============================= 13 passed ==============================
kode keluar: 0

Semua 13 tes lulus. Mulai sekarang, setiap kali Anda mengubah kelas Unit atau anak-anaknya, jalankan python -m pytest. Bila ada angka yang berubah tanpa sengaja, pytest memberi tahu persis di mana. Itulah arti kalimat “aman diubah” di penutup seri.

Latihan

  1. Pada contoh_sederhana/test_hitung.py, tambahkan tes ketiga: biomassa_kg(1250, 480) harus 600. Jalankan dengan python -m pytest di folder itu.
  2. Ubah angka harapan pada contoh_gagal/test_gagal.py menjadi pytest.approx(432.4) lalu jalankan; pastikan tes lulus.
  3. Tulis tes test_hasil_kandang untuk kelas Kandang: tambahkan tiga Ternak (350, 412,5, dan 389,0 kg) dan periksa hasilnya 1151,5.
  4. Tantangan: tulis tes berparameter untuk Pohon.kategori() dengan lima diameter, termasuk batas 30,0. Lalu sengaja ubah >= 30 menjadi > 30 di barang.py, jalankan tes, lihat tes batas gagal, dan kembalikan kodenya.

Ringkasan Bab

  • Tes otomatis memeriksa kode Anda tiap kali dijalankan, jadi perubahan tidak merusak diam-diam.
  • assert adalah dasarnya; berkas test_*.py berisi fungsi test_* dibaca otomatis oleh pytest.
  • Tes gagal ditandai F; baris > menunjuk assert, baris E memberi nilai sebenarnya dan harapannya. Kode keluar 1.
  • Fixture (conftest.py), parametrize, pytest.approx, pytest.raises, dan tmp_path adalah alat sehari-hari.
  • Taruh tes di folder tes/ di samping paket; atur lewat [tool.pytest.ini_options] di pyproject.toml.
  • Paket inventaris kini punya 13 tes yang lulus.

Selanjutnya, di Bab 19: kualitas kode, cara merapikan gaya penulisan otomatis dengan ruff, mencatat jalannya program dengan logging, dan mendokumentasikan proyek.