Selama empat bagian, setiap perubahan dibuktikan dengan skrip cek_api.py yang dijalankan ulang manual — kebiasaan yang mustahil dipertahankan saat API membesar. Bagian ini mengubah seluruh pembuktian itu menjadi test otomatis dengan pytest: satu perintah memeriksa kembali seluruh kontrak API dalam hitungan detik. Bahannya sama dengan chapter API Testing — TestClient, fixture dari chapter Fixtures — ditambah satu masalah baru yang khas proyek berdatabase: test tidak boleh saling meninggalkan data.
Struktur proyek bertambah direktori tests/; seluruh isi app/ dibawa dari Bagian 4 tanpa perubahan:
api-manajemen-tugas/
├── pyproject.toml
├── app/
│ ├── __init__.py
│ ├── database.py
│ ├── main.py
│ └── models.py
└── tests/
└── test_api.pyFixture: Client dan Database Terisolasi #
Buat berkas tests/test_api.py:
import pytest
from fastapi.testclient import TestClient
from app import database
from app.main import app
@pytest.fixture()
def client(tmp_path, monkeypatch):
monkeypatch.setattr(database, "DB_PATH", str(tmp_path / "uji.db"))
return TestClient(app)
def test_buat_lalu_ambil(client):
respons = client.post("/tugas", json={"judul": "Belajar Python"})
assert respons.status_code == 201
hasil = respons.json()
assert hasil == {"id": 1, "judul": "Belajar Python", "selesai": False}
respons = client.get(f"/tugas/{hasil['id']}")
assert respons.status_code == 200
assert respons.json() == hasil
def test_ambil_tugas_tidak_ada(client):
respons = client.get("/tugas/99")
assert respons.status_code == 404
assert "tidak ditemukan" in respons.json()["detail"]
def test_judul_kosong_ditolak(client):
respons = client.post("/tugas", json={"judul": ""})
assert respons.status_code == 422
assert respons.json()["detail"][0]["loc"] == ["body", "judul"]
def test_perbarui_sebagian_field(client):
client.post("/tugas", json={"judul": "Belajar Docker"})
respons = client.patch("/tugas/1", json={"selesai": True})
assert respons.status_code == 200
hasil = respons.json()
assert hasil["judul"] == "Belajar Docker"
assert hasil["selesai"] is True
def test_hapus_lalu_404(client):
client.post("/tugas", json={"judul": "Tugas sementara"})
respons = client.delete("/tugas/1")
assert respons.status_code == 204
respons = client.get("/tugas/1")
assert respons.status_code == 404
def test_daftar_tugas(client):
assert client.get("/tugas").json() == []
client.post("/tugas", json={"judul": "Tugas pertama"})
client.post("/tugas", json={"judul": "Tugas kedua"})
hasil = client.get("/tugas").json()
assert len(hasil) == 2
assert [tugas["judul"] for tugas in hasil] == ["Tugas pertama", "Tugas kedua"]
def test_alur_lengkap(client):
respons = client.post("/tugas", json={"judul": "Belajar testing"})
assert respons.status_code == 201
id_tugas = respons.json()["id"]
respons = client.patch(f"/tugas/{id_tugas}", json={"selesai": True})
assert respons.status_code == 200
assert respons.json()["selesai"] is True
respons = client.delete(f"/tugas/{id_tugas}")
assert respons.status_code == 204
respons = client.get(f"/tugas/{id_tugas}")
assert respons.status_code == 404Tiga baris fixture adalah jantung berkas ini:
tmp_path: fixture bawaan pytest yang menyediakan direktori sementara unik untuk setiap test — terpisah untuktest_buat_lalu_ambil,test_hapus_lalu_404, dan seterusnya.monkeypatch.setattr(database, "DB_PATH", str(tmp_path / "uji.db")): mengganti konstantaDB_PATHdi moduleapp.databasedengan berkas database di direktori sementara itu. Ingat Bagian 3: dependencydapatkan_koneksi()membacaDB_PATHsetiap kali dipanggil, jadi penggantian ini berlaku untuk seluruh request dari test yang bersangkutan.return TestClient(app): satu instance client per test, sehingga tiap test memulai hidup dari database kosong yang segar.
Tanpa dua baris pertama, semua test akan berbagi tugas.db di direktori kerja — tugas dari test lama menumpuk, id melompat tak terduga, dan hasil test bergantung pada urutan jalannya. Dengan isolasi, test_daftar_tugas berani menulis assert client.get("/tugas").json() == [] sebagai kalimat pertamanya: klaim bahwa setiap test dimulai dari nol.
Menjalankan Test #
Dari root proyek, jalankan pytest lewat module Python:
python -m pytest -vOutput yang Dihasilkan:
============================= test session starts ==============================
platform linux -- Python 3.12.15, pytest-9.0.2, pluggy-1.6.0 -- /home/z/.venv/bin/python3
... (baris metadata sesi dipangkas)
collected 7 items
tests/test_api.py::test_buat_lalu_ambil PASSED [ 14%]
tests/test_api.py::test_ambil_tugas_tidak_ada PASSED [ 28%]
tests/test_api.py::test_judul_kosong_ditolak PASSED [ 42%]
tests/test_api.py::test_perbarui_sebagian_field PASSED [ 57%]
tests/test_api.py::test_hapus_lalu_404 PASSED [ 71%]
tests/test_api.py::test_daftar_tugas PASSED [ 85%]
tests/test_api.py::test_alur_lengkap PASSED [100%]
============================== 7 passed in 0.93s ===============================Kenapa python -m pytest, bukan pytest saja? Mode -m menambahkan direktori kerja ke sys.path, sehingga import app.main berhasil. Perintah pytest polos tidak melakukan ini dan gagal dengan ModuleNotFoundError — jebakan klasik yang dibahas di chapter Menulis Test dengan pytest. Di Bagian 6, konfigurasi pythonpath di pyproject.toml membuat kedua cara sama-sama jalan.
Apa yang Diuji #
Setiap test menjahit satu jahitan pada kontrak API:
| Test | Perilaku kontrak yang dijaga |
|---|---|
test_buat_lalu_ambil | POST mengembalikan 201 dengan bentuk tepat, dan hasil GET per id identik |
test_ambil_tugas_tidak_ada | Id yang tidak ada dijawab 404 dengan pesan yang bisa dicari |
test_judul_kosong_ditolak | Judul kosong ditolak 422 dan laporannya menunjuk body.judul |
test_perbarui_sebagian_field | PATCH mengubah satu field tanpa menyentuh field lain |
test_hapus_lalu_404 | DELETE menjawab 204, lalu tugas itu benar-benar hilang |
test_daftar_tugas | List mulai kosong dan mengembalikan tugas terurut |
test_alur_lengkap | Siklus hidup utuh: buat, tandai selesai, hapus, pastikan hilang |
Cakupan ini adalah level integration: test menembus routing, validasi Pydantic, dependency, SQL, sampai konversi bool — semua lapisan yang kita bangun sejak Bagian 1. Berkas lengkapnya tersedia pada bagian Berkas Sumber di bawah halaman. Dengan jaring pengaman ini terpasang, Bagian 6 berani menyentuh konfigurasi dan deployment tanpa takut diam-diam merusak API.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
tests/test_api.py
Unduh berkasimport pytest
from fastapi.testclient import TestClient
from app import database
from app.main import app
@pytest.fixture()
def client(tmp_path, monkeypatch):
monkeypatch.setattr(database, "DB_PATH", str(tmp_path / "uji.db"))
return TestClient(app)
def test_buat_lalu_ambil(client):
respons = client.post("/tugas", json={"judul": "Belajar Python"})
assert respons.status_code == 201
hasil = respons.json()
assert hasil == {"id": 1, "judul": "Belajar Python", "selesai": False}
respons = client.get(f"/tugas/{hasil['id']}")
assert respons.status_code == 200
assert respons.json() == hasil
def test_ambil_tugas_tidak_ada(client):
respons = client.get("/tugas/99")
assert respons.status_code == 404
assert "tidak ditemukan" in respons.json()["detail"]
def test_judul_kosong_ditolak(client):
respons = client.post("/tugas", json={"judul": ""})
assert respons.status_code == 422
assert respons.json()["detail"][0]["loc"] == ["body", "judul"]
def test_perbarui_sebagian_field(client):
client.post("/tugas", json={"judul": "Belajar Docker"})
respons = client.patch("/tugas/1", json={"selesai": True})
assert respons.status_code == 200
hasil = respons.json()
assert hasil["judul"] == "Belajar Docker"
assert hasil["selesai"] is True
def test_hapus_lalu_404(client):
client.post("/tugas", json={"judul": "Tugas sementara"})
respons = client.delete("/tugas/1")
assert respons.status_code == 204
respons = client.get("/tugas/1")
assert respons.status_code == 404
def test_daftar_tugas(client):
assert client.get("/tugas").json() == []
client.post("/tugas", json={"judul": "Tugas pertama"})
client.post("/tugas", json={"judul": "Tugas kedua"})
hasil = client.get("/tugas").json()
assert len(hasil) == 2
assert [tugas["judul"] for tugas in hasil] == ["Tugas pertama", "Tugas kedua"]
def test_alur_lengkap(client):
respons = client.post("/tugas", json={"judul": "Belajar testing"})
assert respons.status_code == 201
id_tugas = respons.json()["id"]
respons = client.patch(f"/tugas/{id_tugas}", json={"selesai": True})
assert respons.status_code == 200
assert respons.json()["selesai"] is True
respons = client.delete(f"/tugas/{id_tugas}")
assert respons.status_code == 204
respons = client.get(f"/tugas/{id_tugas}")
assert respons.status_code == 404