Przejdź do treści

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 >= threshold

I 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ęść.