Python, de la zero Lecția 20 / 60

Mocks, parametrize, conftest.py: trusa de testare

Trei pattern-uri pytest care transforma o situatie de tipul 'asta e imposibil de testat' intr-un paragraf de cod.

Lecția 19 a acoperit elementele de bază din pytest: scrii o funcție, faci un assert, ceri o fixture. Asta te duce o bună parte din drum. Lecția de față acoperă cele trei pattern-uri care se ocupă de restul: parametrize când ai multe cazuri similare, mocks când ai o dependență externă pe care nu vrei s-o apelezi și conftest.py când ai nevoie să împarți setup-ul între mai multe fișiere.

Aceste trei pattern-uri fac diferența dintre „aș testa asta dacă aș putea” și „hai să bat repede testul”.

@pytest.mark.parametrize: tabelul de cazuri

O grămadă de teste aproape identice e un miros suspect. Exemplul clasic:

def test_slugify_simple():
    assert slugify("Hello World") == "hello-world"

def test_slugify_accents():
    assert slugify("Café") == "cafe"

def test_slugify_punctuation():
    assert slugify("foo, bar!") == "foo-bar"

def test_slugify_empty():
    assert slugify("") == ""

Fiecare test e un input și un output așteptat. parametrize le strânge într-un tabel:

import pytest

@pytest.mark.parametrize("raw, expected", [
    ("Hello World", "hello-world"),
    ("Café", "cafe"),
    ("foo, bar!", "foo-bar"),
    ("", ""),
])
def test_slugify(raw, expected):
    assert slugify(raw) == expected

pytest raportează fiecare rând ca un test separat, așa că o eșuare îți spune ce input a stricat lucrurile. Output-ul arată cam așa:

test_slug.py::test_slugify[Hello World-hello-world] PASSED
test_slug.py::test_slugify[Caf\xe9-cafe] PASSED
test_slug.py::test_slugify[foo, bar!-foo-bar] PASSED
test_slug.py::test_slugify[-] PASSED

ID-urile auto-generate din paranteze devin urâte rapid. Folosește argumentul ids= pentru nume lizibile:

@pytest.mark.parametrize("raw, expected", [
    ("Hello World", "hello-world"),
    ("Café", "cafe"),
    ("foo, bar!", "foo-bar"),
    ("", ""),
], ids=["spaces", "accents", "punctuation", "empty"])
def test_slugify(raw, expected):
    assert slugify(raw) == expected

Poți stivui decoratoare parametrize ca să obții produsul cartezian:

@pytest.mark.parametrize("base", [10, 100, 1000])
@pytest.mark.parametrize("exp", [0, 1, 2])
def test_power(base, exp):
    assert power(base, exp) == base ** exp

Asta generează nouă teste: fiecare bază încrucișată cu fiecare exponent. Folosește cu măsură, explozia combinatorială e un mod real de eșec.

Pentru tabele mai complexe, pytest.param îți permite să atașezi markeri pe fiecare rând:

@pytest.mark.parametrize("path", [
    "tests/data/small.json",
    "tests/data/big.json",
    pytest.param("tests/data/huge.json", marks=pytest.mark.slow),
    pytest.param("tests/data/broken.json", marks=pytest.mark.xfail),
])
def test_parse(path):
    parse(Path(path))

Acum pytest -m "not slow" sare peste fișierul mare în bucla ta rapidă de dezvoltare.

O notă despre asistența AI

Tabelele de parametrize sunt unul dintre lucrurile la care asistenții AI sunt foarte buni. Dă-le semnătura funcției și două-trei perechi de exemple input/output, iar ei vor genera un bloc parametrize cuprinzător în câteva secunde, prinzând adesea variații de format la care nu te-ai fi gândit.

Capcana: aproape întotdeauna testează doar happy path-urile. Dacă nu spui explicit „include cazuri limită: input-uri goale, None, șiruri foarte lungi, Unicode, input malformat, valori de frontieră”, vei primi un bloc bine formatat care ratează fiecare bug interesant. Cere explicit marginile, revizuiește rezultatul și adaugă cazul care a stricat producția trimestrul trecut. Asistentul nu știe de ăla.

unittest.mock: a falsifica dependențele externe

Codul real vorbește cu lucruri: API-uri HTTP, baze de date, sistemul de fișiere, ceasul. Testele n-ar trebui s-o facă. Mocks îți permit să înlocuiești un obiect real cu unul fals care înregistrează apelurile și returnează ce-i spui tu.

unittest.mock e în biblioteca standard. Cele două piese pe care le vei folosi sunt MagicMock și patch.

from unittest.mock import MagicMock

def test_email_sender():
    fake_smtp = MagicMock()
    sender = EmailSender(smtp_client=fake_smtp)

    sender.send("ada@example.com", "subject", "body")

    fake_smtp.sendmail.assert_called_once_with(
        "noreply@narcismiclaus.com",
        ["ada@example.com"],
        "Subject: subject\n\nbody",
    )

MagicMock acceptă orice apel de metodă, returnează un alt MagicMock și își amintește tot ce i s-a întâmplat. assert_called_once_with verifică semnătura apelului.

Asta merge când codul tău acceptă dependența ca argument (constructor injection). Când nu o face, când codul apelează direct requests.get în interiorul funcției, recurgi la patch:

from unittest.mock import patch

@patch("my_app.weather.requests.get")
def test_get_temperature(mock_get):
    mock_get.return_value.json.return_value = {"temp_c": 21.3}

    assert get_temperature("Rome") == 21.3
    mock_get.assert_called_once_with(
        "https://api.weather.example/v1",
        params={"city": "Rome"},
    )

Sau ca context manager, când ai nevoie de el doar pentru o parte a testului:

def test_get_temperature():
    with patch("my_app.weather.requests.get") as mock_get:
        mock_get.return_value.json.return_value = {"temp_c": 21.3}
        assert get_temperature("Rome") == 21.3

Regula path-to-mock

Asta e cea mai frecventă greșeală la mocking. Regula:

Patch numele acolo unde e folosit, nu acolo unde e definit.

Dacă my_app/weather.py face import requests și apoi apelează requests.get(...), faci patch la my_app.weather.requests.get. Nu la requests.get.

De ce: import requests leagă numele requests în spațiul de nume al lui my_app.weather. Când faci patch la requests.get la sursă, legarea locală din my_app.weather încă pointează spre funcția reală. Când faci patch la my_app.weather.requests.get, faci patch la legătura pe care codul real o accesează.

Aceeași regulă pentru from x import y:

# my_app/weather.py
from datetime import datetime

def now_iso():
    return datetime.now().isoformat()
# tests/test_weather.py
@patch("my_app.weather.datetime")  # ← aici, nu "datetime.datetime"
def test_now_iso(mock_dt):
    mock_dt.now.return_value.isoformat.return_value = "2026-05-01T12:00:00"
    assert now_iso() == "2026-05-01T12:00:00"

Jumătate din toată confuzia gen „dar merge în producție!” la mocks vine din a greși exact asta.

patch.object

Când ai deja o referință la obiect, patch.object e mai curat decât să construiești un string:

from my_app import weather

def test_with_object():
    with patch.object(weather, "fetch_raw") as mock_fetch:
        mock_fetch.return_value = {"temp_c": 21.3}
        assert weather.get_temperature("Rome") == 21.3

Mock-uri HTTP specifice bibliotecii

Pentru lucrul cu HTTP, bibliotecile dedicate sunt mai plăcute decât patch-ul brut:

  • responses pentru requests:
import responses

@responses.activate
def test_get_temperature():
    responses.get(
        "https://api.weather.example/v1",
        json={"temp_c": 21.3},
    )
    assert get_temperature("Rome") == 21.3
  • pytest-httpx pentru httpx:
def test_get_temperature(httpx_mock):
    httpx_mock.add_response(
        url="https://api.weather.example/v1",
        json={"temp_c": 21.3},
    )
    assert get_temperature("Rome") == 21.3

Acestea fac potrivire pe URL/metodă/headere și îți permit să verifici că au plecat cererile corecte, fără să trebuiască să-ți amintești lanțul exact mock.return_value.json.return_value.

conftest.py: fixtures partajate, descoperire automată

Odată ce ai mai mult de un fișier de test, vrei fixtures definite o dată și folosite peste tot. Asta e conftest.py.

conftest.py e un nume de fișier magic. pytest îl auto-descoperă: orice test dintr-un director (sau subdirector) vede automat fixtures-urile definite în orice conftest.py din acel director până la rădăcină. Fără import-uri.

Un layout tipic:

my_project/
  pyproject.toml
  src/
    my_package/
      ...
  tests/
    conftest.py              # partajat de tot
    test_helpers.py
    unit/
      conftest.py            # doar pentru unit/
      test_pure_functions.py
    integration/
      conftest.py            # doar pentru integration/
      test_api.py
      test_db.py

Top-level-ul tests/conftest.py pentru fixtures ieftine, mereu active:

import os
import pytest
from my_package.config import Settings

@pytest.fixture
def settings():
    return Settings(
        debug=True,
        db_url="sqlite:///:memory:",
        api_key="test-key",
    )

@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
    """Strip user env vars that could leak into tests."""
    for var in ("AWS_PROFILE", "DATABASE_URL", "OPENAI_API_KEY"):
        monkeypatch.delenv(var, raising=False)

autouse=True înseamnă că fiecare test primește acea fixture aplicată automat, perfect pentru igiena variabilelor de mediu.

integration/conftest.py pentru setup costisitor de care au nevoie doar testele de integrare:

import pytest
import docker

@pytest.fixture(scope="session")
def postgres_container():
    client = docker.from_env()
    container = client.containers.run(
        "postgres:16",
        environment={"POSTGRES_PASSWORD": "test"},
        ports={"5432/tcp": None},
        detach=True,
        remove=True,
    )
    wait_for_postgres(container)
    yield container
    container.stop()

@pytest.fixture
def db(postgres_container):
    conn = connect_to(postgres_container)
    conn.execute("BEGIN")
    yield conn
    conn.execute("ROLLBACK")

Testele unitare nu pornesc niciodată un container. Testele de integrare primesc unul pornit o dată pe sesiune. Împărțirea e geografică, pe directoare, iar pytest o impune gratuit.

pytest_plugins

Dacă publici fixtures reutilizabile (să zicem o bibliotecă de testare la nivel de companie), pune-le într-un modul Python obișnuit și spune-i lui conftest.py să-l încarce:

# tests/conftest.py
pytest_plugins = ["mycorp.testing.fixtures"]

Acum fiecare fixture din mycorp.testing.fixtures e disponibilă, exact ca și cum ar fi locuit în conftest.py.

Un exemplu lucrat: testarea unui client API plătit

Imaginează-ți o funcție care apelează un API contorizat. Categoric nu vrei ca suita ta de teste să cheltuie bani:

# my_app/translate.py
import httpx

def translate(text: str, target: str) -> str:
    response = httpx.post(
        "https://api.translate.example/v1",
        json={"text": text, "target": target},
        headers={"Authorization": f"Bearer {load_api_key()}"},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()["translation"]

Testul, folosind pytest-httpx și parametrize:

import pytest

@pytest.mark.parametrize("text, target, translation", [
    ("hello", "it", "ciao"),
    ("goodbye", "ro", "la revedere"),
    ("", "it", ""),
], ids=["english-to-italian", "english-to-romanian", "empty-input"])
def test_translate(httpx_mock, text, target, translation):
    httpx_mock.add_response(
        url="https://api.translate.example/v1",
        json={"translation": translation},
    )
    assert translate(text, target) == translation

def test_translate_raises_on_500(httpx_mock):
    httpx_mock.add_response(
        url="https://api.translate.example/v1",
        status_code=500,
    )
    with pytest.raises(httpx.HTTPStatusError):
        translate("hello", "it")

Trei pattern-uri, un fișier de test, zero bani cheltuiți: parametrize pentru tabel, un mock HTTP pentru rețea și (dacă httpx_mock venea dintr-un conftest.py) o fixture care nu trebuia redeclarată.

Lecția următoare, 21, lasă în urmă testarea la runtime și se uită la stiva de analiză statică: type checkere și lintere care prind o categorie diferită de bug-uri înainte ca vreun test să ruleze măcar.

Citații: pytest documentation, pytest-httpx, responses, unittest.mock. Consultat 2026-05-01.

Caută