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:
responsespentrurequests:
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-httpxpentruhttpx:
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.