PyTest #
PyTest is the most widely used Python testing framework today — more expressive than the built-in unittest, with smart auto-discovery, a powerful fixture system, and a broad plugin ecosystem. PyTest’s strength isn’t just the ease of writing tests (plain assert, not assertEqual), but how its fixtures work: automatic dependency injection, controlled scope, and guaranteed teardown even when tests fail. Understanding fixture scopes, conftest.py, and how to write isolated tests is the foundation of a fast and reliable test suite.
Installation and Configuration #
pip install pytest pytest-cov pytest-mock
Configure pytest in pyproject.toml (the modern way):
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"] # test search directory
python_files = ["test_*.py"] # test file name patterns
python_classes = ["Test*"] # test class name patterns
python_functions = ["test_*"] # test function name patterns
addopts = "-v --tb=short" # default options when running pytest
markers = [
"slow: tests that take a long time",
"integration: tests requiring external services",
"unit: unit tests without external dependencies",
]
[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/migrations/*"]
[tool.coverage.report]
show_missing = true
fail_under = 80 # fail if coverage is below 80%
Recommended directory structure:
myproject/
├── src/
│ └── myapp/
│ ├── __init__.py
│ ├── kalkulasi.py
│ └── layanan.py
├── tests/
│ ├── conftest.py # global fixtures
│ ├── unit/
│ │ ├── test_kalkulasi.py
│ │ └── test_layanan.py
│ └── integration/
│ └── test_api.py
└── pyproject.toml
Basic Tests #
A pytest test is a regular function starting with test_. Use plain assert — pytest displays actual vs expected values informatively when it fails.
# 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"
# Testing exceptions
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)
# Testing type and multiple field values
def test_hasil_lengkap():
hasil = hitung_diskon(100_000, 20)
assert isinstance(hasil, (int, float))
assert hasil == 80_000
assert hasil >= 0
Fixtures #
A fixture is a function that prepares the state a test needs, then cleans it up after the test finishes. Pytest injects fixtures into tests automatically based on parameter names.
import pytest
from myapp.models import Pengguna, Produk
@pytest.fixture
def pengguna_aktif():
"""Simple fixture -- returns a pre-configured object."""
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)
# Tests receive fixtures as parameters
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
# A test can receive several fixtures at once
def test_pengguna_bisa_beli_produk(pengguna_aktif, produk_sample):
assert pengguna_aktif.aktif
assert produk_sample.stok > 0
Fixtures with Setup and Teardown #
import pytest
@pytest.fixture
def koneksi_db():
"""Fixture with setup and teardown -- code after yield is teardown."""
# Setup
db = buat_koneksi_test()
db.begin()
print("\nDatabase connection created")
yield db # the value the test receives
# Teardown -- always runs even if the test fails
db.rollback()
db.close()
print("\nDatabase connection closed")
def test_tambah_data(koneksi_db):
# koneksi_db is ready, will be rolled back after the test
koneksi_db.execute("INSERT INTO produk (nama) VALUES ('Test')")
hasil = koneksi_db.execute("SELECT COUNT(*) FROM produk").fetchone()
assert hasil[0] == 1
Fixture Scopes #
Scope controls how often a fixture is created — function (default) per test, module once per file, session once across the whole test suite.
import pytest
@pytest.fixture(scope="function") # default -- recreate for every test
def produk_baru():
return {"nama": "Produk Test", "stok": 10}
@pytest.fixture(scope="module") # create once per test file
def koneksi_database():
"""DB connections are expensive -- create once per module, not per test."""
conn = buat_koneksi()
yield conn
conn.close()
@pytest.fixture(scope="session") # create once for the whole test suite
def konfigurasi_app():
"""Load the app configuration -- unchanged during the test suite run."""
return {"env": "testing", "debug": True, "db_url": "sqlite:///:memory:"}
# Use the right scope:
# function -- for objects that must be clean per test (models, dicts)
# module -- for expensive connections shareable within one file
# session -- for very expensive resources (servers, global configuration)
conftest.py — Shared Fixtures #
conftest.py is a pytest-specific file for defining fixtures usable by all tests in the same directory and its subdirectories — without explicit imports.
# tests/conftest.py
import pytest
from myapp import create_app
from myapp.database import db as _db
@pytest.fixture(scope="session")
def app():
"""Create an app instance for testing -- once 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):
"""
Roll back after every test -- autouse=True means it's automatically active
without being named in test function parameters.
"""
with app.app_context():
yield
_db.session.rollback()
@pytest.fixture
def pengguna_tersimpan(app):
"""Create a user in the database and return the object."""
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: automatic via bersihkan_db
Parametrize — One Test, Many Scenarios #
import pytest
from myapp.kalkulasi import hitung_diskon, validasi_email
# Simple parametrize
@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 with custom IDs (easier to read in 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 with expected exceptions
@pytest.mark.parametrize("harga,diskon,exc", [
(-100, 10, ValueError), # negative price
(100, -5, ValueError), # negative discount
(100, 110, ValueError), # discount above 100
])
def test_hitung_diskon_input_tidak_valid(harga, diskon, exc):
with pytest.raises(exc):
hitung_diskon(harga, diskon)
Monkeypatch — Temporary Overrides #
monkeypatch is a built-in pytest fixture for temporarily replacing functions, methods, attributes, or environment variables during a test.
import pytest
from myapp import layanan
def test_kirim_email_berhasil(monkeypatch):
"""Test kirim_email without actually sending an email."""
email_terkirim = []
def mock_smtp_send(to, subject, body):
email_terkirim.append({"to": to, "subject": subject})
return True
# Replace the smtp_send function with a 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 with controlled environment variables."""
monkeypatch.setenv("DATABASE_URL", "postgresql://test:***@localhost/testdb")
monkeypatch.setenv("DEBUG", "false")
config = layanan.baca_konfigurasi()
assert config["db_url"] == "postgresql://test:***@localhost/testdb"
assert config["debug"] is False
def test_tanpa_env_database(monkeypatch):
"""Test when DATABASE_URL isn't 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 is a built-in fixture for a temporary directory."""
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 with pytest-mock #
pytest-mock provides the mocker fixture, wrapping unittest.mock with a cleaner API.
import pytest
from myapp import layanan_order
def test_proses_order_berhasil(mocker):
"""Test proses_order with all dependencies mocked."""
# Mock the 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 payment
mock_bayar = mocker.patch(
"myapp.layanan_order.proses_pembayaran",
return_value={"status": "success", "transaction_id": "TXN-001"}
)
# Mock email sending (fire and forget)
mock_email = mocker.patch("myapp.layanan_order.kirim_email_konfirmasi")
# Run the function under test
hasil = layanan_order.buat_order(produk_id=1, jumlah=2, user_id=42)
# Assert the result
assert hasil["status"] == "success"
assert hasil["total"] == 1_000_000
# Verify the mocked functions were called correctly
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 — Categorizing Tests #
import pytest
@pytest.mark.unit
def test_kalkulasi_sederhana():
assert 1 + 1 == 2
@pytest.mark.slow
def test_proses_data_besar():
# a test that takes a long time
import time
time.sleep(2)
assert True
@pytest.mark.integration
def test_koneksi_database_nyata():
# a test needing a real database
pass
@pytest.mark.skip(reason="Feature not implemented yet")
def test_fitur_baru():
pass
@pytest.mark.skipif(
condition=not os.getenv("CI"),
reason="Only runs in the CI pipeline"
)
def test_hanya_di_ci():
pass
@pytest.mark.xfail(reason="Known bug, waiting for a fix")
def test_yang_diketahui_gagal():
assert False
# Run only unit tests
pytest -m unit
# Run everything except slow
pytest -m "not slow"
# Run unit and integration
pytest -m "unit or integration"
Running PyTest #
# Run all tests
pytest
# Verbose -- show the name of every test
pytest -v
# Stop at the first failure
pytest -x
# Stop after 3 failures
pytest --maxfail=3
# Run specific tests only
pytest tests/unit/test_kalkulasi.py
pytest tests/unit/test_kalkulasi.py::test_hitung_diskon_normal
# Rerun the tests that failed last time
pytest --lf
# Show the 10 slowest tests
pytest --durations=10
# Parallel execution (needs pytest-xdist)
pytest -n 4 # 4 parallel workers
pytest -n auto # auto-detect the CPU count
# Coverage report
pytest --cov=myapp --cov-report=term-missing
pytest --cov=myapp --cov-report=html # create an HTML report in htmlcov/
Testing Anti-Patterns #
# ANTI-PATTERN: tests depending on execution order
counter = 0
def test_increment():
global counter
counter += 1
assert counter == 1 # ✗ -- fails if another test runs first
def test_counter_final():
assert counter == 5 # ✗ -- depends on another test
# CORRECT: every test stands alone
def test_increment():
counter = 0
counter += 1
assert counter == 1 # ✓ -- independent
# ANTI-PATTERN: tests touching external resources without mocks
def test_kirim_email():
hasil = kirim_email("[email protected]", "Test") # ✗ -- sends a real email!
assert hasil
# CORRECT: mock all external I/O
def test_kirim_email(mocker):
mock = mocker.patch("myapp.smtp.send") # ✓
kirim_email("[email protected]", "Test")
mock.assert_called_once()
# ANTI-PATTERN: one test testing too many things
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 # ✗ -- hard to tell which one failed
# CORRECT: one test, one 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" # ✓
Summary #
- Plain
assert, notassertEqual— pytest shows actual vs expected values in detail when an assert fails; no special assert methods needed.- Fixtures for setup/teardown — use
yieldin fixtures for a clean setup (before yield) and teardown (after yield) split; teardown is guaranteed to run even if the test fails.- The right fixture scope —
function(default) for objects that must be clean per test;modulefor expensive per-file connections;sessionfor global unchanged resources.conftest.pyfor shared fixtures — fixtures inconftest.pyare automatically available to all tests in the same directory without imports; create one at the roottests/for global fixtures.autouse=True— use it for fixtures that must be active in all tests without being named explicitly, like database cleanup or state resets.parametrizeto reduce duplication — run one test function with many input/expected combinations; usepytest.param(..., id=...)for readable IDs in output.monkeypatchfor external dependencies — temporarily replace functions, attributes, or env vars during a test; automatically restored after the test finishes.mocker.patchfor complex mocks — usepytest-mockfor mocks with call verification (assert_called_once_with), custom return values, and side effects.- Every test must be independent — tests must not depend on execution order or state left by other tests; use fixtures for clean setup every time.
- 80%+ coverage as a baseline — configure
fail_under = 80inpyproject.toml; high coverage isn’t a quality guarantee, but low coverage is a sign of untested areas.