pytest dla początkujących: jedna funkcja, sześć testów, prawdziwy output

Żeby zacząć z pytestem, potrzebujesz trzech rzeczy: pliku, którego nazwa zaczyna się od test_, funkcji, której nazwa zaczyna się od test, i zwykłego assert. To całe API, jakiego potrzebujesz przez pierwszy miesiąc. pytest instalujesz dla tego, co wypisuje, gdy test pada: wartości po obu stronach porównania, bez proszenia. Poniżej jest sześć testów do dwóch małych funkcji, a jeden z nich łapie błąd, którego nie zobaczysz przy czytaniu kodu.

Instalacja i pierwszy test

pytest to zewnętrzna paczka, więc instalujesz go pipem — w tym samym środowisku, w którym jest testowany kod:

$ python -m pip install pytest
$ python -m pytest --version
pytest 8.4.2

Testujemy funkcję total_of z tekstu o gicie, która pomija wszystko, co nie jest liczbą, oraz nową funkcję, która odejmuje od ceny rabat procentowy. Obie lądują w prices.py:

def total_of(prices):
    total = 0
    for price in prices:
        if not isinstance(price, (int, float)):
            continue
        total = total + price
    return total


def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return price * percent / 100

Obok plik test_prices.py:

from prices import apply_discount, total_of


def test_total_of_sums_numbers():
    assert total_of([2.5, 1.5, 6]) == 10


def test_total_of_empty_list_is_zero():
    assert total_of([]) == 0


def test_apply_discount_takes_percent_off():
    assert apply_discount(200, 25) == 150

Bez klasy, bez self, bez assertEqual. Test to funkcja, która albo kończy się po cichu, albo rzuca wyjątek — a nieudany assert rzuca.

Czytaj raport z błędu od góry do dołu

$ pytest -q
..F                                                                      [100%]
================================== FAILURES ===================================
____________________ test_apply_discount_takes_percent_off ____________________

    def test_apply_discount_takes_percent_off():
>       assert apply_discount(200, 25) == 150
E       assert 50.0 == 150
E        +  where 50.0 = apply_discount(200, 25)

test_prices.py:13: AssertionError
=========================== short test summary info ===========================
FAILED test_prices.py::test_apply_discount_takes_percent_off - assert 50.0 ==...
1 failed, 2 passed in 0.10s

Pierwsza linia to jeden znak na test: kropka to zaliczony, F to oblany. Potem sam błąd. > wskazuje linię, która padła. Najcenniejsze są linie z E: funkcja zwróciła 50.0, test oczekiwał 150, a pytest pokazuje wywołanie, które dało ten wynik.

Napisałeś gołe assert, a i tak dostałeś obie wartości. pytest przepisuje asercje w plikach testowych, zanim je uruchomi, więc potrafi podać, ile wyszło po każdej stronie. Sam Python dałby Ci AssertionError i nic więcej.

Błąd: price * percent / 100 to wysokość rabatu, a nie cena po rabacie. Dwadzieścia pięć procent z 200 to 50. Poprawka to price * (100 - percent) / 100 i przebieg robi się zielony:

$ pytest -q
...                                                                      [100%]
3 passed in 0.01s

Jeden test, wiele przypadków: parametrize

Dwa prawie identyczne testy dla total_of to moment, w którym zaczyna się kopiuj-wklej. @pytest.mark.parametrize zamienia jedną funkcję testową w osobny test dla każdego wiersza danych, więc nowy przypadek kosztuje jedną linijkę. Przy wypisywaniu wierszy zapytaj, co jeszcze ktoś może przekazać. String mamy. A wartość logiczną?

import pytest


@pytest.mark.parametrize(
    "prices, expected",
    [
        ([2.5, 1.5, 6], 10),
        ([], 0),
        ([3, "free", 4], 7),
        ([5, True], 5),
    ],
)
def test_total_of(prices, expected):
    assert total_of(prices) == expected
$ pytest -q
...F.                                                                    [100%]
================================== FAILURES ===================================
__________________________ test_total_of[prices3-5] ___________________________

prices = [5, True], expected = 5
...
>       assert total_of(prices) == expected
E       assert 6 == 5
E        +  where 6 = total_of([5, True])

test_prices.py:16: AssertionError

Tego błędu nie znalazło żadne czytanie kodu. W Pythonie bool jest podklasą int, więc isinstance(True, int) zwraca True, a True dolicza się jako 1. Jeśli jakiś formularz wrzuci wartość checkboxa do listy cen, suma po cichu przesunie się o jeden. Poprawka: najpierw odrzuć wartości logiczne.

        if isinstance(price, bool) or not isinstance(price, (int, float)):
            continue

Zwróć uwagę na identyfikator testu, test_total_of[prices3-5]: pytest nazywa każdy przypadek, żebyś wiedział, który wiersz padł. prices3 to czwarty wiersz, licząc od zera.

Jak sprawdzić, że kod rzuca wyjątek

Rabat 150% powinien zostać odrzucony, a nie zamienić się w ujemną cenę. Test z pytest.raises przechodzi tylko wtedy, gdy kod w bloku rzuci wskazany wyjątek:

def test_apply_discount_rejects_percent_over_100():
    with pytest.raises(ValueError, match="between 0 and 100"):
        apply_discount(200, 150)

match sprawdza komunikat jako wyrażenie regularne, więc test padnie także wtedy, gdy jakiś niezwiązany ValueError poleci z innego powodu. Wszystkie sześć testów:

$ pytest -q
......                                                                   [100%]
6 passed in 0.01s

Flagi, których użyjesz w pierwszym miesiącu

PolecenieCo robi
pytest -qKrótki output: kropki, błędy, jedna linia podsumowania
pytest -vJedna linia na test, z nazwą i wynikiem
pytest -xZatrzymaj się na pierwszym błędzie
pytest -k total_ofUruchom tylko testy, których nazwa pasuje do wyrażenia
pytest test_prices.py::test_total_ofUruchom jedną funkcję testową z jednego pliku
$ pytest -v -k total_of
collecting ... collected 6 items / 2 deselected / 4 selected

test_prices.py::test_total_of[prices0-10] PASSED                         [ 25%]
test_prices.py::test_total_of[prices1-0] PASSED                          [ 50%]
test_prices.py::test_total_of[prices2-7] PASSED                          [ 75%]
test_prices.py::test_total_of[prices3-5] PASSED                          [100%]

======================= 4 passed, 2 deselected in 0.02s =======================

Najczęstszy błąd początkujących nie wygląda na błąd

$ pytest -q

no tests ran in 0.00s

Tak wygląda sytuacja, w której pytest nie znalazł nic do uruchomienia. Domyślnie zbiera pliki test_*.py albo *_test.py, a w nich funkcje, których nazwa zaczyna się od test. Nazwij plik tests_prices.py albo funkcję check_total, a pytest pominie je bez słowa skargi. Kod wyjścia to 5, a nie 0 — dlatego pipeline CI traktuje „zero testów” jako problem, a nie jako sukces.

Co dalej

Zielone testy mówią tylko tyle, że działają przypadki, o których pomyślałeś. Wiersz z True istnieje, bo ktoś zapytał „co jeszcze może tu wpaść?” — a tester mutacyjny zadaje podobne pytanie samym testom: jeśli zepsuję tę linię, czy cokolwiek zrobi się czerwone?

Tak też ocenia ProdQuest. Twoje rozwiązanie przechodzi przez testy pytest, także ukryte, których nie widzisz, a obok nich lint, testy mutacyjne i kontrolę bezpieczeństwa — razem co najmniej 70% oceny. Edytor, terminal i Python działają w przeglądarce, więc nic nie instalujesz. Jeśli wolisz ćwiczyć na zadaniach dobranych do Twojego poziomu, zamiast wymyślać własne, rozwiąż pierwsze zadanie jako gość. Jeśli jesteś jeszcze przed pierwszą funkcją, zacznij od planu na pierwszy tydzień.

FAQ

Czy pytest jest lepszy od unittest?

Dla początkującego jest krótszy. unittest jest w bibliotece standardowej i wymaga klasy oraz metod w rodzaju self.assertEqual; pytest bierze zwykłe funkcje i gołe assert. pytest uruchamia też istniejące testy unittest, więc wybierając go, nie zamykasz sobie drogi do żadnego ze stylów.

Dlaczego pytest pisze „no tests ran”?

Bo nic nie znalazł. Sprawdź, czy plik nazywa się test_*.py albo *_test.py, czy funkcje testowe zaczynają się od test i czy uruchamiasz pytest z katalogu, w którym leżą.

Jak uruchomić jeden test?

Podaj ścieżkę i nazwę: pytest test_prices.py::test_total_of. Testy po fragmencie nazwy uruchomisz przez pytest -k total_of.

Czy do testów w pyteście potrzebuję klas?

Nie. Test to funkcja, której nazwa zaczyna się od test. Możesz grupować testy w klasie o nazwie zaczynającej się od Test, ale nic Cię do tego nie zmusza.