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:

text
api-manajemen-tugas/
├── pyproject.toml
├── app/
│   ├── __init__.py
│   ├── database.py
│   ├── main.py
│   └── models.py
└── tests/
    └── test_api.py

Fixture: Client dan Database Terisolasi #

Buat berkas tests/test_api.py:

python
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 == 404

Tiga baris fixture adalah jantung berkas ini:

  1. tmp_path: fixture bawaan pytest yang menyediakan direktori sementara unik untuk setiap test — terpisah untuk test_buat_lalu_ambil, test_hapus_lalu_404, dan seterusnya.
  2. monkeypatch.setattr(database, "DB_PATH", str(tmp_path / "uji.db")): mengganti konstanta DB_PATH di module app.database dengan berkas database di direktori sementara itu. Ingat Bagian 3: dependency dapatkan_koneksi() membaca DB_PATH setiap kali dipanggil, jadi penggantian ini berlaku untuk seluruh request dari test yang bersangkutan.
  3. 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:

bash
python -m pytest -v

Output yang Dihasilkan:

text
============================= 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:

TestPerilaku kontrak yang dijaga
test_buat_lalu_ambilPOST mengembalikan 201 dengan bentuk tepat, dan hasil GET per id identik
test_ambil_tugas_tidak_adaId yang tidak ada dijawab 404 dengan pesan yang bisa dicari
test_judul_kosong_ditolakJudul kosong ditolak 422 dan laporannya menunjuk body.judul
test_perbarui_sebagian_fieldPATCH mengubah satu field tanpa menyentuh field lain
test_hapus_lalu_404DELETE menjawab 204, lalu tugas itu benar-benar hilang
test_daftar_tugasList mulai kosong dan mengembalikan tugas terurut
test_alur_lengkapSiklus 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 berkas
python
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 == 404