Kurs Python · Moduł 6: Async i FastAPI

Testing - weryfikacja stabilności

4 min czytania
W tej lekcji4

Witaj! Darwin z testing dla FastAPI!

Wyobraź sobie, że poprawiasz jedną linijkę w endpointcie logowania, a po wdrożeniu okazuje się, że przestała działać lista gatunków. Nikt tego nie sprawdził, bo "przecież zmiana była mała". Testy automatyczne łapią takie wpadki w kilka sekund, zanim zobaczy je ktokolwiek poza Tobą.

Testing to krytyczna część produkcyjnych aplikacji. FastAPI ma świetne wsparcie dla pytest!

Analogia Safari: Testing to jak weryfikacja sprzętu przed safari - sprawdzamy, czy GPS działa, czy pojazd jest sprawny, czy radio łapie! Lepiej wykryć problemy przed wycieczką!

Instalacja

Instalujemy trzy pakiety: pytest uruchamia testy, httpx to klient HTTP, na którym FastAPI opiera swojego klienta testowego, a pytest-asyncio pozwala pisać testy jako funkcje async def:

1pip install pytest httpx pytest-asyncio

Nic więcej nie trzeba konfigurować. pytest sam znajduje pliki o nazwach test_*.py lub *_test.py i uruchamia w nich funkcje, których nazwy zaczynają się od test.

Podstawowy test FastAPI

TestClient symuluje żądania HTTP do aplikacji bez uruchamiania serwera - nie potrzebujesz uvicorna ani wolnego portu. Tworzysz go raz, przekazując obiekt app, i pierwszy test może sprawdzić trasę główną:

1# test_main.py
2from fastapi.testclient import TestClient
3from main import app
4
5client = TestClient(app)
6
7def test_root():
8    response = client.get("/")
9    assert response.status_code == 200
10    assert response.json() == {"message": "Hello Safari"}

Test to zwykła funkcja def, nawet jeśli endpointy w aplikacji są async - TestClient sam zajmuje się pętlą zdarzeń. Słowo assert sprawdza warunek, a gdy ten nie jest spełniony, pytest pokaże obie porównywane wartości, więc od razu widzisz, co poszło nie tak.

Kolejne testy sprawdzają listę gatunków i pojedynczy rekord. Oprócz kodu statusu warto zajrzeć do treści odpowiedzi:

1def test_list_species():
2    response = client.get("/species")
3    assert response.status_code == 200
4    assert isinstance(response.json(), list)
5
6def test_get_species():
7    response = client.get("/species/1")
8    assert response.status_code == 200
9    data = response.json()
10    assert data["id"] == 1
11    assert "name" in data

response.json() zamienia odpowiedź na słownik lub listę Pythona. Nie porównujemy całego rekordu, tylko sprawdzamy to, co naprawdę ważne: typ wyniku, id i obecność pola name.

Test tworzenia gatunku wysyła ciało żądania parametrem json:

1def test_create_species():
2    response = client.post("/species", json={
3        "name": "Gepard",
4        "scientific_name": "Acinonyx jubatus",
5        "population": 7100,
6        "habitat": "savanna"
7    })
8    assert response.status_code == 201
9    assert response.json()["name"] == "Gepard"

Uwaga na pułapkę: FastAPI domyślnie zwraca dla POST kod 200. Ten test przejdzie tylko wtedy, gdy endpoint deklaruje @app.post("/species", status_code=201). Kod 201 Created lepiej opisuje utworzenie zasobu, więc polecam dopisać ten parametr w aplikacji, zamiast osłabiać test.

Ostatni test sprawdza ścieżkę błędu:

1def test_species_not_found():
2    response = client.get("/species/999")
3    assert response.status_code == 404

Testowanie przypadków negatywnych jest równie ważne jak pozytywnych. Gatunek o id 999 nie istnieje, więc oczekujemy 404, a nie 500 ani pustej odpowiedzi.

Uruchom testy:

Wystarczy jedno polecenie w katalogu projektu:

1pytest

Każda kropka w wyniku to test, który przeszedł, a litera F oznacza porażkę ze szczegółowym opisem poniżej. Flaga -v wypisze nazwę każdego testu.

Async tests

Czasem sam test musi być asynchroniczny, na przykład gdy wywołujesz w nim funkcje async z bazą danych. Wtedy zamiast TestClient używamy AsyncClient z httpx, a dekorator @pytest.mark.asyncio z pakietu pytest-asyncio mówi pytestowi, że funkcję trzeba uruchomić w pętli zdarzeń:

1import pytest
2from httpx import ASGITransport, AsyncClient
3from main import app
4
5@pytest.mark.asyncio
6async def test_list_species_async():
7    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
8        response = await client.get("/species")
9        assert response.status_code == 200

ASGITransport łączy klienta bezpośrednio z aplikacją, bez sieci, a base_url może być dowolny, bo żadne żądanie nie wychodzi na zewnątrz. W starszym kodzie, także w ćwiczeniach, spotkasz skrót AsyncClient(app=app). httpx oznaczył go jako przestarzały w wersji 0.27, a w wersji 0.28 usunął - dziś kończy się błędem TypeError. Dokumentacja FastAPI używa zamiast pytest-asyncio dekoratora @pytest.mark.anyio - oba podejścia działają, wybierz jedno i trzymaj się go w całym projekcie.

Dobre nawyki

Testy powyżej zakładają, że w bazie jest gatunek o id 1. To kruche założenie: wynik zależy od tego, co akurat leży w bazie. W praktyce testy korzystają z osobnej bazy testowej, a FastAPI pozwala podmienić zależność get_db przez app.dependency_overrides, więc endpointy dostają sesję testową bez zmiany ich kodu. Pisz testy razem z funkcją, a nie "kiedyś" - im dłużej czekasz, tym trudniej je dopisać.

Następna lekcja: Production deployment! Zielone testy to przepustka, bez której aplikacja nie powinna wyjechać do chmury.

Pamiętaj: test to przegląd sprzętu przed wyjazdem - dziesięć minut w obozie oszczędza godzin na sawannie z zepsutym radiem.

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Do czego służy pytest-asyncio?

  2. 2. Co zapewnia TestClient w FastAPI?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w pionie

    Ułóż asynchroniczny test endpoint:

  • Klikanie w kolejności

    Ułóż asercję sprawdzającą status code:

  • Edytor kodu

    Stwórz async test sprawdzający, że GET /species zwraca status 200 i listę.

Przydatne artykuły