PyTest #
PyTest adalah framework testing Python yang paling banyak dipakai saat ini — lebih ekspresif dari unittest bawaan, dengan auto-discovery yang cerdas, fixture system yang powerful, dan ekosistem plugin yang luas. Kekuatan PyTest bukan hanya di kemudahan menulisnya (assert biasa, bukan assertEqual), tapi di cara fixture-nya bekerja: dependency injection yang otomatis, scope yang dikontrol, dan teardown yang dijamin berjalan meski test gagal. Memahami fixture scope, conftest.py, dan cara menulis test yang terisolasi adalah fondasi test suite yang cepat dan andal.
Instalasi dan Konfigurasi #
pip install pytest pytest-cov pytest-mock
Konfigurasi pytest di pyproject.toml (cara modern):
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"] # direktori pencarian test
python_files = ["test_*.py"] # pola nama file test
python_classes = ["Test*"] # pola nama kelas test
python_functions = ["test_*"] # pola nama fungsi test
addopts = "-v --tb=short" # opsi default saat menjalankan pytest
markers = [
"slow: test yang memerlukan waktu lama",
"integration: test yang membutuhkan layanan eksternal",
"unit: test unit tanpa dependency eksternal",
]
[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/migrations/*"]
[tool.coverage.report]
show_missing = true
fail_under = 80 # gagal jika coverage di bawah 80%
Struktur direktori yang direkomendasikan:
myproject/
├── src/
│ └── myapp/
│ ├── __init__.py
│ ├── kalkulasi.py
│ └── layanan.py
├── tests/
│ ├── conftest.py # fixture global
│ ├── unit/
│ │ ├── test_kalkulasi.py
│ │ └── test_layanan.py
│ └── integration/
│ └── test_api.py
└── pyproject.toml
Test Dasar #
Test di pytest adalah fungsi biasa yang dimulai dengan test_. Gunakan assert biasa — pytest menampilkan nilai aktual vs expected secara informatif saat gagal.
# tests/unit/test_kalkulasi.py
from myapp.kalkulasi import hitung_diskon, hitung_pajak, format_harga
def test_hitung_diskon_normal():
assert hitung_diskon(100_000, 10) == 90_000
def test_hitung_diskon_nol_persen():
assert hitung_diskon(100_000, 0) == 100_000
def test_hitung_diskon_seratus_persen():
assert hitung_diskon(100_000, 100) == 0
def test_format_harga():
assert format_harga(18_500_000) == "Rp18.500.000"
# Pengujian exception
def test_hitung_diskon_persen_negatif():
with pytest.raises(ValueError, match="Diskon tidak boleh negatif"):
hitung_diskon(100_000, -5)
def test_hitung_pajak_persen_di_atas_100():
with pytest.raises(ValueError):
hitung_pajak(100_000, 150)
# Pengujian tipe dan nilai lebih dari satu field
def test_hasil_lengkap():
hasil = hitung_diskon(100_000, 20)
assert isinstance(hasil, (int, float))
assert hasil == 80_000
assert hasil >= 0
Fixtures #
Fixture adalah fungsi yang menyiapkan state yang dibutuhkan test, kemudian membersihkannya setelah test selesai. Pytest menginjeksikan fixture ke test secara otomatis berdasarkan nama parameter.
import pytest
from myapp.models import Pengguna, Produk
@pytest.fixture
def pengguna_aktif():
"""Fixture sederhana — kembalikan objek yang sudah terkonfigurasi."""
return Pengguna(
nama="Budi Santoso",
email="[email protected]",
aktif=True
)
@pytest.fixture
def produk_sample():
return Produk(nama="Laptop Gaming", harga=18_500_000, stok=5)
# Test menerima fixture sebagai parameter
def test_pengguna_aktif(pengguna_aktif):
assert pengguna_aktif.aktif is True
assert pengguna_aktif.email == "[email protected]"
def test_produk_sample(produk_sample):
assert produk_sample.stok == 5
assert produk_sample.harga > 0
# Test bisa menerima beberapa fixture sekaligus
def test_pengguna_bisa_beli_produk(pengguna_aktif, produk_sample):
assert pengguna_aktif.aktif
assert produk_sample.stok > 0
Fixture dengan Setup dan Teardown #
import pytest
@pytest.fixture
def koneksi_db():
"""Fixture dengan setup dan teardown — kode setelah yield adalah teardown."""
# Setup
db = buat_koneksi_test()
db.begin()
print("\nKoneksi database dibuat")
yield db # nilai yang diterima test
# Teardown -- selalu dijalankan meski test gagal
db.rollback()
db.close()
print("\nKoneksi database ditutup")
def test_tambah_data(koneksi_db):
# koneksi_db sudah siap, akan di-rollback setelah test
koneksi_db.execute("INSERT INTO produk (nama) VALUES ('Test')")
hasil = koneksi_db.execute("SELECT COUNT(*) FROM produk").fetchone()
assert hasil[0] == 1
Scope Fixture #
Scope mengontrol berapa kali fixture dibuat — function (default) setiap test, module sekali per file, session sekali sepanjang test suite.
import pytest
@pytest.fixture(scope="function") # default — buat ulang setiap test
def produk_baru():
return {"nama": "Produk Test", "stok": 10}
@pytest.fixture(scope="module") # buat sekali per file test
def koneksi_database():
"""Koneksi DB mahal — buat sekali per module, bukan per test."""
conn = buat_koneksi()
yield conn
conn.close()
@pytest.fixture(scope="session") # buat sekali untuk seluruh test suite
def konfigurasi_app():
"""Load konfigurasi aplikasi — tidak berubah selama test suite berjalan."""
return {"env": "testing", "debug": True, "db_url": "sqlite:///:memory:"}
# Gunakan scope yang tepat:
# function -- untuk objek yang harus bersih setiap test (model, dict)
# module -- untuk koneksi yang mahal tapi bisa di-share dalam satu file
# session -- untuk resource yang sangat mahal (server, konfigurasi global)
conftest.py — Fixture Bersama #
conftest.py adalah file khusus pytest untuk mendefinisikan fixture yang bisa dipakai oleh semua test dalam direktori yang sama dan subdirektorinya — tanpa perlu import eksplisit.
# tests/conftest.py
import pytest
from myapp import create_app
from myapp.database import db as _db
@pytest.fixture(scope="session")
def app():
"""Buat instance aplikasi untuk testing — satu kali per session."""
app = create_app({"TESTING": True, "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:"})
with app.app_context():
_db.create_all()
yield app
_db.drop_all()
@pytest.fixture(scope="module")
def client(app):
"""HTTP test client."""
return app.test_client()
@pytest.fixture(autouse=True)
def bersihkan_db(app):
"""
Rollback setelah setiap test -- autouse=True artinya aktif otomatis
tanpa perlu disebut di parameter fungsi test.
"""
with app.app_context():
yield
_db.session.rollback()
@pytest.fixture
def pengguna_tersimpan(app):
"""Buat pengguna di database dan kembalikan objeknya."""
from myapp.models import Pengguna
with app.app_context():
p = Pengguna(nama="Test User", email="[email protected]")
p.set_password("password123")
_db.session.add(p)
_db.session.commit()
yield p
# cleanup: otomatis via bersihkan_db
Parametrize — Satu Test, Banyak Skenario #
import pytest
from myapp.kalkulasi import hitung_diskon, validasi_email
# Parametrize sederhana
@pytest.mark.parametrize("harga,diskon,expected", [
(100_000, 10, 90_000),
(100_000, 0, 100_000),
(100_000, 100, 0),
(500_000, 25, 375_000),
(1_000, 50, 500),
])
def test_hitung_diskon_berbagai_skenario(harga, diskon, expected):
assert hitung_diskon(harga, diskon) == expected
# Parametrize dengan ID kustom (lebih mudah dibaca di output)
@pytest.mark.parametrize("email,valid", [
pytest.param("[email protected]", True, id="email_valid"),
pytest.param("budi@", False, id="domain_hilang"),
pytest.param("@example.com", False, id="username_hilang"),
pytest.param("budi@example", False, id="tld_hilang"),
pytest.param("", False, id="email_kosong"),
])
def test_validasi_email(email, valid):
assert validasi_email(email) == valid
# Parametrize dengan exception yang diharapkan
@pytest.mark.parametrize("harga,diskon,exc", [
(-100, 10, ValueError), # harga negatif
(100, -5, ValueError), # diskon negatif
(100, 110, ValueError), # diskon lebih dari 100
])
def test_hitung_diskon_input_tidak_valid(harga, diskon, exc):
with pytest.raises(exc):
hitung_diskon(harga, diskon)
Monkeypatch — Override Sementara #
monkeypatch adalah fixture bawaan pytest untuk mengganti sementara fungsi, metode, atribut, atau environment variable selama test berlangsung.
import pytest
from myapp import layanan
def test_kirim_email_berhasil(monkeypatch):
"""Test kirim_email tanpa benar-benar mengirim email."""
email_terkirim = []
def mock_smtp_send(to, subject, body):
email_terkirim.append({"to": to, "subject": subject})
return True
# Ganti fungsi smtp_send dengan mock
monkeypatch.setattr(layanan, "smtp_send", mock_smtp_send)
hasil = layanan.kirim_email_konfirmasi("[email protected]", "Order #101")
assert hasil is True
assert len(email_terkirim) == 1
assert email_terkirim[0]["to"] == "[email protected]"
def test_baca_konfigurasi_dari_env(monkeypatch):
"""Test dengan environment variable yang dikontrol."""
monkeypatch.setenv("DATABASE_URL", "postgresql://test:test@localhost/testdb")
monkeypatch.setenv("DEBUG", "false")
config = layanan.baca_konfigurasi()
assert config["db_url"] == "postgresql://test:test@localhost/testdb"
assert config["debug"] is False
def test_tanpa_env_database(monkeypatch):
"""Test saat DATABASE_URL tidak di-set."""
monkeypatch.delenv("DATABASE_URL", raising=False)
with pytest.raises(ValueError, match="DATABASE_URL harus di-set"):
layanan.baca_konfigurasi()
def test_tulis_file(monkeypatch, tmp_path):
"""tmp_path adalah fixture bawaan untuk direktori temporary."""
file_path = tmp_path / "output.txt"
monkeypatch.setattr(layanan, "OUTPUT_DIR", str(tmp_path))
layanan.simpan_laporan({"total": 100})
assert file_path.exists()
assert "total" in file_path.read_text()
Mocking dengan pytest-mock #
pytest-mock menyediakan fixture mocker yang membungkus unittest.mock dengan API yang lebih bersih.
import pytest
from myapp import layanan_order
def test_proses_order_berhasil(mocker):
"""Test proses_order dengan semua dependency di-mock."""
# Mock database query
mock_produk = mocker.MagicMock()
mock_produk.stok = 10
mock_produk.harga = 500_000
mocker.patch("myapp.layanan_order.Produk.query.get", return_value=mock_produk)
# Mock pembayaran
mock_bayar = mocker.patch(
"myapp.layanan_order.proses_pembayaran",
return_value={"status": "success", "transaction_id": "TXN-001"}
)
# Mock kirim email (fire and forget)
mock_email = mocker.patch("myapp.layanan_order.kirim_email_konfirmasi")
# Jalankan fungsi yang ditest
hasil = layanan_order.buat_order(produk_id=1, jumlah=2, user_id=42)
# Assert hasil
assert hasil["status"] == "success"
assert hasil["total"] == 1_000_000
# Verifikasi bahwa fungsi yang di-mock dipanggil dengan benar
mock_bayar.assert_called_once_with(jumlah=1_000_000, user_id=42)
mock_email.assert_called_once()
def test_proses_order_stok_habis(mocker):
mock_produk = mocker.MagicMock()
mock_produk.stok = 0
mocker.patch("myapp.layanan_order.Produk.query.get", return_value=mock_produk)
with pytest.raises(ValueError, match="Stok tidak mencukupi"):
layanan_order.buat_order(produk_id=1, jumlah=1, user_id=42)
def test_proses_order_produk_tidak_ada(mocker):
mocker.patch("myapp.layanan_order.Produk.query.get", return_value=None)
with pytest.raises(ValueError, match="Produk tidak ditemukan"):
layanan_order.buat_order(produk_id=999, jumlah=1, user_id=42)
Markers — Kategorisasi Test #
import pytest
@pytest.mark.unit
def test_kalkulasi_sederhana():
assert 1 + 1 == 2
@pytest.mark.slow
def test_proses_data_besar():
# test yang butuh waktu lama
import time
time.sleep(2)
assert True
@pytest.mark.integration
def test_koneksi_database_nyata():
# test yang butuh database sungguhan
pass
@pytest.mark.skip(reason="Fitur belum diimplementasikan")
def test_fitur_baru():
pass
@pytest.mark.skipif(
condition=not os.getenv("CI"),
reason="Hanya dijalankan di CI pipeline"
)
def test_hanya_di_ci():
pass
@pytest.mark.xfail(reason="Bug yang diketahui, menunggu fix")
def test_yang_diketahui_gagal():
assert False
# Jalankan hanya test unit
pytest -m unit
# Jalankan semua kecuali slow
pytest -m "not slow"
# Jalankan unit dan integration
pytest -m "unit or integration"
Menjalankan PyTest #
# Jalankan semua test
pytest
# Verbose -- tampilkan nama setiap test
pytest -v
# Hentikan pada kegagalan pertama
pytest -x
# Hentikan setelah 3 kegagalan
pytest --maxfail=3
# Jalankan test tertentu saja
pytest tests/unit/test_kalkulasi.py
pytest tests/unit/test_kalkulasi.py::test_hitung_diskon_normal
# Jalankan ulang test yang gagal terakhir kali
pytest --lf
# Tampilkan 10 test paling lambat
pytest --durations=10
# Parallel execution (butuh pytest-xdist)
pytest -n 4 # 4 worker paralel
pytest -n auto # deteksi otomatis jumlah CPU
# Coverage report
pytest --cov=myapp --cov-report=term-missing
pytest --cov=myapp --cov-report=html # buat laporan HTML di htmlcov/
Anti-Pattern dalam Testing #
# ANTI-PATTERN: test bergantung pada urutan eksekusi
counter = 0
def test_increment():
global counter
counter += 1
assert counter == 1 # ✗ -- gagal jika test lain berjalan lebih dulu
def test_counter_final():
assert counter == 5 # ✗ -- bergantung pada test lain
# BENAR: setiap test berdiri sendiri
def test_increment():
counter = 0
counter += 1
assert counter == 1 # ✓ -- independen
# ANTI-PATTERN: test menyentuh resource eksternal tanpa mock
def test_kirim_email():
hasil = kirim_email("[email protected]", "Test") # ✗ -- kirim email sungguhan!
assert hasil
# BENAR: mock semua I/O eksternal
def test_kirim_email(mocker):
mock = mocker.patch("myapp.smtp.send") # ✓
kirim_email("[email protected]", "Test")
mock.assert_called_once()
# ANTI-PATTERN: satu test menguji terlalu banyak hal
def test_segalanya():
pengguna = buat_pengguna("Budi", "[email protected]")
assert pengguna.id is not None
produk = buat_produk("Laptop", 1000)
order = buat_order(pengguna.id, produk.id)
bayar = proses_pembayaran(order.id)
notif = kirim_notifikasi(order.id)
assert bayar.status == "success"
assert notif.terkirim # ✗ -- sulit tahu mana yang gagal
# BENAR: satu test, satu behavior
def test_buat_pengguna_berhasil():
pengguna = buat_pengguna("Budi", "[email protected]")
assert pengguna.id is not None # ✓
def test_buat_order_valid():
order = buat_order(pengguna_id=1, produk_id=1)
assert order.status == "pending" # ✓
Ringkasan #
assertbiasa, bukanassertEqual— pytest menampilkan nilai aktual vs expected secara detail saat assert gagal; tidak perlu metode assert khusus.- Fixture untuk setup/teardown — gunakan
yielddalam fixture untuk pemisahan setup (sebelum yield) dan teardown (setelah yield) yang bersih; teardown dijamin berjalan meski test gagal.- Scope fixture yang tepat —
function(default) untuk objek yang harus bersih tiap test;moduleuntuk koneksi mahal per file;sessionuntuk resource global yang tidak berubah.conftest.pyuntuk fixture bersama — fixture diconftest.pyotomatis tersedia untuk semua test di direktori yang sama tanpa perlu import; buat satu di roottests/untuk fixture global.autouse=True— gunakan untuk fixture yang harus aktif di semua test tanpa disebut secara eksplisit, seperti cleanup database atau reset state.parametrizeuntuk mengurangi duplikasi — jalankan satu fungsi test dengan banyak kombinasi input/expected; gunakanpytest.param(..., id=...)untuk ID yang mudah dibaca di output.monkeypatchuntuk dependency eksternal — ganti fungsi, atribut, atau env var sementara selama test; otomatis di-restore setelah test selesai.mocker.patchuntuk mock yang kompleks — gunakanpytest-mockuntuk mock dengan verifikasi panggilan (assert_called_once_with), return value kustom, dan side effects.- Setiap test harus independen — test tidak boleh bergantung pada urutan eksekusi atau state yang ditinggalkan test lain; gunakan fixture untuk setup yang bersih setiap saat.
- Coverage 80%+ sebagai baseline — konfigurasi
fail_under = 80dipyproject.toml; coverage tinggi bukan jaminan kualitas, tapi coverage rendah adalah tanda area yang tidak tertest.