Python, de la zero Lecția 19 / 60

pytest de la zero la fixtures

De ce pytest a învins unittest, pattern-urile de bază pentru teste și sistemul de fixtures care face setup-ul de teste suportabil.

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.

Caută