Program kita bekerja penuh dan tahan terhadap berbagai berkas bermasalah. Namun coba bayangkan harus menguji perilakunya: fungsi operasi menulis ke layar lewat print sekaligus mengubah data, dan validasi record menyatu dengan pembacaan berkas sehingga tidak bisa diperiksa tanpa membuat berkas sungguhan. Pada bagian terakhir ini kita melakukan refactoring — mengatur ulang kode tanpa mengubah perilakunya, sesuai prinsip pada materi Refactoring — menjadi unit kecil yang bisa diuji, melengkapi semuanya dengan type hints, lalu menutup proyek dengan test pytest yang seluruhnya wajib lulus.
Memisahkan Logika dari Tampilan #
Masalah inti main.py lama: setiap fungsi operasi mencampur dua tanggung jawab. Sebagai contoh, tandai_selesai() mengubah data, mencetak pesan, dan mengembalikan status sekaligus. Refactoring memindahkan seluruh print ke main(); fungsi operasi kini hanya menerima data, mengubahnya, dan melaporkan hasil lewat nilai kembalian:
def tandai_selesai(daftar_tugas: list[dict], nomor: int) -> bool:
for tugas in daftar_tugas:
if tugas["id"] == nomor:
tugas["selesai"] = True
return True
return False
def hapus_tugas(daftar_tugas: list[dict], nomor: int) -> dict | None:
for indeks, tugas in enumerate(daftar_tugas):
if tugas["id"] == nomor:
return daftar_tugas.pop(indeks)
return NoneFungsi tandai_selesai() kini murni logika: berikan list dan nomor, fungsi mengembalikan True bila ada tugas yang berubah. Fungsi hapus_tugas() mengembalikan dictionary tugas yang dihapus — atau None bila nomor tidak ditemukan — sehingga main() tetap bisa mencetak judul tugas yang dihapus seperti sebelumnya. Kini memanggil fungsi tersebut dari test sama mudahnya dengan memanggilnya dari main().
Perhatikan pula type hints yang menyertai setiap definisi: list[dict] untuk state, int untuk nomor, bool atau dict | None untuk hasil. Bentuk X | None (baca: X atau None) tersedia sejak Python 3.10 dan dibahas pada materi Union dan Optional Types. Dengan anotasi ini, main.py lengkap versi final menjadi:
"""Pengelola Daftar Tugas — program manajemen tugas berbasis terminal."""
import storage
NAMA_BERKAS = "tugas.json"
def tampilkan_menu() -> None:
print("=== Pengelola Daftar Tugas ===")
print("1. Tambah tugas")
print("2. Daftar tugas")
print("3. Tandai selesai")
print("4. Hapus tugas")
print("5. Keluar")
def nomor_id_baru(daftar_tugas: list[dict]) -> int:
if not daftar_tugas:
return 1
return max(tugas["id"] for tugas in daftar_tugas) + 1
def tambah_tugas(daftar_tugas: list[dict], judul: str) -> dict:
tugas_baru = {"id": nomor_id_baru(daftar_tugas), "judul": judul, "selesai": False}
daftar_tugas.append(tugas_baru)
return tugas_baru
def tampilkan_daftar(daftar_tugas: list[dict]) -> None:
if not daftar_tugas:
print("Belum ada tugas.")
return
for tugas in daftar_tugas:
penanda = "[x]" if tugas["selesai"] else "[ ]"
print(f"{penanda} {tugas['id']}. {tugas['judul']}")
def baca_nomor() -> int | None:
try:
return int(input("Nomor tugas: ").strip())
except ValueError:
print("Nomor tugas harus berupa angka.")
return None
def tandai_selesai(daftar_tugas: list[dict], nomor: int) -> bool:
for tugas in daftar_tugas:
if tugas["id"] == nomor:
tugas["selesai"] = True
return True
return False
def hapus_tugas(daftar_tugas: list[dict], nomor: int) -> dict | None:
for indeks, tugas in enumerate(daftar_tugas):
if tugas["id"] == nomor:
return daftar_tugas.pop(indeks)
return None
def main() -> None:
daftar_tugas = storage.muat_tugas(NAMA_BERKAS)
if daftar_tugas:
print(f"{len(daftar_tugas)} tugas dimuat dari {NAMA_BERKAS}.")
while True:
tampilkan_menu()
pilihan = input("Pilihan: ").strip()
if pilihan == "1":
judul = input("Judul tugas: ").strip()
if judul:
tugas_baru = tambah_tugas(daftar_tugas, judul)
print(f"Tugas \"{tugas_baru['judul']}\" tersimpan dengan id {tugas_baru['id']}.")
storage.simpan_tugas(daftar_tugas, NAMA_BERKAS)
else:
print("Judul tugas tidak boleh kosong.")
elif pilihan == "2":
tampilkan_daftar(daftar_tugas)
elif pilihan == "3":
nomor = baca_nomor()
if nomor is not None:
if tandai_selesai(daftar_tugas, nomor):
print(f"Tugas {nomor} ditandai selesai.")
storage.simpan_tugas(daftar_tugas, NAMA_BERKAS)
else:
print(f"Tugas dengan nomor {nomor} tidak ditemukan.")
elif pilihan == "4":
nomor = baca_nomor()
if nomor is not None:
dihapus = hapus_tugas(daftar_tugas, nomor)
if dihapus is not None:
print(f"Tugas \"{dihapus['judul']}\" dihapus.")
storage.simpan_tugas(daftar_tugas, NAMA_BERKAS)
else:
print(f"Tugas dengan nomor {nomor} tidak ditemukan.")
elif pilihan == "5":
print("Sampai jumpa!")
break
else:
print("Pilihan tidak dikenal.")
if __name__ == "__main__":
main()storage.py sebagai Fungsi Murni #
Validasi pada muat_tugas Bagian 4 adalah logika yang layak diuji, tetapi menyatu dengan pembacaan berkas. Refactoring memecahnya menjadi dua fungsi murni — fungsi yang hasilnya hanya ditentukan oleh argumennya, tanpa menyentuh berkas atau layer:
def tugas_sah(item: object) -> bool:
"""Periksa apakah satu record memiliki struktur tugas yang benar."""
if not isinstance(item, dict):
return False
id_sah = isinstance(item.get("id"), int) and not isinstance(item.get("id"), bool)
judul_sah = isinstance(item.get("judul"), str)
selesai_sah = isinstance(item.get("selesai"), bool)
return id_sah and judul_sah and selesai_sah
def saring_tugas(data: object) -> list[dict]:
"""Ambil hanya record yang sah dari hasil parsing JSON."""
if not isinstance(data, list):
return []
return [item for item in data if tugas_sah(item)]Isi tugas_sah() tidak berbeda dari empat pemeriksaan Bagian 4 — termasuk pengaman bool pada kunci id — hanya dibungkus sebagai satu pertanyaan yang bisa diajukan ke record mana pun. Fungsi saring_tugas() kemudian menerapkan pertanyaan itu ke seluruh data lewat list comprehension. Anotasi object pada parameter kedua fungsi ini jujur terhadap kenyataan: hasil json.load() bisa berupa tipe apa pun. Berikut storage.py lengkap versi final:
"""Modul penyimpanan daftar tugas ke berkas JSON."""
import json
import os
def tugas_sah(item: object) -> bool:
"""Periksa apakah satu record memiliki struktur tugas yang benar."""
if not isinstance(item, dict):
return False
id_sah = isinstance(item.get("id"), int) and not isinstance(item.get("id"), bool)
judul_sah = isinstance(item.get("judul"), str)
selesai_sah = isinstance(item.get("selesai"), bool)
return id_sah and judul_sah and selesai_sah
def saring_tugas(data: object) -> list[dict]:
"""Ambil hanya record yang sah dari hasil parsing JSON."""
if not isinstance(data, list):
return []
return [item for item in data if tugas_sah(item)]
def simpan_tugas(daftar_tugas: list[dict], nama_file: str) -> None:
"""Simpan seluruh daftar tugas ke berkas JSON."""
with open(nama_file, "w", encoding="utf-8") as berkas:
json.dump(daftar_tugas, berkas, ensure_ascii=False, indent=2)
def muat_tugas(nama_file: str) -> list[dict]:
"""Muat daftar tugas dari berkas JSON."""
try:
with open(nama_file, "r", encoding="utf-8") as berkas:
data = json.load(berkas)
except FileNotFoundError:
return []
except json.JSONDecodeError:
print(f"Berkas {nama_file} tidak berisi JSON yang valid.")
os.replace(nama_file, nama_file + ".bak")
print(f"Berkas yang rusak dipindah ke {nama_file}.bak.")
print("Program memulai dengan daftar tugas kosong.")
return []
return saring_tugas(data)Menulis test_storage.py #
Sekarang saatnya menuai hasilnya. Test berikut memeriksa lima perilaku storage.py menggunakan pytest — penulisan test-nya mengikuti pola materi Menulis Test dengan pytest dan fixture tmp_path dari materi Fixtures:
"""Test untuk modul penyimpanan daftar tugas."""
import json
import storage
def test_simpan_dan_muat_bolak_balik(tmp_path):
berkas = tmp_path / "tugas.json"
daftar = [
{"id": 1, "judul": "Belajar Python dasar", "selesai": False},
{"id": 2, "judul": "Beli susu", "selesai": True},
]
storage.simpan_tugas(daftar, str(berkas))
hasil = storage.muat_tugas(str(berkas))
assert hasil == daftar
def test_muat_berkas_hilang(tmp_path):
berkas = tmp_path / "tidak-ada.json"
hasil = storage.muat_tugas(str(berkas))
assert hasil == []
def test_muat_berkas_rusak(tmp_path, capsys):
berkas = tmp_path / "rusak.json"
berkas.write_text("{ id: 1, judul: tanpa tanda kutip }", encoding="utf-8")
hasil = storage.muat_tugas(str(berkas))
tangkapan = capsys.readouterr()
assert hasil == []
assert not berkas.exists()
assert (tmp_path / "rusak.json.bak").exists()
assert "tidak berisi JSON yang valid" in tangkapan.out
def test_muat_melewati_record_tidak_sah(tmp_path):
berkas = tmp_path / "tugas.json"
berkas.write_text(
json.dumps(
[
{"id": 1, "judul": "Belajar Python dasar", "selesai": False},
{"id": "dua", "judul": "Id bertipe string", "selesai": False},
{"id": True, "judul": "Id bertipe boolean", "selesai": False},
{"id": 3, "judul": 42, "selesai": False},
{"id": 4, "judul": "Tanpa kunci selesai"},
"bukan dictionary",
]
),
encoding="utf-8",
)
hasil = storage.muat_tugas(str(berkas))
assert hasil == [{"id": 1, "judul": "Belajar Python dasar", "selesai": False}]
def test_simpan_daftar_kosong(tmp_path):
berkas = tmp_path / "kosong.json"
storage.simpan_tugas([], str(berkas))
assert json.loads(berkas.read_text(encoding="utf-8")) == []Lima test tersebut menutup seluruh jangkauan modul:
test_simpan_dan_muat_bolak_balik: simpan dua tugas lalu muat kembali — hasilnya harus sama persis. Fixturetmp_pathmenyediakan folder sementara unik per test, sehingga test tidak pernah saling menimpa berkas.test_muat_berkas_hilang: memuat berkas yang tidak ada harus mengembalikan list kosong, bukan exception.test_muat_berkas_rusak: berkas rusak menghasilkan list kosong, berkas asli dipindah ke.bak, dan pesan peringatan benar-benar tercetak. Fixturecapsysmenangkap keluaranprint()agar bisa ikut dipertanggungjawabkan.test_muat_melewati_record_tidak_sah: dari enam record berisi berbagai pelanggaran struktur — id string, id boolean, judul angka, kunci hilang, dan elemen non-dictionary — hanya satu record sah yang boleh lolos.test_simpan_daftar_kosong: list kosong tetap harus tersimpan sebagai JSON[], bukan berkas gagal dibuat.
Menjalankan Test #
Dari folder yang berisi ketiga berkas, jalankan pytest:
python3 -m pytest -vOutput yang Dihasilkan:
============================= test session starts ==============================
platform linux -- Python 3.12.15, pytest-9.0.2, pluggy-1.6.0
rootdir: /tmp/pengelola-daftar-tugas
collecting ... collected 5 items
test_storage.py::test_simpan_dan_muat_bolak_balik PASSED [ 20%]
test_storage.py::test_muat_berkas_hilang PASSED [ 40%]
test_storage.py::test_muat_berkas_rusak PASSED [ 60%]
test_storage.py::test_muat_melewati_record_tidak_sah PASSED [ 80%]
test_storage.py::test_simpan_daftar_kosong PASSED [100%]
============================== 5 passed in 0.06s ===============================Baris rootdir dan beberapa baris header lain mengikuti lokasi proyek serta plugin yang terpasang di masing-masing mesin — yang penting adalah lima baris PASSED dan ringkasan 5 passed. Sebagai pemeriksaan tambahan, type hints versi final juga lolos pemeriksaan mypy:
python3 -m mypy storage.py main.pyOutput yang Dihasilkan:
Success: no issues found in 2 source filesProyek Pengelola Daftar Tugas kini tuntas: menu interaktif dengan state yang rapi, empat operasi lengkap, penyimpanan JSON yang bertahan antar eksekusi, pemuatan defensif terhadap berbagai berkas bermasalah, dan struktur kode yang teruji. Ketiga berkas tahap akhir ini — main.py, storage.py, dan test_storage.py — tersedia utuh pada bagian Berkas Sumber di bawah halaman. Dari sini ada banyak arah pengembangan alami: tanggal tenggat pada setiap tugas, pencarian berdasarkan judul, atau migrasi penyimpanan ke SQLite — semuanya kini tinggal menulis test untuk perilaku barunya terlebih dahulu.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
main.py
Unduh berkas"""Pengelola Daftar Tugas — program manajemen tugas berbasis terminal."""
import storage
NAMA_BERKAS = "tugas.json"
def tampilkan_menu() -> None:
print("=== Pengelola Daftar Tugas ===")
print("1. Tambah tugas")
print("2. Daftar tugas")
print("3. Tandai selesai")
print("4. Hapus tugas")
print("5. Keluar")
def nomor_id_baru(daftar_tugas: list[dict]) -> int:
if not daftar_tugas:
return 1
return max(tugas["id"] for tugas in daftar_tugas) + 1
def tambah_tugas(daftar_tugas: list[dict], judul: str) -> dict:
tugas_baru = {"id": nomor_id_baru(daftar_tugas), "judul": judul, "selesai": False}
daftar_tugas.append(tugas_baru)
return tugas_baru
def tampilkan_daftar(daftar_tugas: list[dict]) -> None:
if not daftar_tugas:
print("Belum ada tugas.")
return
for tugas in daftar_tugas:
penanda = "[x]" if tugas["selesai"] else "[ ]"
print(f"{penanda} {tugas['id']}. {tugas['judul']}")
def baca_nomor() -> int | None:
try:
return int(input("Nomor tugas: ").strip())
except ValueError:
print("Nomor tugas harus berupa angka.")
return None
def tandai_selesai(daftar_tugas: list[dict], nomor: int) -> bool:
for tugas in daftar_tugas:
if tugas["id"] == nomor:
tugas["selesai"] = True
return True
return False
def hapus_tugas(daftar_tugas: list[dict], nomor: int) -> dict | None:
for indeks, tugas in enumerate(daftar_tugas):
if tugas["id"] == nomor:
return daftar_tugas.pop(indeks)
return None
def main() -> None:
daftar_tugas = storage.muat_tugas(NAMA_BERKAS)
if daftar_tugas:
print(f"{len(daftar_tugas)} tugas dimuat dari {NAMA_BERKAS}.")
while True:
tampilkan_menu()
pilihan = input("Pilihan: ").strip()
if pilihan == "1":
judul = input("Judul tugas: ").strip()
if judul:
tugas_baru = tambah_tugas(daftar_tugas, judul)
print(f"Tugas \"{tugas_baru['judul']}\" tersimpan dengan id {tugas_baru['id']}.")
storage.simpan_tugas(daftar_tugas, NAMA_BERKAS)
else:
print("Judul tugas tidak boleh kosong.")
elif pilihan == "2":
tampilkan_daftar(daftar_tugas)
elif pilihan == "3":
nomor = baca_nomor()
if nomor is not None:
if tandai_selesai(daftar_tugas, nomor):
print(f"Tugas {nomor} ditandai selesai.")
storage.simpan_tugas(daftar_tugas, NAMA_BERKAS)
else:
print(f"Tugas dengan nomor {nomor} tidak ditemukan.")
elif pilihan == "4":
nomor = baca_nomor()
if nomor is not None:
dihapus = hapus_tugas(daftar_tugas, nomor)
if dihapus is not None:
print(f"Tugas \"{dihapus['judul']}\" dihapus.")
storage.simpan_tugas(daftar_tugas, NAMA_BERKAS)
else:
print(f"Tugas dengan nomor {nomor} tidak ditemukan.")
elif pilihan == "5":
print("Sampai jumpa!")
break
else:
print("Pilihan tidak dikenal.")
if __name__ == "__main__":
main()
storage.py
Unduh berkas"""Modul penyimpanan daftar tugas ke berkas JSON."""
import json
import os
def tugas_sah(item: object) -> bool:
"""Periksa apakah satu record memiliki struktur tugas yang benar."""
if not isinstance(item, dict):
return False
id_sah = isinstance(item.get("id"), int) and not isinstance(item.get("id"), bool)
judul_sah = isinstance(item.get("judul"), str)
selesai_sah = isinstance(item.get("selesai"), bool)
return id_sah and judul_sah and selesai_sah
def saring_tugas(data: object) -> list[dict]:
"""Ambil hanya record yang sah dari hasil parsing JSON."""
if not isinstance(data, list):
return []
return [item for item in data if tugas_sah(item)]
def simpan_tugas(daftar_tugas: list[dict], nama_file: str) -> None:
"""Simpan seluruh daftar tugas ke berkas JSON."""
with open(nama_file, "w", encoding="utf-8") as berkas:
json.dump(daftar_tugas, berkas, ensure_ascii=False, indent=2)
def muat_tugas(nama_file: str) -> list[dict]:
"""Muat daftar tugas dari berkas JSON."""
try:
with open(nama_file, "r", encoding="utf-8") as berkas:
data = json.load(berkas)
except FileNotFoundError:
return []
except json.JSONDecodeError:
print(f"Berkas {nama_file} tidak berisi JSON yang valid.")
os.replace(nama_file, nama_file + ".bak")
print(f"Berkas yang rusak dipindah ke {nama_file}.bak.")
print("Program memulai dengan daftar tugas kosong.")
return []
return saring_tugas(data)
test_storage.py
Unduh berkas"""Test untuk modul penyimpanan daftar tugas."""
import json
import storage
def test_simpan_dan_muat_bolak_balik(tmp_path):
berkas = tmp_path / "tugas.json"
daftar = [
{"id": 1, "judul": "Belajar Python dasar", "selesai": False},
{"id": 2, "judul": "Beli susu", "selesai": True},
]
storage.simpan_tugas(daftar, str(berkas))
hasil = storage.muat_tugas(str(berkas))
assert hasil == daftar
def test_muat_berkas_hilang(tmp_path):
berkas = tmp_path / "tidak-ada.json"
hasil = storage.muat_tugas(str(berkas))
assert hasil == []
def test_muat_berkas_rusak(tmp_path, capsys):
berkas = tmp_path / "rusak.json"
berkas.write_text("{ id: 1, judul: tanpa tanda kutip }", encoding="utf-8")
hasil = storage.muat_tugas(str(berkas))
tangkapan = capsys.readouterr()
assert hasil == []
assert not berkas.exists()
assert (tmp_path / "rusak.json.bak").exists()
assert "tidak berisi JSON yang valid" in tangkapan.out
def test_muat_melewati_record_tidak_sah(tmp_path):
berkas = tmp_path / "tugas.json"
berkas.write_text(
json.dumps(
[
{"id": 1, "judul": "Belajar Python dasar", "selesai": False},
{"id": "dua", "judul": "Id bertipe string", "selesai": False},
{"id": True, "judul": "Id bertipe boolean", "selesai": False},
{"id": 3, "judul": 42, "selesai": False},
{"id": 4, "judul": "Tanpa kunci selesai"},
"bukan dictionary",
]
),
encoding="utf-8",
)
hasil = storage.muat_tugas(str(berkas))
assert hasil == [{"id": 1, "judul": "Belajar Python dasar", "selesai": False}]
def test_simpan_daftar_kosong(tmp_path):
berkas = tmp_path / "kosong.json"
storage.simpan_tugas([], str(berkas))
assert json.loads(berkas.read_text(encoding="utf-8")) == []