Co testować i gdzie – piramida testów w praktyce, bez religii pokrycia
W pierwszej części opowiedziałem, jak przez ćwierć wieku dowiadywałem się o błędach od produkcji i dlaczego dziś piszę testy przed kodem. Ta część jest o pytaniu, które przychodzi zaraz potem: skoro testować, to co dokładnie i gdzie? Bo „pisz testy” to rada tej samej wartości, co „jedz zdrowo” – prawdziwa i bezużyteczna, dopóki nie padnie konkret.
Piramida, ale po ludzku
Klasyczna odpowiedź nazywa się piramidą testów i zwykle jest rysowana, a nie tłumaczona. Spróbuję słowami, bo istota jest prosta – to trzy poziomy różniące się jednym: ile świata bierze udział w teście.
- Testy jednostkowe sprawdzają pojedynczą funkcję lub klasę w izolacji. Żadnej bazy, sieci, plików. Wykonują się w milisekundach, więc może ich być dużo i mogą biec po każdej zmianie.
- Testy integracyjne sprawdzają, czy elementy działają razem: widok z prawdziwym zapytaniem do bazy, serializacja z prawdziwym modelem. Wolniejsze, więc jest ich mniej.
- Testy przepływów (E2E) uruchamiają całą aplikację i przechodzą przez nią jak człowiek: logowanie, klik, formularz, wynik. Najdroższe i najwolniejsze, więc jest ich najmniej – ale tylko one widzą aplikację w całości.
Kształt piramidy to nie estetyka, tylko ekonomia: im wyżej, tym test jest droższy w utrzymaniu i wolniejszy w wykonaniu, więc wyższych poziomów ma być mniej. Odwrócona piramida – mnóstwo testów klikających i garstka jednostkowych – daje suitę, która chodzi kwadrans i sypie się od zmiany koloru przycisku.
Poziom pierwszy: jednostkowe, czyli pytest
Konkret zamiast teorii. Funkcja, jakich wiele w każdej aplikacji – sprawdzenie, czy wynik przekracza próg zaliczenia:
# Python
def threshold_reached(correct: int, total: int, threshold: float = 0.5) -> bool:
"""Czy odsetek poprawnych odpowiedzi osiąga próg zaliczenia."""
if total <= 0:
raise ValueError("liczba pytań musi być dodatnia")
return correct / total >= thresholdI testy do niej – pisane, zanim powstało ciało funkcji:
# Python (pytest)
import pytest
@pytest.mark.parametrize("correct, total, expected", [
(10, 20, True), # dokładnie na progu – zalicza
(9, 20, False), # tuż pod progiem
(0, 20, False), # skrajność: zero poprawnych
(20, 20, True), # skrajność: komplet
])
def test_threshold(correct, total, expected):
assert threshold_reached(correct, total) is expected
def test_zero_questions_raises():
# zero pytań to błąd danych, nie wynik do policzenia
with pytest.raises(ValueError):
threshold_reached(0, 0)Dwie rzeczy są tu ważniejsze niż składnia. Po pierwsze, każdy wiersz parametrów to nazwany przypadek, z granicą progu na czele – bo błędy mieszkają na granicach, nie w środku przedziałów. Po drugie, przypadek total == 0 w ogóle by nie powstał, gdybym pisał test do gotowej funkcji: implementacja by o nim milczała, więc test też. Pisząc test najpierw, myślałem o wymaganiach – i pytanie „a co, gdy pytań nie ma?” zadało się samo.
Poziom drugi: integracyjne, czyli widok z prawdziwą bazą
Funkcja policzy dobrze, ale czy widok ją w ogóle woła? Czy zapytanie zwraca to, co myślę? Tu wchodzą testy Django z klientem testowym i bazą tworzoną na czas testu:
# Python (Django)
from django.test import TestCase
class ExamListViewTests(TestCase):
def test_hides_unpublished_exams(self):
# niepublikowany egzamin nie może wyciec na listę
Exam.objects.create(name="INF.02", published=True)
Exam.objects.create(name="INF.99", published=False)
response = self.client.get("/egzaminy/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "INF.02")
self.assertNotContains(response, "INF.99")Ten test nie sprawdza żadnej pojedynczej funkcji – sprawdza umowę: routing, widok, zapytanie i szablon dają razem właściwy wynik. To jest dokładnie ta kategoria błędów, która w mojej epoce ręcznego klikania kończyła się wariantem „nikt nie zgłosił”: każdy element z osobna działał, całość nie.
Skąd naprawdę biorą się luki – i co mierzy pokrycie
Narzędzia takie jak coverage.py mierzą, które linie kodu wykonały się podczas testów. I tu czai się najczęstsze nieporozumienie: pokrycie linii to nie pokrycie przypadków. Spójrz jeszcze raz na threshold_reached – wystarczy jeden test z happy path, żeby każda linia tej funkcji się wykonała. Pokrycie: sto procent. A granica progu, zero pytań, zaokrąglenia przy dzieleniu – niesprawdzone. Linia była wykonana, ale nie była sprawdzona na tym, co w niej groźne.
Dlatego traktuję pokrycie jak mapę, nie jak wynik do wymaksowania. Niskie pokrycie modułu to informacja: tu nikt niczego nie pilnuje. Wysokie pokrycie nie jest informacją o niczym – mówi tylko, że kod się wykonywał. Pogoń za setką na liczniku produkuje testy pisane pod licznik: wykonują linie, nie sprawdzają zachowań. Z pierwszej części wiadomo, że luki i tak powstaną – tam, gdzie nie starczyło głowy. Pokrycie pomaga je znaleźć, nie udowodnić, że ich nie ma.
Testy pilnują nie tylko kodu
Najciekawsze zastosowanie testów odkryłem późno: asercje można postawić na rzeczach, które nie są kodem aplikacji.
- Ten blog ma testy treści. Wpisy publikuję seriami, dzień po dniu, a części czekające w kolejce są draftami. Test przechodzi po wszystkich opublikowanych wpisach i sprawdza, że żaden nie linkuje do wpisu, którego jeszcze nie ma – bo czytelnik dostałby 404. Drugi pilnuje literówek w linkach wewnętrznych.
- Blog testuje też liczby w artykułach. W serii o algorytmach sortowania cytuję konkretne liczby porównań i zapisów z animacji. Test uruchamia te same algorytmy i sprawdza, że liczby w tekście zgadzają się z kodem – gdy zmienię implementację, test przypomni, że treść wpisu właśnie się zdezaktualizowała.
- Testy jako zapora przed nawrotem nawyku. Przy wyprowadzaniu Bootstrapa z zawodowe.edu.pl najważniejsze testy nie sprawdzały, czy coś działa – sprawdzały, czy w przepisanych szablonach nie pojawia się klasa Bootstrapa ani
data-bs-. Pilnowały decyzji, nie funkcji.
Wspólny mianownik: test to sposób na zamianę dowolnej reguły „u nas robi się tak” w coś, co samo się egzekwuje. Reguły spisane w dokumentacji miękną z czasem. Reguły spisane w asercjach – nie.
Sekcja uczciwości: czego nie testuję
- Wyglądu. Czy przycisk jest ładny i czy odstęp jest właściwy – ocenia człowiek. Testy porównujące zrzuty ekranu piksel po pikselu u mnie nie mieszkają: sypią się od zmiany fontu i uczą ignorować czerwony kolor.
- Cudzego kodu. Nie testuję, czy framework robi to, co obiecuje w dokumentacji – testuję swoją logikę i swoje umowy z nim.
- Rzeczy jednorazowych. Skrypt migracji uruchamiany raz dostaje kontrolę wyniku, nie suitę.
I zasada nadrzędna: test niestabilny jest gorszy niż brak testu. Suita, która czasem świeci na czerwono bez powodu, uczy jednej rzeczy – klikać „uruchom ponownie” bez czytania. A wtedy pewnego dnia zignoruje się czerwień, która była prawdziwa.
Puenta
Piramida odpowiada na „gdzie”: dużo szybkich testów jednostkowych na dole, mniej integracyjnych w środku, garść przepływów na górze. Na „co” odpowiada prostsza reguła: testuj granice i umowy, nie linie; pilnuj asercjami każdej reguły, którą dziś pilnujesz siłą woli. A pokrycie niech zostanie tym, czym jest – mapą dziur, nie trofeum.
Została góra piramidy. Testom przepływów – tym, które klikają w aplikację jak człowiek, i temu, czemu przestały być udręką – poświęcam następną część.