Testy charakterystyczne (characterization tests) to technika pracy ze starym kodem, którą Michael Feathers opisał w książce „Working Effectively with Legacy Code". Zamiast zakładać, jak system powinien działać, opisujesz testami, jak działa obecnie — łącznie z jego błędami i dziwnymi zachowaniami. Dzięki temu zyskujesz siatkę bezpieczeństwa, która pozwala refaktoryzować kod bez ryzyka, że „naprawa" zepsuje coś, co biznes uważa za działające.
Najważniejsze: Test charakterystyczny nie ocenia, czy kod jest poprawny — dokumentuje, co kod faktycznie robi. To kontrakt na bieżące zachowanie systemu. Dopiero mając taki kontrakt, możesz bezpiecznie refaktoryzować legacy code. Kolejność zawsze ta sama: najpierw test, potem zmiana.
Problem, który rozwiązują testy charakterystyczne
W nowoczesnym projekcie piszesz test przed funkcją: wiesz, co ma robić, więc opisujesz to testem. W starym kodzie jest odwrotnie — nie wiesz, co kod robi, a nikt już tego nie pamięta. Dokumentacja (jeśli istnieje) kłamie, komentarze są sprzed lat, a jedynym źródłem prawdy jest działający system.
W takiej sytuacji klasyczne podejście „napisz testy jednostkowe, potem refaktoryzuj" nie działa, bo nie masz od czego zacząć. Testy charakterystyczne odwracają kierunek: zamiast testować to, co kod powinien robić, utrwalasz to, co robi. Nawet jeśli utrwalasz błąd — to też jest wiedza. Cel nie brzmi „kod ma być poprawny", lecz „kod ma się nie zmienić, dopóki świadomie nie zdecyduję inaczej".
To fundament bezpiecznej modernizacji. Cały plan działań — od audytu po CI/CD — opisuję w przewodniku o modernizacji legacy code, a techniki samej refaktoryzacji w artykule o refaktoryzacji legacy code.
Jak napisać test charakterystyczny — krok po kroku
Proces dla pojedynczego fragmentu kodu wygląda tak
- Wybierz zachowanie do utrwalenia — funkcję, metodę, endpoint. Zacznij od ścieżki krytycznej dla biznesu, nie od najłatwiejszej.
- Ustal wejście — przygotuj dane, które w realnym systemie uruchamiają ten kod. W legacy to często najtrudniejszy etap: dane rozsiane po wielu tabelach i zależnościach.
- Uruchom i zapisz wynik — wywołaj kod i zobacz, co zwraca. Nie zgaduj, nie poprawiaj, nie „ulepszaj". Zapisz wynik dokładnie taki, jaki jest.
- Zapisz wynik jako asercję — test przechodzi, dopóki kod zwraca dokładnie to, co zwracał. To twój kontrakt.
- Powtórz dla przypadków brzegowych — puste dane, brak rekordu, błędne wejście. Zachowania błędów też są zachowaniem, które refaktoryzacja mogłaby zmienić.
Pierwsze uruchomienie zwykle kończy się odkryciem, że kod robi coś zupełnie innego, niż sugeruje jego nazwa. To nie porażka — to najcenniejsza informacja z całego procesu.
Testy charakterystyczne vs testy regresji
Oba rodzaje testów chronią przed niechcianymi zmianami, ale pełnią różne role w procesie
| Aspekt | Test charakterystyczny | Test regresji |
|---|---|---|
| Cel | Utrwalenie obecnego zachowania | Ochrona przed ponownym błędem |
| Moment pisania | Przed refaktoryzacją | Po wykryciu i naprawie błędu |
| Ocena poprawności | Nie ocenia — dokumentuje | Zakłada poprawność naprawy |
| Trwałość | Część wygaszana po refaktoryzacji | Trwała — zostaje na stałe |
W praktyce oba typy żyją razem. Testy charakterystyczne piszesz masowo przed modernizacją modułu, a testy regresji dopisujesz na bieżąco przy każdym błędzie zgłoszonym z produkcji. Z czasem część testów charakterystycznych „awansuje" — gdy zweryfikujesz, że utrwalone zachowanie jest poprawne, test staje się zwykłym testem regresji. Jeśli kod Laravel, szerszy warsztat testowania (PHPUnit, Pest, testy end-to-end) opisuję w artykule o testach automatycznych w Laravel.
Przykład z PHP — utrwalanie zachowania kalkulatora rabatów
Najprostszy przykład to funkcja z logiką biznesową, która „nikt nie wie, jak dokładnie działa". Wyobraź sobie metodę liczącą rabat, w której przez lata narosły warunki: rabat zależy od kwoty, kategorii klienta i dnia tygodnia, a jeden z warunków wygląda na błąd. Test charakterystyczny wygląda tu tak:
public function test_rabat_dla_klienta_b2b_przy_kwocie_10k(): void
{
$calculator = new LegacyDiscountCalculator();
// Zachowanie zaobserwowane na produkcji 2026-10-01
$rabat = $calculator->calculate(10000.0, 'b2b', '2026-10-01');
$this->assertSame(0.17, $rabat);
}
Nazwa mówi wszystko — to nie jest test „jaki rabat powinien być", lecz „jaki rabat system daje dzisiaj". Jeśli w refaktoryzacji okaże się, że 0.17 to skutek błędu, decyzja o jego naprawie będzie świadoma i będzie miała osobny test. Do tego czasu każda zmiana struktury kodu musi utrzymać 0.17.
W większych fragmentach, gdzie wyniki są złożone, stosuje się wariant zwany golden master — przechwytujesz pełne wyjście systemu (np. wygenerowany dokument, odpowiedź endpointu) i porównujesz je bajt po bajcie z zapisanym wzorcem.
Golden master — gdy wyniki są zbyt złożone na asercje
Dla fragmentów, których wynikiem jest dokument, e-mail, PDF albo rozbudowany JSON, ręczne pisanie asercji jest niepraktyczne. Golden master działa inaczej: uruchamiasz kod na reprezentatywnym zbiorze danych wejściowych, zapisujesz pełne wyniki jako pliki wzorcowe, a test porównuje bieżące wyjście z wzorcem.
Zaleta jest oczywista — pokrycie całego zachowania bez opisywania każdego pola. Ryzyko też: wzorzec trzeba przejrzeć i świadomie zaakceptować, a przy każdej legalnej zmianie zachowania aktualizować. Golden master sprawdza się najlepiej w modułach, które są zamrożone na czas refaktoryzacji — dokładnie tam, gdzie testy charakterystyczne mają największą wartość.
Kiedy testy charakterystyczne się nie opłacają
Technika ma granice. Nie ma sensu utrwalać zachowania, które
-
i tak zostanie zmienione w ramach tej samej modernizacji — test tylko przeszkodzi,
-
jest wynikiem niedziałającej funkcji, którą biznes świadomie wyłącza,
-
zależy od zewnętrznych systemów, których nie kontrolujesz (tu lepiej izolować przez mocki i testować logikę własną),
-
dotyczy modułu, który zostanie przepisany od zera bez zachowania kompatybilności.
-
Zasada jest prosta: utrwalasz zachowanie, które chcesz zachować. Decyzję o tym, co ma przetrwać modernizację, podejmujesz z biznesem przed napisaniem pierwszego testu.
Od testów do refaktoryzacji
Testy charakterystyczne to pierwszy krok procesu modernizacji, nie cel sam w sobie. Ich wartość materializuje się w momencie, gdy zaczynasz zmieniać kod: refaktoryzujesz funkcję, uruchamiasz testy, widzisz zielone — wdrażasz. Bez tego kroku każda zmiana w legacy kodzie to zakład, a nie inżynieria. Kolejne etapy — wzorce refaktoryzacji, modernizacja modułowa, CI/CD — opisuję w planie modernizacji legacy code.
Najczęściej zadawane pytania
Czym różni się test charakterystyczny od zwykłego testu jednostkowego?
Test jednostkowy weryfikuje, czy kod działa zgodnie z wymaganiami. Test charakterystyczny dokumentuje, jak kod działa obecnie — bez oceny, czy to zgodne z wymaganiami. Pisze się go wtedy, gdy wymagania są nieznane albo rozjechane z rzeczywistością, czyli głównie w starych systemach.
Czy testy charakterystyczne utrwalają błędy?
Tak, i jest to świadoma decyzja. Utrwalony błąd staje się udokumentowanym zachowaniem, które można naprawić w kontrolowany sposób — z osobnym testem i decyzją biznesową. Alternatywa, czyli naprawianie błędów przy okazji refaktoryzacji, prowadzi do zmian, których nikt nie potrafi zweryfikować.
Ile testów charakterystycznych trzeba napisać?
Tyle, żeby refaktoryzowany moduł był bezpieczny — w praktyce pokrycie ścieżek krytycznych i przypadków brzegowych, zwykle kilkanaście do kilkudziesięciu testów na moduł. Nie chodzi o pokrycie stuprocentowe, lecz o pokrycie tego, co może się zepsuć i co da się zauważyć.
Czy testy charakterystyczne zostają w projekcie na stałe?
Część tak. Testy utrwalające zachowanie, które okazało się poprawne, stają się zwykłymi testami regresji i zostają na stałe. Testy utrwalające zachowanie tymczasowe albo błędy wygaszasz, gdy moduł zostanie zmodernizowany i zweryfikowany — po to, żeby nie blokowały przyszłych, świadomych zmian.