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.
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:
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_keyerrorMari kita bedah bagian pentingnya:
data_mentah(): lima baris yang mewakili seluruh spektrum —TRX-001dengan spasi dan huruf kecil,TRX-002dengan spasi di tengah dan harga berupa teks,TRX-003dengan jumlah"x",TRX-004dengan harga"seribu", dan baris tanpakode_transaksi. Lima baris ini cukup untuk menguji semua aturan fungsi.test_hasil_bersih_dikunci_persis(): output dikunci denganassert_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.test_teks_dengan_spasi_dan_kapital(): memastikan"jakarta","JAKARTA", dan" Bandung "dinormalisasi ke bentuk kanonik. Tanpa test ini, perubahanstr.title()menjadistr.lower()bisa lolos dan membuat dua cabang terlihat sebagai dua grup berbeda.test_baris_tak_valid_dibuang():pd.to_numeric(..., errors="coerce")mengubah"x"dan"seribu"menjadiNaN, laludropna(subset=...)membuang baris-baris itu. Test menegaskan keputusan desain: baris tak valid tidak ikut ke output, bukan diisi nol.test_dataframe_kosong(): input nol baris tetap harus menghasilkan output nol baris dengan kolom lengkap termasuktotal. Kasus tepi ini sering diabaikan dan menjadi sumberIndexErroratau kolom hilang saat hari pertama bulan — saat data baru belum masuk.test_kolom_hilang_melempar_keyerror(): input tanpa kolomhargaberhenti denganKeyError, ditangkap di dalampytest.raises. Keputusan desainnya eksplisit: kolom wajib yang hilang adalah kondisi yang harus berhenti, bukan diam-diam menghasilkan kolom kosong.jalankan_semua(): runner yang sama seperti chapter sebelumnya, sehingga kelima test bisa dieksekusi langsung denganpython3 uji_bersihkan.pytanpa alat tambahan.
Kasus Tepi yang Layak Ada di Setiap Suite #
| Kasus tepi | Kenapa penting |
|---|---|
| Input kosong (0 baris) | Hari pertama bulan, file sumber belum terisi |
| Teks dengan spasi/kapital tak konsisten | Sumber data diisi manual oleh banyak orang |
| Angka berformat teks | CSV menyimpan semua kolom sebagai teks |
| Kunci utama kosong atau duplikat | Baris ganda dan data uji tertinggal di sumber |
| Kolom wajib hilang | Perubahan nama kolom di hulu tanpa kabar |
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] == totalSetiap 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 .