POWRÓT_DO_BLOGA
AI & Automatyzacja 14 min

Jak pisać testy dla aplikacji AI? Piramida testów, złoty dataset i CI/CD dla LLM

Jak zaprojektować suite testów dla aplikacji LLM: testy jednostkowe z mockowaniem, integracyjne ze złotym datasetem, ewaluacja LLM-as-judge i bramka jakości w CI/CD. Z prawdziwym kodem Python i GitHub Actions.

Zmieniasz prompt z "odpowiadaj po polsku" na "zawsze odpowiadaj w języku polskim". Testy przechodzą — były pisane pod starą wersję. Trzy tygodnie później klient dzwoni: "wasza aplikacja zaczęła odpowiadać po angielsku dla części użytkowników." Okazuje się, że aktualizacja modelu zmieniła domyślne zachowanie, a nowy prompt przestał działać dla edge case'ów. Nie miałeś testów regresyjnych, więc nie wiedziałeś.

Testowanie aplikacji AI to inny problem niż testowanie klasycznego kodu. Wyjście LLM nie jest deterministyczne — identyczne wejście może dać różne wyjście przy każdym wywołaniu. Ale da się zaprojektować suite testów, która da ci pewność przed deploymentem.

Dlaczego klasyczne assertEquals nie zadziała dla LLM?

Klasyczne testy sprawdzają równość: assert result == expected. Dla LLM to nie ma sensu — model mówi to samo różnymi słowami za każdym razem.

Trzy fundamentalne różnice: - Niedeterminizm — temperatura > 0 daje różne wyjście przy każdym wywołaniu, nawet przy tych samych danych wejściowych - Semantyka zamiast syntaksy — "Masz 14 dni" i "Okres zwrotu to dwa tygodnie" to ta sama odpowiedź — assertEqual odrzuci obie wersje jako błędne - Brak wyroczni — nie ma jednej "poprawnej" odpowiedzi tak jak w klasycznym algorytmie sortowania czy parsowania

Efekt: testy, które wyglądają jak testy, ale nie łapią prawdziwych regresji.

Co testować, a czego nie?

Testuj: - logikę budowania promptu — deterministyczna, testowalna jak zwykły kod - poprawność parsowania odpowiedzi — czy JSON ma wymagane pola i poprawne typy - format wyjścia — czy wynik mieści się w limicie znaków, ma wymaganą strukturę - kryteria jakości semantycznej — faithfulness, relevance — na próbce 5–10% wywołań - regresję semantyczną względem baseline po każdej aktualizacji modelu

Nie testuj: - samego modelu bazowego — OpenAI, Anthropic i Google testują to za ciebie - identycznej treści słowo w słowo — model zmieni zdanie i test padnie - wydajności modelu na ogólnych benchmarkach — to nie twój kod - wewnętrznych mechanizmów frameworków (LangChain, LlamaIndex) — testujesz ich kod, nie swój

/// PIRAMIDA TESTÓW DLA APLIKACJI AI

Im wyżej, tym wolniej i drożej — ale głębsza ocena semantyczna

Nie testuj tylko jednej warstwy — każda łapie inne klasy błędów

Ewaluacja LLM-as-judge
G-Eval · semantyczna ocena jakości · korelacja 0.85+ z człowiekiem
WOLNE · DROGIE · 5–10% WYWOŁAŃ
Integracyjne — Złoty Dataset
Prawdziwe API · DeepEval / RAGAS · faithfulness + relevance
~$0.01/PR · MINUTY · NA KAŻDY PR Z AI
Jednostkowe — Mock LLM
Deterministyczne · pytest · testuj logikę, nie model
$0 · <5s · NA KAŻDY COMMIT
$0
KOSZT JEDNOSTKOWYCH
~$0.01
KOSZT INTEGRACYJNYCH/PR
~$0.05
KOSZT PEŁNEJ EWALUACJI

Warstwa 1: Testy jednostkowe — mockuj model, testuj kod

Testy jednostkowe dla AI nie wywołują modelu. Testują logikę aplikacji wokół modelu: budowanie promptu, parsowanie odpowiedzi, obsługę błędów. Używasz unittest.mock — framework zwraca predefiniowaną odpowiedź. Testy są szybkie (< 5 sekund), bezpłatne i w pełni deterministyczne.

test_unit.py
# test_unit.pyfrom unittest.mock import patch, MagicMockimport pytestfrom myapp.rag_chain import build_prompt, RAGChaindef test_prompt_zawiera_kontekst():    context = "Polityka zwrotow: 14 dni bez podania przyczyny."    question = "Ile mam czasu na zwrot?"    messages = build_prompt(question, context)    user_content = " ".join(m["content"] for m in messages if m["role"] == "user")    assert "14 dni" in user_content    assert question in user_contentdef test_prompt_ma_role_system():    messages = build_prompt("test?", "ctx")    assert messages[0]["role"] == "system"    assert "asystent" in messages[0]["content"].lower()@patch("myapp.rag_chain.client.chat.completions.create")def test_chain_przekazuje_kontekst(mock_create):    mock_create.return_value = MagicMock(        choices=[MagicMock(message=MagicMock(content="Masz 14 dni na zwrot."))]    )    chain = RAGChain()    wynik = chain.run("Ile mam czasu na zwrot?", context="14 dni")    assert "14 dni" in wynik    msgs = mock_create.call_args[1]["messages"]    assert any("14 dni" in str(m) for m in msgs)@patch("myapp.rag_chain.client.chat.completions.create")def test_chain_blad_gdy_pusty_kontekst(mock_create):    chain = RAGChain()    with pytest.raises(ValueError, match="pusty kontekst"):        chain.run("pytanie", context="")

Uruchom: pytest tests/unit/ -v — zero kosztów API, wynik w < 5 sekund.

Warstwa 2: Testy integracyjne — złoty dataset jako wyrocznia

Testy integracyjne wywołują prawdziwy model z zestawem złotych przypadków testowych. Złoty dataset to kolekcja przykładów: wejście → kryterium jakości — stworzona raz, wersjonowana w repozytorium, poszerzana po każdym błędzie produkcyjnym.

Jak zbudować złoty dataset?

  1. 1.Zbierz 30–100 reprezentatywnych pytań — z logów produkcyjnych lub od ekspertów domenowych; im więcej, tym większe pokrycie
  2. 2.Dodaj edge case'y — pytania poza zakresem, dwuznaczności, pytania po angielsku, ekstremalnie długie konteksty
  3. 3.Zdefiniuj kryterium, nie dokładną odpowiedź — "musi zawierać liczbę 14", "nie może zawierać słowa 'przepraszam'", threshold faithfulness ≥ 0.80
  4. 4.Wersjonuj jak kod — przechowuj w tests/fixtures/golden.json, każda zmiana wymaga code review i komentarza dlaczego
  5. 5.Poszerzaj po każdym błędzie — nowy błąd produkcyjny staje się nowym przypadkiem testowym; dataset staje się z czasem coraz trafniejszy
test_integration.py
# test_integration.pyimport json, pytestfrom deepeval import assert_testfrom deepeval.test_case import LLMTestCasefrom deepeval.metrics import AnswerRelevancyMetric, FaithfulnessMetricfrom myapp.rag_chain import RAGChain@pytest.fixture(scope="session")def chain():    return RAGChain()@pytest.mark.parametrize("case", [    {"q": "Ile mam dni na zwrot?", "ctx": "Zwroty: 14 dni.", "exp": "14"},    {"q": "Czy moge zwrocic uzywany produkt?", "ctx": "Tylko nowe produkty.", "exp": "nowy"},    {"q": "Koszt wysylki zwrotu?", "ctx": "Zwrot gratis powyzej 200 PLN.", "exp": "200"},])def test_golden_set(case, chain):    odpowiedz = chain.run(case["q"], context=case["ctx"])    test_case = LLMTestCase(        input=case["q"],        actual_output=odpowiedz,        retrieval_context=[case["ctx"]]    )    assert_test(test_case, [        AnswerRelevancyMetric(threshold=0.7),        FaithfulnessMetric(threshold=0.8)    ])    assert case["exp"].lower() in odpowiedz.lower()

Koszt: 100 przypadków × 500 tokenów × $0.15/1M = ~$0.008 za jedno uruchomienie.

Warstwa 3: Ewaluacja — gdy kryterium to "dobry", a nie "poprawny"

Dla złożonych wyjść (podsumowania, generowanie treści, konwersacje wieloturowe) używamy LLM-as-judge: drugi model ocenia jakość wyjścia według kryteriów. DeepEval implementuje G-Eval — metodę osiągającą korelację 0.85+ z ludzką oceną.

Ważna zasada: judge powinien być z innej rodziny modeli niż model oceniany — jeśli produkcja używa GPT-4o, judge powinien być Claudem lub odwrotnie. Eliminuje bias self-enhancement (ten sam model ocenia własne wyjście o 10–25% wyżej niż powinien).

test_eval.py
# test_eval.pyimport pytestfrom deepeval import assert_testfrom deepeval.test_case import LLMTestCase, LLMTestCaseParamsfrom deepeval.metrics import GEvalfrom myapp.summarizer import summarize_documentPOPRAWNOSC = GEval(    name="Poprawnosc",    criteria="Czy podsumowanie jest faktycznie zgodne z dokumentem i nie zawiera informacji spoza niego?",    evaluation_params=[LLMTestCaseParams.INPUT, LLMTestCaseParams.ACTUAL_OUTPUT],    threshold=0.7)ZWIEZLOSC = GEval(    name="Zwiezlosc",    criteria="Czy podsumowanie jest zwiezle i nie powtarza tych samych informacji wielokrotnie?",    evaluation_params=[LLMTestCaseParams.ACTUAL_OUTPUT],    threshold=0.6)@pytest.mark.parametrize("doc,min_len,max_len", [    ("Raport Q1: przychody wzrosly o 15%. Koszty stabilne. Marza 32%.", 20, 200),    ("Polityka zwrotow: 14 dni, tylko nowe produkty, koszt po stronie klienta.", 15, 150),])def test_summarizer_quality(doc, min_len, max_len):    summary = summarize_document(doc)    assert min_len <= len(summary) <= max_len    assert_test(        LLMTestCase(input=doc, actual_output=summary),        [POPRAWNOSC, ZWIEZLOSC]    )

/// CI/CD Z BRAMKĄ JAKOŚCI AI

Unit na każdym commicie — ewaluacja tylko przy zmianie promptów

paths: [myapp/prompts/**, myapp/chain.py, tests/fixtures/**]

01
Commit / PR
każdy push
02
Unit Tests
<5s · $0
03
Smoke Eval
5 cases · tylko AI
04
Full Eval
Golden set + LLM-judge
05
Bramka ≥ 0.80
Blokada merge
06
Deploy
Produkcja
<5s
UNIT TESTS NA KAŻDYM PR
~5 min
PEŁNA EWALUACJA NA MERGE
~$0.05
KOSZT EWALUACJI/MERGE

Jak skonfigurować CI/CD — bramka jakości przed deploymentem?

Cel: każdy PR zmieniający prompty lub logikę AI musi przejść przez ocenę jakości. Jeśli wyniki spadną poniżej progu — merge zablokowany automatycznie.

Dwa etapy w pipeline: - unit tests — uruchamiane na każdym PR (< 30 sekund, $0 kosztu API) - eval gate — uruchamiany tylko gdy zmienione pliki pasują do ścieżek AI (kilka minut, ~$0.01)

.github/workflows/ai-eval.yml
name: AI Evaluation Gateon:  pull_request:    paths:      - 'myapp/prompts/**'      - 'myapp/chain.py'      - 'tests/fixtures/**'jobs:  unit-tests:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: actions/setup-python@v5        with: {python-version: '3.12'}      - run: pip install -r requirements.txt      - run: pytest tests/unit/ -v --tb=short  eval-gate:    needs: unit-tests    runs-on: ubuntu-latest    env:      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}    steps:      - uses: actions/checkout@v4      - uses: actions/setup-python@v5        with: {python-version: '3.12'}      - run: pip install -r requirements.txt deepeval      - run: deepeval test run tests/integration/ --min-success-rate 0.85      - run: python tests/regression/check_baseline.py --threshold 0.80

Wyniki ewaluacji pojawiają się w PR comments — developer widzi które metryki padły bez wchodzenia w logi CI.

Jak wykryć regresję po aktualizacji modelu?

OpenAI i Anthropic aktualizują modele bez ostrzeżenia. Żeby wiedzieć, że coś się zmieniło, potrzebujesz baseline — zapisanych wyników ewaluacji na złotym datasecie z daną wersją modelu.

  1. 1.Po każdym deploymencie zapisz wyniki jako baseline_vN.json z datą i wersją modelu
  2. 2.Po każdej podejrzanej aktualizacji modelu uruchom eval ponownie i porównaj z baseline
  3. 3.Alert gdy faithfulness lub relevance spada o > 5 punktów procentowych
  4. 4.Jeśli regresja potwierdzona — rollback do poprzedniej wersji promptu lub zablokuj update
MetrykaBaseline (maj)Po aktualizacjiZmianaAkcja
Faithfulness0.870.79−8%Alert + analiza promptu
Answer Relevancy0.820.81−1%OK — mieści się w wariancji
Hallucination rate3.2%4.1%+0.9%Obserwuj przez tydzień
Latency p952.1s2.3s+10%OK
Koszt/1k wywołań$0.024$0.026+8%OK

Narzędzia — co wybrać i do czego?

NarzędzieTypDo czegoCena
DeepEvalPython SDKPełna piramida: unit + integracyjne + G-Eval + CIDarmowy / $29+
RAGASPython SDKRAG pipelines: faithfulness, context recall, precisionDarmowy
PromptFooCLI/YAMLPorównanie wersji promptów, red teaming, A/BDarmowy / $20+
EvidentlyPython + ActionRegresja jakości, dashboardy, GitHub Actions wrapperDarmowy / $95+
LangSmithSaaSLangChain + eval + tracing w jednym miejscuDarmowy / $39+

Typowe błędy — czego nie robić

  • Testowanie modelu bazowego zamiast własnego kodu — sprawdzasz inteligencję GPT-4, nie swój kod. Testuj tylko logikę, którą sam napisałeś
  • Jeden wielki test zamiast piramidy — jeśli każdy test wywołuje LLM-as-judge, płacisz $0.05 za każdy commit i CI trwa 20 minut
  • Złoty dataset bez edge case'ów — 50 przypadków "happy path" wykryje 30% błędów. Dodaj 20% trudnych pytań i 20% przypadków spoza zakresu
  • Brak wersjonowania datasetu — zmieniony golden.json bez code review to gotowy przepis na fałszywie pozytywne testy
  • Porównanie dokładnej treściassert result == "14 dni" padnie gdy model odpowie "czternaście dni". Używaj assert "14" in result lub DeepEval metrics
  • Ignorowanie kosztu ewaluacji — 1000 przypadków × LLM-as-judge = $1–5 za uruchomienie. Planuj budżet, uruchamiaj pełną ewaluację na merge, nie na każdy commit

Lista kontrolna

  1. 1.Testy jednostkowe mockują LLM — zero kosztów API, deterministyczne, uruchamiane na każdym commicie
  2. 2.Złoty dataset: 30–100 przypadków, w tym edge case'y i błędy produkcyjne, wersjonowany jak kod
  3. 3.Testy integracyjne używają DeepEval lub RAGAS z progami — threshold ≥ 0.75 dla faithfulness
  4. 4.Ewaluacja G-Eval dla złożonych wyjść — judge z innej rodziny modeli niż produkcja
  5. 5.Baseline po każdym deploymencie, alert gdy metryki odchylą się o > 5 punktów procentowych
  6. 6.CI/CD: unit na każdym PR, eval gate tylko przy zmianach AI — paths filter w GitHub Actions
  7. 7.Koszt ewaluacji monitorowany i planowany: < $0.05 za merge do main
  8. 8.Wyniki widoczne w PR comments — nie wymagają przeszukiwania logów CI

---

Buduję systemy testowania dla aplikacji AI — od piramidy testów jednostkowych przez złoty dataset po CI/CD z bramką jakości. Jeśli twoja aplikacja LLM jest w produkcji bez suite testów, napisz do mnie — zaczynam od audytu kodu i zaprojektowania minimalnej sensownej piramidy.

/// AUTHOR
Paweł Wiszniewski – AI & Web Engineer

Paweł Wiszniewski

Senior Full-Stack Engineer & AI Architect

8+ lat doświadczenia. Buduję systemy AI, automatyzacje i aplikacje webowe, które redukują koszty i zwiększają efektywność operacyjną firm.

Signal received?

Przerwij
Ciszę

Zainicjuj protokół. Nawiąż połączenie. Zbudujmy coś głośnego.

> OCZEKIWANIE_NA_SYGNAŁ...