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
| Polecenie | Co robi |
|---|---|
pytest -q | Krótki output: kropki, błędy, jedna linia podsumowania |
pytest -v | Jedna linia na test, z nazwą i wynikiem |
pytest -x | Zatrzymaj się na pierwszym błędzie |
pytest -k total_of | Uruchom tylko testy, których nazwa pasuje do wyrażenia |
pytest test_prices.py::test_total_of | Uruchom 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.