Kurs Python · Moduł 12: Projekt końcowy
Strategia Testowania AI
W tej lekcji5
Ranger, który sprawdza tylko, czy land rover odpala na parkingu, nie wie jeszcze, czy przejedzie przez rzekę. Testowanie aplikacji AI ma ten sam problem w podwójnej dawce. Zwykły kod zachowuje się deterministycznie: ta sama funkcja z tym samym wejściem daje ten sam wynik. Model językowy potrafi odpowiedzieć na to samo pytanie na dwa różne sposoby. Dlatego musimy weryfikować nie tylko kod, ale także jakość odpowiedzi.
Piramida testów dla AI
Klasyczna piramida testów mówi: najwięcej szybkich testów jednostkowych u podstawy, mniej integracyjnych w środku, najmniej wolnych testów end-to-end na szczycie. W projekcie AI dokładamy warstwę ewaluacji odpowiedzi modelu. Proporcje poniżej to orientacyjny cel, a nie norma:
1 ╱╲
2 ╱ ╲
3 ╱ E2E╲ ← 10% - Testy end-to-end
4 ╱──────╲
5 ╱ ╲
6 ╱Integration╲ ← 20% - Testy integracyjne
7 ╱────────────╲
8 ╱ ╲
9 ╱ AI Evaluation ╲ ← 30% - Ewaluacja AI
10 ╱──────────────────╲
11 ╱ ╲
12 ╱ Unit ╲ ← 40% - Testy jednostkowe
13 ╱────────────────────────╲Podstawa jest najszersza, bo testy jednostkowe są najszybsze i najtańsze. Ewaluacja AI leży nad nimi: jest wolniejsza i często kosztuje (każde pytanie to wywołanie modelu). Od najmniej do najbardziej kosztownych: unit, integration, E2E, a na końcu testy wydajnościowe pod obciążeniem.
Unit Tests
Testy jednostkowe piszemy w pytest. Fixture to funkcja oznaczona @pytest.fixture, która przygotowuje dane lub zasoby dla testu, a pytest wstrzykuje ją po nazwie parametru. Mock z unittest.mock tworzy atrapę obiektu, a spec= pilnuje, by atrapa miała tylko metody prawdziwej klasy.
1import pytest
2from unittest.mock import Mock, AsyncMock
3
4class TestDocumentService:
5 @pytest.fixture
6 def mock_repo(self):
7 return Mock(spec=DocumentRepository)
8
9 @pytest.fixture
10 def mock_embedder(self):
11 embedder = Mock()
12 embedder.embed.return_value = [0.1] * 1536
13 return embedder
14
15 @pytest.fixture
16 def service(self, mock_repo, mock_embedder):
17 return DocumentService(mock_repo, mock_embedder)Fixture service sama korzysta z dwóch innych fixture, więc pytest buduje całe drzewo zależności za Ciebie. Embedder zwraca stały wektor 1536 liczb, bez żadnego wywołania API. Dalszy ciąg tej samej klasy to właściwe testy:
1 def test_chunk_document(self, service):
2 doc = Document(content="A" * 1000)
3 chunks = service.chunk_document(doc, chunk_size=200)
4
5 assert len(chunks) == 5
6 assert all(len(c.content) <= 200 for c in chunks)
7
8 @pytest.mark.asyncio
9 async def test_save_document(self, service, mock_repo):
10 doc = Document(title="Test", content="Content")
11 mock_repo.save = AsyncMock(return_value=doc)
12
13 result = await service.save(doc)
14
15 mock_repo.save.assert_called_once()
16 assert result.title == "Test"Test chunkowania sprawdza czystą logikę: 1000 znaków w kawałkach po 200 to 5 fragmentów. Test zapisu jest asynchroniczny, dlatego ma znacznik @pytest.mark.asyncio z wtyczki pytest-asyncio i używa AsyncMock, który zwraca wartość po await. Asercja to po prostu assert result == expected albo, jak tutaj, assert result.title == "Test". Do podmiany funkcji w module (na przykład wywołania zewnętrznego API) służy unittest.mock.patch, używane jako dekorator lub with patch("modul.funkcja") as fake:.
Integration Tests
Test integracyjny uruchamia kilka części razem. Testcontainers startuje prawdziwego Qdranta w kontenerze Dockera na czas testów, a httpx.AsyncClient wysyła żądania prosto do aplikacji ASGI, bez sieci.
1import pytest
2import pytest_asyncio
3from httpx import ASGITransport, AsyncClient
4from testcontainers.community.qdrant import QdrantContainer
5
6@pytest.fixture(scope="module")
7def qdrant_container():
8 with QdrantContainer() as qdrant:
9 yield qdrant
10
11@pytest_asyncio.fixture
12async def app_client(qdrant_container):
13 app = create_app(qdrant_url=f"http://{qdrant_container.rest_host_address}")
14 transport = ASGITransport(app=app)
15 async with AsyncClient(transport=transport, base_url="http://test") as client:
16 yield clientDwie rzeczy zmieniły się w bibliotekach i warto je znać. W httpx od wersji 0.28 nie ma już parametru app=, aplikację przekazujesz przez ASGITransport. W pytest-asyncio asynchroniczną fixture oznacza się @pytest_asyncio.fixture. Kontener Qdranta instalujesz jako testcontainers[qdrant], a starsze wersje importowały go z testcontainers.qdrant. Teraz sam test:
1@pytest.mark.asyncio
2async def test_upload_and_query(app_client):
3 # Upload document
4 response = await app_client.post(
5 "/documents",
6 json={"title": "Test", "content": "Python is great"}
7 )
8 assert response.status_code == 201
9
10 # Query
11 response = await app_client.post(
12 "/query",
13 json={"question": "What is Python?"}
14 )
15 assert response.status_code == 200
16 assert "Python" in response.json()["answer"]Test przechodzi przez cały przepływ: upload, potem zapytanie. Zauważ, że ostatnia asercja zależy od odpowiedzi modelu, więc może być niestabilna. Takie sprawdzenia lepiej przenieść do ewaluacji.
AI Evaluation
Ewaluacja mierzy jakość odpowiedzi na przygotowanym zbiorze pytań. Każdy przypadek zawiera pytanie i słowa kluczowe, których spodziewamy się w odpowiedzi:
1from dataclasses import dataclass
2
3@dataclass
4class EvalCase:
5 question: str
6 expected_keywords: list[str]
7 context_required: bool = True
8
9eval_dataset = [
10 EvalCase(
11 question="Co to jest RAG?",
12 expected_keywords=["retrieval", "generation", "augmented"],
13 context_required=True
14 ),
15 EvalCase(
16 question="Jak działa embedding?",
17 expected_keywords=["wektor", "semantyczny", "reprezentacja"],
18 context_required=True
19 )
20]Sam zbiór to zwykłe dane. Funkcja oceniająca liczy, jaki odsetek słów kluczowych pojawił się w odpowiedzi, i sprawdza, czy system podał źródła:
1async def evaluate_rag_system(query_fn, dataset: list[EvalCase]) -> dict:
2 results = {"total": len(dataset), "passed": 0, "failed": []}
3
4 for case in dataset:
5 response = await query_fn(case.question)
6
7 # Check keywords
8 answer_lower = response.answer.lower()
9 keywords_found = sum(1 for k in case.expected_keywords if k in answer_lower)
10 keyword_score = keywords_found / len(case.expected_keywords)
11
12 # Check sources
13 has_sources = len(response.sources) > 0 if case.context_required else True
14
15 if keyword_score >= 0.5 and has_sources:
16 results["passed"] += 1
17 else:
18 results["failed"].append({
19 "question": case.question,
20 "keyword_score": keyword_score,
21 "has_sources": has_sources
22 })
23
24 results["accuracy"] = results["passed"] / results["total"]
25 return resultsWynik to accuracy plus lista porażek do przejrzenia. Słowa kluczowe to prosta, zgrubna metoda. Poważniejsze projekty oceniają też wierność odpowiedzi względem źródeł, ale zasada zostaje: stały zbiór pytań, powtarzalny pomiar.
CI/CD Pipeline
Testy mają sens, gdy uruchamiają się same przy każdym pushu. GitHub Actions odpala kolejne kroki na czystej maszynie, a Qdrant działa obok jako usługa:
1# .github/workflows/test.yml
2name: Test Suite
3
4on: [push, pull_request]
5
6jobs:
7 test:
8 runs-on: ubuntu-latest
9 services:
10 qdrant:
11 image: qdrant/qdrant
12 ports:
13 - 6333:6333
14
15 steps:
16 - uses: actions/checkout@v4
17 - uses: actions/setup-python@v5
18 with:
19 python-version: "3.12"
20
21 - name: Install dependencies
22 run: pip install -r requirements.txt
23
24 - name: Run unit tests
25 run: pytest tests/unit -v
26
27 - name: Run integration tests
28 run: pytest tests/integration -v
29 env:
30 QDRANT_URL: http://localhost:6333
31
32 - name: Run AI evaluation
33 run: python scripts/evaluate.py
34 env:
35 OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}Klucz API trafia z secrets, nigdy z repozytorium. Pokrycie kodu mierzy wtyczka pytest-cov: pytest --cov=app wypisze, które linie nie zostały przetestowane. Pojedynczy plik uruchomisz przez pytest tests/unit/test_service.py, a wiele pipeline'ów po testach buduje jeszcze obraz poleceniem docker build -t myapp:latest ., do którego wrócimy przy deploymencie. Moja rada: odpalaj ewaluację AI osobno od testów jednostkowych, bo jest wolniejsza i płatna. W następnej lekcji zajmiemy się dokumentacją.
Pamiętaj: testy to próbne przejazdy przez rzekę, zanim poprowadzisz nimi całą ekspedycję.
Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jaki typ testów powinien stanowić największą część piramidy testów?
2. Do czego służą fixtures w pytest?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w pionie
Uporządkuj kroki:
- Edytor kodu
Zaimplementuj test z fixtures i mock
- Klikanie w kolejności
Kliknij w kolejności:
- Układanie w poziomie
Ułóż elementy:
- Edytor kodu
Użyj unittest.mock.patch do mockowania API call.
- Układanie w pionie
Posortuj typy testów od największej do najmniejszej liczby: