Transformasi data adalah bagian pipeline yang paling sering berubah: aturan pembersihan baru, format sumber berganti, kasus aneh muncul dari lapangan. Perubahan kecil pada satu baris kode transformasi bisa menggeser angka laporan tanpa error apa pun. Karena itu setiap fungsi transformasi layak dikunci dengan test yang memakai input kecil, output terdefinisi, dan kasus tepi. Chapter ini menerapkan pola pengujian dari dua chapter sebelumnya pada satu fungsi pembersihan transaksi yang utuh, dari teks kotor sampai angka rusak.

Mendesain Input Uji yang Mewakili #

Satu fungsi transformasi biasanya menyembunyikan banyak aturan sekaligus: merapikan teks, mengonversi angka, dan membuang baris yang tidak sah. Input uji yang baik memuat semua jalur itu dalam beberapa baris saja — baris bersih sebagai acuan, baris dengan spasi dan kapitalisasi tidak konsisten, baris dengan angka yang tidak sah, dan baris tanpa kunci. Data uji seperti ini dibuat secara manual di dalam skrip, bukan diambil dari file besar, supaya output harapannya bisa dihitung tanpa alat bantu dan test berjalan dalam sekejap.

Lima Test untuk Satu Fungsi Pembersihan #

Skrip berikut menguji bersihkan_transaksi(): fungsi yang merapikan teks, mengubah kolom angka menjadi numerik, menghitung total, lalu membuang baris yang kunci atau totalnya kosong. Kelima test mencakup output terdefinisi, normalisasi teks, pembuangan baris tak valid, input kosong, dan kolom wajib yang hilang.

python
import pytest
import pandas as pd
from pandas.testing import assert_frame_equal


def bersihkan_transaksi(df: pd.DataFrame) -> pd.DataFrame:
    """Rapikan teks, bersihkan angka, hitung total, buang baris tak valid."""
    hasil = df.copy()
    hasil["kode_transaksi"] = hasil["kode_transaksi"].str.strip()
    hasil["cabang"] = hasil["cabang"].str.strip().str.title()
    hasil["jumlah"] = pd.to_numeric(hasil["jumlah"], errors="coerce")
    hasil["harga"] = pd.to_numeric(hasil["harga"], errors="coerce")
    hasil["total"] = hasil["jumlah"] * hasil["harga"]
    hasil = hasil.dropna(subset=["kode_transaksi", "total"])
    return hasil.reset_index(drop=True)


def data_mentah() -> pd.DataFrame:
    """Input kecil yang memuat semua kasus: bersih, kotor, dan rusak."""
    return pd.DataFrame(
        {
            "kode_transaksi": [" TRX-001 ", "TRX-002", "TRX-003", "TRX-004", None],
            "cabang": ["jakarta", " Bandung ", "JAKARTA", "Medan", "Surabaya"],
            "jumlah": ["2", 1, "x", 4, 3],
            "harga": [10_000, "20000", 5_000, "seribu", 7_500],
        }
    )


def test_hasil_bersih_dikunci_persis():
    """Output terdefinisi: dua baris valid dengan nilai dan dtype tertentu."""
    hasil = bersihkan_transaksi(data_mentah())
    harapan = pd.DataFrame(
        {
            "kode_transaksi": ["TRX-001", "TRX-002"],
            "cabang": ["Jakarta", "Bandung"],
            "jumlah": [2.0, 1.0],
            "harga": [10_000.0, 20_000.0],
            "total": [20_000.0, 20_000.0],
        }
    )
    assert_frame_equal(hasil, harapan)


def test_teks_dengan_spasi_dan_kapital():
    """Kasus tepi: spasi ekstra dan kapitalisasi tidak mengubah hasil."""
    hasil = bersihkan_transaksi(data_mentah())
    assert hasil["kode_transaksi"].tolist() == ["TRX-001", "TRX-002"]
    assert hasil["cabang"].tolist() == ["Jakarta", "Bandung"]


def test_baris_tak_valid_dibuang():
    """Kasus tepi: angka tidak sah dan kunci kosong tidak ikut ke output."""
    hasil = bersihkan_transaksi(data_mentah())
    assert len(hasil) == 2


def test_dataframe_kosong():
    """Kasus tepi: input kosong menghasilkan output kosong dengan kolom lengkap."""
    kosong = pd.DataFrame(
        {
            "kode_transaksi": pd.Series([], dtype="str"),
            "cabang": pd.Series([], dtype="str"),
            "jumlah": pd.Series([], dtype="int64"),
            "harga": pd.Series([], dtype="int64"),
        }
    )
    hasil = bersihkan_transaksi(kosong)
    assert len(hasil) == 0
    assert list(hasil.columns) == [
        "kode_transaksi", "cabang", "jumlah", "harga", "total",
    ]


def test_kolom_hilang_melempar_keyerror():
    """Kasus tepi: input tanpa kolom wajib berhenti dengan KeyError."""
    tanpa_harga = pd.DataFrame({"kode_transaksi": ["TRX-001"], "jumlah": [1]})
    with pytest.raises(KeyError):
        bersihkan_transaksi(tanpa_harga)


def jalankan_semua() -> None:
    """Runner kecil: jalankan setiap test_ dan laporkan hasilnya."""
    daftar = (
        test_hasil_bersih_dikunci_persis,
        test_teks_dengan_spasi_dan_kapital,
        test_baris_tak_valid_dibuang,
        test_dataframe_kosong,
        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_hasil_bersih_dikunci_persis
OK   test_teks_dengan_spasi_dan_kapital
OK   test_baris_tak_valid_dibuang
OK   test_dataframe_kosong
OK   test_kolom_hilang_melempar_keyerror

Mari kita bedah bagian pentingnya:

  1. data_mentah(): lima baris yang mewakili seluruh spektrum — TRX-001 dengan spasi dan huruf kecil, TRX-002 dengan spasi di tengah dan harga berupa teks, TRX-003 dengan jumlah "x", TRX-004 dengan harga "seribu", dan baris tanpa kode_transaksi. Lima baris ini cukup untuk menguji semua aturan fungsi.
  2. test_hasil_bersih_dikunci_persis(): output dikunci dengan assert_frame_equal() terhadap DataFrame harapan yang dihitung manual: dua baris, angka bulat, dtype float untuk kolom hasil konversi. Test ini adalah pagar utama — perubahan apa pun pada aturan bersihkan langsung terlihat.
  3. test_teks_dengan_spasi_dan_kapital(): memastikan "jakarta", "JAKARTA", dan " Bandung " dinormalisasi ke bentuk kanonik. Tanpa test ini, perubahan str.title() menjadi str.lower() bisa lolos dan membuat dua cabang terlihat sebagai dua grup berbeda.
  4. test_baris_tak_valid_dibuang(): pd.to_numeric(..., errors="coerce") mengubah "x" dan "seribu" menjadi NaN, lalu dropna(subset=...) membuang baris-baris itu. Test menegaskan keputusan desain: baris tak valid tidak ikut ke output, bukan diisi nol.
  5. test_dataframe_kosong(): input nol baris tetap harus menghasilkan output nol baris dengan kolom lengkap termasuk total. Kasus tepi ini sering diabaikan dan menjadi sumber IndexError atau kolom hilang saat hari pertama bulan — saat data baru belum masuk.
  6. test_kolom_hilang_melempar_keyerror(): input tanpa kolom harga berhenti dengan KeyError, ditangkap di dalam pytest.raises. Keputusan desainnya eksplisit: kolom wajib yang hilang adalah kondisi yang harus berhenti, bukan diam-diam menghasilkan kolom kosong.
  7. jalankan_semua(): runner yang sama seperti chapter sebelumnya, sehingga kelima test bisa dieksekusi langsung dengan python3 uji_bersihkan.py tanpa alat tambahan.

Kasus Tepi yang Layak Ada di Setiap Suite #

Kasus tepiKenapa penting
Input kosong (0 baris)Hari pertama bulan, file sumber belum terisi
Teks dengan spasi/kapital tak konsistenSumber data diisi manual oleh banyak orang
Angka berformat teksCSV menyimpan semua kolom sebagai teks
Kunci utama kosong atau duplikatBaris ganda dan data uji tertinggal di sumber
Kolom wajib hilangPerubahan nama kolom di hulu tanpa kabar
python parametrize untuk pasangan input-harapan berulang
import pandas as pd
import pytest

from uji_bersihkan import bersihkan_transaksi


@pytest.mark.parametrize("jumlah,harga,total", [(2, 10_000, 20_000), (1, 20_000, 20_000), (0, 5_000, 0)])
def test_total_konsisten(jumlah, harga, total):
    df = pd.DataFrame(
        {
            "kode_transaksi": ["TRX-001"],
            "cabang": ["Jakarta"],
            "jumlah": [jumlah],
            "harga": [harga],
        }
    )
    hasil = bersihkan_transaksi(df)
    assert hasil["total"].iloc[0] == total

Setiap baris pada daftar parametrize menjadi satu test terpisah di laporan pytest, sehingga pasangan yang gagal terlihat persis namanya.

Fungsi yang sudah terkunci oleh test dan gerbang tinggal satu kebutuhan agar bisa dipercaya penuh: hasil yang sama bila dijalankan lagi di waktu dan lingkungan berbeda. Persiapan itu — seed, versi pustaka, dan data uji tetap — dibahas pada chapter penutup section ini, Reproducible Data Processing.

Referensi resmi untuk chapter ini: pandas.DataFrame.pipe , pandas.to_numeric , dan pytest parametrize .