Setelah memahami konsep dasar dan anatomi test yang baik, saatnya praktik. Kita mulai dari yang paling dasar di piramida testing: unit test — menguji fungsi-fungsi murni di task-tracker-api, dimulai dari app/security.py (hashing password dan JWT dari seri Security).
Kenapa Mulai dari Sini?
app/security.py berisi fungsi-fungsi yang IDEAL untuk unit test pertama: tidak butuh database, tidak butuh HTTP request, cuma menerima input dan mengembalikan output.
def hash_password(plain_password: str) -> str: ...
def verify_password(plain_password: str, hashed_password: str) -> bool: ...
def create_access_token(subject: str) -> str: ...
def decode_access_token(token: str) -> Optional[str]: ...
Fungsi seperti ini disebut pure function (atau mendekati murni) — hasilnya cuma bergantung pada input yang diberikan, tidak menyentuh state di luar dirinya (kecuali secret_key dari config, yang tetap konsisten). Ini yang membuatnya gampang diuji: tidak perlu "menyiapkan dunia" yang rumit sebelum memanggilnya.
Instalasi dan Struktur Dasar Pytest
pip install pytest
Pytest punya konvensi penamaan yang otomatis dikenali (disebut test discovery), tanpa perlu registrasi manual:
test_*.py atau *_test.py.test_.tests/ (atau folder mana pun sesuai konfigurasi).Test Pertama
# tests/unit/test_security.py
from app.security import hash_password, verify_password
def test_hash_password_is_not_plaintext():
assert hash_password("supersecret123") != "supersecret123"
def test_verify_password_accepts_correct_password():
hashed = hash_password("supersecret123")
assert verify_password("supersecret123", hashed) is True
def test_verify_password_rejects_wrong_password():
hashed = hash_password("supersecret123")
assert verify_password("passwordsalah", hashed) is False
Perhatikan: pytest memakai assert BIASA dari Python — bukan method khusus seperti self.assertEqual(...) di framework testing lain. Ini salah satu alasan pytest populer: sintaksnya sesederhana mungkin. Di balik layar, pytest melakukan assertion rewriting — kalau assert gagal, pytest otomatis menampilkan detail nilai kiri-kanan yang dibandingkan, tanpa kita perlu menulis pesan error manual.
Jalankan:
pytest tests/unit/test_security.py -v
tests/unit/test_security.py::test_hash_password_is_not_plaintext PASSED
tests/unit/test_security.py::test_verify_password_accepts_correct_password PASSED
tests/unit/test_security.py::test_verify_password_rejects_wrong_password PASSED
Menguji Kasus Negatif — Sama Pentingnya dengan Kasus Positif
Test yang cuma menguji "kalau semuanya benar" itu setengah pekerjaan. Sama pentingnya: menguji apa yang terjadi kalau input SALAH atau data DIRUSAK:
def test_decode_access_token_rejects_garbage_token():
assert decode_access_token("bukan-token-yang-valid") is None
def test_decode_access_token_rejects_tampered_token():
token = create_access_token(subject="alice")
header, payload, signature = token.split(".")
# Membalik signature memastikan hasilnya PASTI berubah — lihat catatan
# di bawah kenapa ini penting.
tampered = f"{header}.{payload}.{signature[::-1]}"
assert decode_access_token(tampered) is None
Kisah nyata dari balik layar seri ini: versi awal test kedua di atas menukar cuma SATU karakter di ujung token, bukan membalik seluruh signature-nya. Test itu ternyata flaky — kadang lolos, kadang gagal, tanpa perubahan kode sama sekali. Penyebabnya: base64 (format yang dipakai JWT) punya redundansi bit di posisi tertentu, jadi menukar satu karakter di posisi yang "kurang beruntung" kadang tidak benar-benar mengubah hasil decode-nya. Ini pelajaran nyata soal prinsip Repeatable dari artikel sebelumnya — begitu menemukan test yang hasilnya tidak konsisten, curigai ASUMSI di balik test-nya, bukan cuma kode yang diuji.
Menandai Test dengan Marker
Supaya nanti bisa memilah test cepat (unit) dari test lambat (integration), kita tandai dengan marker:
import pytest
@pytest.mark.unit
def test_hash_password_is_not_plaintext():
...
Marker didaftarkan di pytest.ini supaya pytest tidak memperingatkan "marker tidak dikenal":
[pytest]
markers =
unit: test unit murni, tanpa I/O apa pun (database, network, filesystem)
integration: test integrasi, lewat HTTP client dan database test
Sekarang kita bisa menjalankan cuma unit test:
pytest -m unit -v
tests/unit/test_security.py::test_hash_password_returns_different_hash_each_time PASSED
tests/unit/test_security.py::test_hash_password_is_not_plaintext PASSED
tests/unit/test_security.py::test_verify_password_accepts_correct_password PASSED
tests/unit/test_security.py::test_verify_password_rejects_wrong_password PASSED
tests/unit/test_security.py::test_create_and_decode_access_token_roundtrip PASSED
tests/unit/test_security.py::test_decode_access_token_rejects_garbage_token PASSED
tests/unit/test_security.py::test_decode_access_token_rejects_tampered_token PASSED
======================= 7 passed in 3.37s =======================
Tujuh test ini nantinya akan berdampingan dengan puluhan integration test (dibahas mulai artikel berikutnya) — dan begitu keduanya ada, kamu akan langsung merasakan bedanya: pytest -m unit selesai dalam hitungan detik, cocok dijalankan berulang kali SETIAP menyimpan perubahan kode, sementara test yang menyentuh database perlu waktu lebih lama.
Rangkuman
test_*.py dan fungsi test_*, tanpa registrasi manual, dan memakai assert biasa.@pytest.mark.unit) memudahkan memilah test cepat dari test lambat, didaftarkan di pytest.ini.