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, not assertEqual — pytest shows actual vs expected values in detail when an assert fails; no special assert methods needed.
  • Fixtures for setup/teardown — use yield in 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 scopefunction (default) for objects that must be clean per test; module for expensive per-file connections; session for global unchanged resources.
  • conftest.py for shared fixtures — fixtures in conftest.py are automatically available to all tests in the same directory without imports; create one at the root tests/ 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.
  • parametrize to reduce duplication — run one test function with many input/expected combinations; use pytest.param(..., id=...) for readable IDs in output.
  • monkeypatch for external dependencies — temporarily replace functions, attributes, or env vars during a test; automatically restored after the test finishes.
  • mocker.patch for complex mocks — use pytest-mock for 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 = 80 in pyproject.toml; high coverage isn’t a quality guarantee, but low coverage is a sign of untested areas.

← Previous: Flask   Next: Selenium →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact