Empat chapter sebelumnya berlatih membaca dan melacak keanehan setelah kejadian; chapter ini menggeser kancingnya menjadi proaktif. Sebuah fungsi pandas yang benar hari ini bisa salah besok karena input baru, perubahan data sumber, atau upgrade versi. Unit test mengunci perilaku fungsi terhadap data kecil yang hasilnya kita hitung manual, sehingga perubahan apa pun yang menggeser hasil langsung terlihat. Chapter ini membangun fondasi pengujian DataFrame: anatomi satu test, pola perbandingan yang tepat untuk struktur, nilai, dan jalur error, serta cara menjalankannya dengan pytest.

Anatomi Satu Unit Test #

Satu unit test adalah fungsi bernama diawali test_ yang mengikuti pola tiga langkah: siapkan input kecil (arrange), panggil fungsi yang diuji (act), dan bandingkan hasil dengan nilai yang sudah dipastikan benar (assert). Inputnya sengaja kecil — dua sampai empat baris — supaya nilai harapannya bisa dihitung tanpa alat bantu. Test seperti ini bukan pengganti eksplorasi data besar; test menjaga logika, sedangkan eksplorasi menggali insight dari data nyata. Fungsi yang kita uji di chapter ini adalah omzet_per_cabang(), sebuah agregasi sederhana dari data transaksi toko.

Empat Sifat yang Layak Diuji #

Untuk satu fungsi transformasi, empat sifat berikut hampir selalu layak diuji: struktur hasil (kolom dan jumlah baris), nilai (angka per baris), kegagalan yang disengaja (agar kita yakin test memang bekerja), dan jalur error (input tidak sah melempar error yang tepat). Script berikut menguji keempatnya, lalu menjalankan semuanya lewat runner kecil bawaan sehingga bisa dieksekusi langsung tanpa alat tambahan.

python
import pytest
import pandas as pd


def omzet_per_cabang(df: pd.DataFrame) -> pd.DataFrame:
    """Hitung omzet per cabang dari data transaksi (unit yang diuji)."""
    return (
        df.assign(total=df["jumlah"] * df["harga"])
        .groupby("cabang", as_index=False)["total"]
        .sum()
    )


def data_uji() -> pd.DataFrame:
    """DataFrame kecil dengan hasil yang sudah dihitung manual."""
    return pd.DataFrame(
        {
            "cabang": ["Jakarta", "Bandung", "Jakarta", "Surabaya"],
            "jumlah": [2, 1, 3, 4],
            "harga": [10_000, 20_000, 5_000, 2_500],
        }
    )


def test_kolom_dan_baris():
    """Sifat 1 — struktur: kolom dan jumlah baris sesuai."""
    hasil = omzet_per_cabang(data_uji())
    assert list(hasil.columns) == ["cabang", "total"]
    assert len(hasil) == 3  # Jakarta, Bandung, Surabaya


def test_nilai_total_per_cabang():
    """Sifat 2 — nilai: setiap cabang memuat omzet yang dihitung manual."""
    hasil = omzet_per_cabang(data_uji())
    harapan = pd.DataFrame(
        {
            "cabang": ["Bandung", "Jakarta", "Surabaya"],
            "total": [20_000, 35_000, 10_000],
        }
    )
    assert hasil.equals(harapan)


def test_angka_dari_asumsi_adalah_kegagalan():
    """Sifat 2 (contoh GAGAL) — angka diambil dari asumsi, bukan data uji."""
    hasil = omzet_per_cabang(data_uji())
    assert hasil["total"].sum() == 999_999, "total 999.999 hanya asumsi"


def test_kolom_hilang_melempar_keyerror():
    """Sifat 3 — jalur error: kolom yang dibutuhkan tidak ada."""
    tanpa_harga = pd.DataFrame({"cabang": ["Jakarta"], "jumlah": [1]})
    with pytest.raises(KeyError):
        omzet_per_cabang(tanpa_harga)


def jalankan_semua() -> None:
    """Runner kecil: jalankan setiap test_ dan laporkan hasilnya."""
    daftar = (
        test_kolom_dan_baris,
        test_nilai_total_per_cabang,
        test_angka_dari_asumsi_adalah_kegagalan,
        test_kolom_hilang_melempar_keyerror,
    )
    for fungsi in daftar:
        try:
            fungsi()
        except Exception as exc:
            detail = str(exc).splitlines()[0] if str(exc) else "(tanpa pesan)"
            print(f"GAGAL {fungsi.__name__}: {type(exc).__name__}: {detail}")
        else:
            print(f"OK   {fungsi.__name__}")


if __name__ == "__main__":
    jalankan_semua()

Output yang Dihasilkan:

text
OK   test_kolom_dan_baris
OK   test_nilai_total_per_cabang
GAGAL test_angka_dari_asumsi_adalah_kegagalan: AssertionError: total 999.999 hanya asumsi
OK   test_kolom_hilang_melempar_keyerror

Mari kita bedah bagian pentingnya:

  1. data_uji(): DataFrame empat baris dengan dua transaksi Jakarta, satu Bandung, satu Surabaya. Dua baris untuk satu cabang sengaja disertakan — grup dengan lebih dari satu baris adalah kasus yang paling sering salah dihitung.
  2. test_kolom_dan_baris(): memeriksa struktur dengan assert list(hasil.columns) == [...] dan len(hasil) == 3. Perbandingan list kolom akan menangkap perubahan nama atau urutan kolom yang sering luput dari perhitungan.
  3. test_nilai_total_per_cabang(): membandingkan seluruh hasil dengan DataFrame harapan yang angkanya dihitung manual: Bandung 20.000, Jakarta 35.000, Surabaya 10.000. Metode DataFrame.equals() membandingkan bentuk, dtype, dan nilai sekaligus, termasuk nilai NaN yang dianggap sama posisi.
  4. test_angka_dari_asumsi_adalah_kegagalan(): test yang sengaja gagal. Total sebenarnya 65.000, jadi assert melempar AssertionError dengan pesan total 999.999 hanya asumsi. Melihat tampilan kegagalan sekali-sekali berguna: runner mencetak GAGAL diikuti pesan, dan test yang gagal tidak menghentikan test lain.
  5. test_kolom_hilang_melempar_keyerror(): memanggil omzet_per_cabang() dengan DataFrame tanpa kolom harga di dalam blok with pytest.raises(KeyError). Konteks manager ini hanya lolos bila error yang tepat terlempar; bila tidak ada error atau error salah kelas, blok tersebut menggagalkan test.
  6. jalankan_semua(): runner kecil yang memanggil setiap test dalam try/except dan mencetak statusnya. Perhatikan pola str(exc).splitlines()[0] if str(exc) else "(tanpa pesan)" — pesan AssertionError polos tanpa argumen bisa kosong, jadi runner perlu penangan tersebut.

Menjalankan Test yang Sama dengan pytest #

Runner kecil cocok untuk demonstrasi, tetapi pytest memberi banyak fasilitas: koleksi otomatis file test_*.py, laporan kegagalan yang detail, fixture untuk penyiapan data, dan parameterisasi. Versi pytest dari test yang sama cukup disimpan sebagai test_transaksi.py dan dijalankan dengan python3 -m pytest test_transaksi.py -q; pytest mencetak titik per test dan ringkasan seperti 3 passed.

python test_transaksi.py — versi pytest dari test yang sama
import pandas as pd
import pytest
from pandas.testing import assert_frame_equal

from uji_omzet import data_uji, omzet_per_cabang


def test_struktur():
    hasil = omzet_per_cabang(data_uji())
    assert list(hasil.columns) == ["cabang", "total"]
    assert len(hasil) == 3


def test_nilai_dengan_assert_frame_equal():
    hasil = omzet_per_cabang(data_uji())
    harapan = pd.DataFrame(
        {
            "cabang": ["Bandung", "Jakarta", "Surabaya"],
            "total": [20_000, 35_000, 10_000],
        }
    )
    assert_frame_equal(hasil, harapan)


def test_kolom_hilang():
    tanpa_harga = pd.DataFrame({"cabang": ["Jakarta"], "jumlah": [1]})
    with pytest.raises(KeyError):
        omzet_per_cabang(tanpa_harga)

Dua tambahan pytest yang layak dicatat: @pytest.mark.parametrize menjalankan satu fungsi test berkali-kali dengan pasangan input-harapan berbeda, dan fixture seperti tmp_path menyediakan folder sementara untuk test yang membaca atau menulis file.

Dengan keempat sifat dasar terkunci, langkah berikutnya adalah mengetahui opsi apa saja yang bisa diatur saat dua DataFrame dibandingkan — termasuk toleransi pembulatan float dan pemeriksaan dtype — yang dibahas penuh pada chapter berikutnya.

Referensi resmi untuk chapter ini: dokumentasi pytest , pytest.raises pada referensi API pytest , dan pandas.DataFrame.equals .