Biblioteca standard livrează unittest și încă există proiecte serioase care-l folosesc. Dar în 2026, dacă faci git clone la un repo Python aleator și te uiți în tests/, vei găsi pytest. Orice framework major, orice SDK de cloud, orice unealtă internă pe care am atins-o în ultimii cinci ani: pytest. Motivul e simplu: pytest îți permite să scrii un test ca o funcție obișnuită cu un assert obișnuit și-ți dă un sistem de fixtures care se ocupă de setup fără ierarhii de moștenire.
Lecția asta e începutul Modulului 4. O să scriem teste pe care alți oameni (și viitorul tău eu) le pot citi efectiv.
De ce nu unittest
unittest e modelat după JUnit-ul din Java. A venit în Python la începutul anilor 2000 și se vede. Un test unittest este o clasă care moștenește din TestCase, cu metode numite test_*, hook-uri speciale setUp / tearDown și un API de aserții propriu:
import unittest
class TestMath(unittest.TestCase):
def setUp(self):
self.numbers = [1, 2, 3]
def test_sum(self):
self.assertEqual(sum(self.numbers), 6)
def test_max(self):
self.assertGreater(max(self.numbers), 2)
def tearDown(self):
self.numbers = None
Același lucru în pytest:
def test_sum():
assert sum([1, 2, 3]) == 6
def test_max():
assert max([1, 2, 3]) > 2
Fără clasă, fără moștenire, fără self.assertEqual. Doar funcții și assert. Când o aserție eșuează, pytest rescrie instrucțiunea assert la momentul importului, astfel încât mesajul de eșec să arate valorile reale fără să fii nevoit să scrii un mesaj custom:
> assert sum([1, 2, 3]) == 7
E assert 6 == 7
E + where 6 = sum([1, 2, 3])
Acea introspecție, integrată în assert rewriter, e cel mai mare câștig de quality-of-life față de unittest.
Instalare și descoperirea testelor
Adaugă-l ca dev dependency:
uv add --dev pytest
Prin convenție, testele stau într-un director tests/ la nivel de top:
my_project/
pyproject.toml
src/
my_package/
__init__.py
core.py
tests/
test_core.py
test_helpers.py
pytest descoperă testele parcurgând directorul de lucru și căutând fișiere numite test_*.py sau *_test.py, apoi colectând funcții numite test_* (și metode ale claselor numite Test* care nu au un __init__). Poți suprascrie fiecare parte din asta în pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-ra --strict-markers"
-ra arată un sumar scurt al tuturor rezultatelor non-passing la final. --strict-markers dă eroare dacă folosești un marker nedeclarat: util pentru a prinde greșelile de tipar.
Rularea testelor
Întreaga suită:
pytest
Un singur fișier:
pytest tests/test_core.py
O singură funcție de test (sintaxa :: e „node id”-ul pytest):
pytest tests/test_core.py::test_parse_url
Flag-ul foarte util -k, care selectează teste prin potrivire de substring pe numele testului:
pytest -k "url and not slow"
Oprire la primul eșec când debughezi:
pytest -x
Afișează print-urile din codul tău (pytest captează stdout în mod implicit):
pytest -s
Modul verbose afișează numele fiecărui test pe măsură ce rulează:
pytest -v
Combină-le liber. Treaba mea zilnică e pytest -x -v cât iterez, apoi pytest pentru o rulare completă curată înainte de commit.
Scrierea unei aserții utile
assert e doar assert. Orice e truthy trece; orice e falsy eșuează. Pentru că assert rewriter-ul despachetează expresia, aproape niciodată nu trebuie să scrii un mesaj custom:
def test_user_creation():
user = User(name="Ada", age=36)
assert user.name == "Ada"
assert user.age == 36
assert user.is_adult
Pentru float-uri, folosește pytest.approx:
import pytest
def test_compound_interest():
assert compound(100, 0.05, 10) == pytest.approx(162.89, rel=1e-3)
Pentru excepții, pytest.raises:
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_invalid_email():
with pytest.raises(ValueError, match="invalid email"):
validate_email("not-an-email")
Argumentul match= rulează re.search pe mesajul excepției, ceea ce-ți permite să afirmi care ValueError ai obținut fără să scrii o egalitate fragilă pe string-uri.
Pentru warning-uri, pattern-ul paralel e pytest.warns(DeprecationWarning).
Fixtures: trucul de dependency injection
Majoritatea testelor reale au nevoie de ceva: o conexiune la baza de date, un director temporar, un client HTTP fals, o configurație parsată. În unittest scriai setUp. În pytest scrii o fixture și orice test care o vrea o listează ca parametru.
import pytest
@pytest.fixture
def sample_user():
return User(name="Ada", age=36)
def test_user_name(sample_user):
assert sample_user.name == "Ada"
def test_user_is_adult(sample_user):
assert sample_user.is_adult
Când pytest vede sample_user în lista de parametri a unui test, caută fixture-ul cu acel nume, îl apelează și pasează valoarea de retur înăuntru. Asta-i tot. Mecanismul scalează de la o fixture la cincizeci.
O fixture poate face setup și teardown folosind yield:
@pytest.fixture
def db_connection():
conn = connect("postgresql://localhost/test")
conn.execute("BEGIN")
yield conn
conn.execute("ROLLBACK")
conn.close()
Tot ce e înainte de yield rulează înainte de test. Tot ce e după rulează când testul se termină, chiar dacă a eșuat. Ăsta e cel mai curat înlocuitor pentru setUp / tearDown pe care-l vei găsi.
Scope-ul fixture-urilor
În mod implicit, o fixture e recreată pentru fiecare test care o folosește. Pentru setup-uri scumpe asta e risipă. Scope-ul controlează reutilizarea:
@pytest.fixture(scope="session")
def docker_postgres():
container = start_postgres()
yield container
container.stop()
@pytest.fixture(scope="function")
def db(docker_postgres):
"""A clean transaction per test, on a single shared container."""
conn = docker_postgres.connect()
conn.execute("BEGIN")
yield conn
conn.execute("ROLLBACK")
Cele patru scope-uri sunt function (default), class, module și session. Combinația de mai sus, infrastructură cu scope de sesiune și tranzacții cu scope de funcție, e pattern-ul standard pentru testele Postgres. Containerul pornește o singură dată pentru toată rularea de teste; fiecare test individual primește o tranzacție curată făcută rollback.
Compunerea fixture-urilor
Fixture-urile pot cere alte fixtures. pytest construiește un graf de dependențe și le instanțiază în ordinea corectă:
@pytest.fixture
def settings():
return Settings(debug=True, db_url="sqlite:///:memory:")
@pytest.fixture
def app(settings):
return create_app(settings)
@pytest.fixture
def client(app):
return TestClient(app)
def test_root(client):
assert client.get("/").status_code == 200
Testul cere client. pytest vede că client are nevoie de app, iar app are nevoie de settings. Le construiește bottom-up și pasează client testului. Ceri vreodată doar ce ai nevoie efectiv.
Fixtures încorporate care merită știute
pytest livrează cu o mână pe care le vei folosi constant. Două sunt esențiale.
tmp_path îți dă un director pathlib.Path proaspăt, unic pentru test:
def test_writes_file(tmp_path):
target = tmp_path / "out.txt"
write_report(target, data=[1, 2, 3])
assert target.read_text().startswith("Report")
Directorul e curățat automat. Gata cu dansul cu tempfile.TemporaryDirectory() și try/finally.
monkeypatch patch-uiește atribute, variabile de mediu și sys.path pentru durata unui singur test:
def test_uses_env_var(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key-123")
assert load_config().api_key == "test-key-123"
def test_swap_function(monkeypatch):
monkeypatch.setattr("my_package.core.fetch", lambda url: {"ok": True})
assert run_pipeline() == [{"ok": True}]
Anulează tot când testul se termină, ca să nu poți accidental să scapi stare în următorul test.
Altele bune: capsys (captează stdout/stderr), caplog (captează înregistrările de log), request (introspectează testul care rulează acum).
Skipping și eșecuri așteptate
Uneori un test are sens doar în anumite condiții:
import sys
import pytest
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only")
def test_unix_socket():
...
@pytest.mark.skipif(
sys.version_info < (3, 13),
reason="needs PEP 695 type aliases",
)
def test_new_syntax():
...
xfail zice „ăsta-i așteptat să eșueze chiar acum, nu face CI-ul roșu, dar spune-mi când începe să treacă”:
@pytest.mark.xfail(reason="upstream bug, fix tracked in #1234")
def test_known_bug():
assert call_buggy_thing() == 42
Când bug-ul subiacent e reparat, testul începe să treacă, pytest îl raportează ca XPASSED și tu scoți marker-ul. E o listă de TODO încorporată.
Unde mergi mai departe
Acum știi destul ca să scrii teste utile pentru majoritatea codului. Lecția 20 acoperă cele trei pattern-uri care transformă restul testării din „imposibil” în „un paragraf”: parametrize pentru tabele de cazuri, mocks pentru sisteme externe și conftest.py pentru partajarea fixture-urilor între fișiere.
Documentația pytest de la docs.pytest.org e neobișnuit de bună: când nimerești ceva ce lecția asta n-a acoperit, verifică acolo întâi.
Citații: documentația pytest, consultat 2026-05-01.