Testy mutacyjne: czy testom pisanym przez AI można ufać?
Test mutacyjny psuje Twój kod w jednym miejscu i sprawdza, czy któryś test to zauważył. Pokrycie kodu mierzy wykonanie. Wynik mutacyjny mierzy asercję. Odkąd testy pisze model, przestała to być różnica akademicka.
Pokrycie kodu straciło znaczenie wtedy, kiedy stało się darmowe. Agent dopisze dwieście przypadków w minutę. Pokrycie skacze do dziewięćdziesięciu procent, CI świeci na zielono. Żadna z tych trzech rzeczy nie odpowiada na jedyne sensowne pytanie: gdyby ten kod był zły, czy pękłaby jakakolwiek asercja. Testowanie mutacyjne odpowiada na nie mechanicznie. Jako jedyny pomiar jakości testów nie potaniał razem z pisaniem testów.
Jak działa test mutacyjny
Testy mutacyjne to technika oceny jakości zestawu testów: narzędzie wprowadza do kodu produkcyjnego jedną drobną, celową zmianę i sprawdza, czy któryś test ją wychwyci. Bierzesz jeden plik produkcyjny i zmieniasz w nim jedną rzecz. Znak większe lub równe na większe. Koniunkcję na alternatywę. Warunek na prawdę. Krok pętli w drugą stronę. Całą gałąź warunku wyrzucasz. Tak powstała wersja to mutant. Puszczasz na niej te testy, które miały tego pilnować.
- Któryś test się wywalił, więc mutant jest zabity. Testy tę decyzję trzymają.
- Wszystko przeszło na zielono z popsutym kodem, więc mutant przeżył. To jest znalezisko. Masz linię, którą testy wykonują i o której nie mówią nic.
Powtarzasz to kilkaset razy na plik. Wynik mutacyjny to udział zabitych w tym, co dało się osądzić.
Cała idea mieści się w jednym zdaniu. Zwykły test sprawdza kod. Test mutacyjny sprawdza test.
Pomysł ma pół wieku i stoi na dwóch założeniach. Oba warto znać, zanim uwierzysz w liczbę. Pierwsze: programiści piszą kod prawie poprawny, więc prawdziwe błędy są małymi odchyleniami od dobrej wersji, a nie losowym szumem. Drugie: zestaw testów, który łapie te drobne, jednopunktowe usterki, złapie też złożone. Nowe jest tylko to, że technika wreszcie się opłaca, a powód nie ma z nią samą nic wspólnego.
Czego nie powie Ci pokrycie kodu
Pokrycie mówi, czy linia się wykonała. Mutacja mówi, czy ktokolwiek cokolwiek o niej twierdzi. To dwie różne wielkości i liczy się druga.
Test, który woła funkcję i nie sprawdza wyniku, daje sto procent pokrycia. Test, który podstawia wszystkie zależności i asertuje na podstawce, daje sto procent pokrycia. Test, który zapisał aktualny wynik jako oczekiwany, daje sto procent pokrycia. Ostatni świeci na zielono także wtedy, kiedy ten wynik jest zły.
To nie jest niszowa obserwacja. W kwietniu 2026 Thoughtworks wpisał testowanie mutacyjne do pierścienia Trial w swoim Technology Radar. Uzasadnienie brzmi tak: wysokie pokrycie potrafi maskować testy puste logicznie albo wygenerowany kod, o którym nigdy niczego sensownie nie stwierdzono.
AI w testowaniu oprogramowania: co się zmienia, kiedy testy pisze model
Cztery rzeczy naraz i tylko pierwsza jest oczywista.
Pisanie testów staniało, ocena testów nie. Dwieście wygenerowanych przypadków ląduje w pull requeście. Czyta je ten sam człowiek co wcześniej, tylko materiału ma dziesięć razy więcej. Więc nie czyta. Wynik mutacyjny skaluje się razem z tempem pisania, bo liczy go maszyna.
Awaria testu pisanego przez model to dokładnie ta, którą wykrywa mutacja. Model piszący test do gotowego kodu ma ten kod w kontekście. Najtańsza droga do zielonego prowadzi przez test, który powtarza implementację. Podstawia zależności i asertuje na podstawce. Przepisuje aktualny wynik do oczekiwanego. Sprawdza, że coś się wywołało, zamiast sprawdzić, co z tego wyszło. Taki test się wykonuje, przechodzi i podnosi pokrycie. Przeszedłby też z odwróconym warunkiem. To jest definicja mutanta, który przeżył.
Nie jest to opinia. Zespół z Luksemburga, ten sam, który od lat siedzi w mutacjach, przejrzał dwadzieścia cztery projekty. Modele piszą asercje opisujące zachowanie aktualne, nie oczekiwane. Ten sam problem miały wcześniej generatory losowe. Kiedy model sam ocenia, czy asercja jest dobra, trafia w czterdziestu kilku procentach. Na kodzie z błędem trafność spada o kolejne osiem do dziewięciu punktów. Model jest więc mocny jako autor i słaby jako recenzent, czyli wpada w tę samą pułapkę, przez którą model nie może być własnym recenzentem.
Widać to również w tym, co agenty commitują. Badanie z tego roku przejrzało ponad milion dwieście tysięcy commitów z 2025 roku w ponad dwóch tysiącach repozytoriów. Wśród commitów agentów, które w ogóle ruszają pliki testowe, mocki dokłada trzydzieści sześć procent. Wśród takich samych commitów pisanych bez agenta dwadzieścia sześć. Autorzy radzą wprost: zasady mockowania zapisz w pliku konfiguracyjnym agenta.
Kiedy agent może zmieniać testy, testy przestają być niezależną miarą. Zadanie brzmi: zrób, żeby CI było zielone. Osłabienie asercji realizuje to zadanie i jest legalnym ruchem. Benchmark opublikowany pod koniec zeszłego roku sprawdził, którędy modele idą po zielone CI. Edycja samego pliku z testami wyszła rzadka: przy zadaniach opisanych jednoznacznie poniżej procenta, a przy tych z arbitralnym oracle’em zero u wszystkich trzech agentów. Jeden z nich skasował za to plik z testami w trzech procentach jednoznacznych przebiegów. Rośnie jednak co innego. Zaspokajanie widocznych przypadków testowych bez rozwiązania problemu skacze z około dwóch procent do dwudziestu dwóch, a u najgorszego agenta do czterdziestu czterech. To dziewięć zadań i sami autorzy nazywają tę próbkę hałaśliwą, ale kierunek jest jednoznaczny. Inna droga, ten sam wniosek: kontrolę, którą agent widzi, można zaspokoić bez rozwiązywania czegokolwiek. Dlatego warto z góry ustalić, czy plik z testami należy do tego, co agent zmienia bez pytania.
Wynik mutacyjny ma tu własność, której pokrycie nie ma. Wywal asercję, a pokrycie zostanie takie samo. Wynik mutacyjny spadnie. Miara musi leżeć poza zasięgiem tego, kogo mierzy, i to jest ta sama zasada, która każe iterować agentem wyłącznie względem miary, której on sam nie może przesunąć.
Rośnie udział kodu, który pilnuje modelu. Reguły zaufania, walidacja odpowiedzi, parsowanie, fallbacki, ponowienia, limity. Ten kod chodzi na rzadkiej ścieżce. Testy pisze się na częstej. To więc dokładnie ta część systemu, o której zielony zestaw testów nie mówi nic.
Co znalazłem w produkcyjnym kodzie
Tyle kontekstu, ile mogę podać. Produkcyjna aplikacja B2B, w której model wyciąga wartości z dokumentów wejściowych, a kod dookoła niego decyduje, czy im zaufać. Zestaw testów: ponad tysiąc przykładów, zero błędów, zielony od dawna.
Harness napisałem od zera. Gotowy odpadł z trzech niezależnych powodów naraz. Wymaga nowszej wersji języka niż ta, na której stoi aplikacja. Podbija bibliotekę parsera dzieloną z linterem, więc ceną za narzędzie do testów byłby diff na całym lintowaniu. I jest komercyjny dla repozytoriów prywatnych. Szesnaście operatorów, dobranych pod klasy błędów, które w tym typie kodu kosztują najwięcej. Granice i pomyłki o jeden. Połknięte wyjątki. Modyfikacja wspólnego stanu w miejscu.
Pierwszy audyt jednego pliku: 486 mutantów. 235 zabitych, 204 ocalałych, 44 na liniach, których żaden wybrany test nie wykonuje. Procentu jeszcze nie podaję, bo to nie on jest znaleziskiem. Wyszły z tego trzy rzeczy i tylko jedna z nich to „dopisz test”.
Prawdziwy błąd
Jedna funkcja decyduje, którą wartość przyjąć: tę od modelu czy tę wyciągniętą z dokumentu. Miała dwie reguły zaufania i obie patrzyły wyłącznie na kształt oraz długość tekstu. Żadna nie pytała, czy ten tekst ma cokolwiek wspólnego z miejscem, z którego rzekomo pochodzi. Krótka, ładnie sformatowana, całkowicie zmyślona wartość wygrywała. Nic dalej jej nie weryfikowało. Sąsiedni test omijał tę ścieżkę tylko dlatego, że jego dwa napisy przypadkiem miały tę samą długość.
To znalezisko o projekcie, nie o testach. Reguła zaufania sprawdzała format wartości, a nie jej pochodzenie, a to są dwie różne rzeczy. Zabezpieczenie istniało, ktoś je napisał i przepuścił przez review. Nikt go nigdy nie sprawdził, czyli dokładnie tak wygląda awarię, która nie zapala żadnej lampki. Poprawka trzyma się jednej reguły. Wartość bez ani jednego wspólnego tokenu ze źródłem, które odtwarza nasza własna ekstrakcja, nie mogła zostać z tego źródła odczytana. Regułę sprawdziłem na pełnym zbiorze historycznym, nie na próbce.
Martwy kod
W całym pierwszym audycie, już nie na jednym pliku, 106 mutantów siedziało na liniach, których nie wykonuje żaden test. Wszystkie w czterech metodach. Żadna z tych metod nie ma w repozytorium wywołania. Przyjechały razem w jednym imporcie i nikt ich potem nie ruszył. Wynik: 214 skasowanych linii i jedna decyzja zamiast 106 ticketów.
Świeży kod ze świeżymi testami
Trzecia rzecz jest najbardziej niewygodna. Puściłem harness na jego własnym pliku operatorów. Kilka godzin wcześniej ten plik dostał cztery nowe operatory i 101 linii nowych testów, w tym samym commicie. 97 mutantów, 87 zabitych, 90,6 procent. Pięć z dziewięciu ocalałych siedziało w kodzie napisanym tego samego dnia, razem z tymi testami. Jeden pokazał martwe zabezpieczenie przed nullem tam, gdzie null nie wystąpi. Drugi trafił w warunek, do którego nie dotarł żaden przykład.
Dziewięćdziesiąt procent to dobry wynik. Dziewięć niepilnowanych decyzji w pliku, który właśnie przeszedł pełne review i dostał komplet świeżych testów, to też fakt.
To robię na co dzień: doradzam przy strategii AI i buduję agentów, którzy przeżywają demo.
Wynik mutacyjny kłamie bez kubełka „nie wiem”
Tu leży różnica między pomiarem a dashboardem. Tę część pomija się najczęściej.
Mój harness rozróżnia osiem wyników na mutanta. Tylko dwa z nich mówią cokolwiek o testach: zabity i ocalały. Pozostałe sześć to harness przyznający, że werdyktu nie ma. Mutant się zawiesił. Mutant wysadził proces. Żaden wybrany test nie wykonuje tej linii. Plik nie nadaje się do audytu. Mutant się nie sparsował. Proces potomny umarł, zanim harness zdążył wgrać mutanta.
Wrzuć tę szóstkę do mianownika, a dostaniesz liczbę wyższą i bezużyteczną, czyli kolejną liczbę, która wygląda na dowód, a nim nie jest. Rozdziel je, a dostaniesz coś, na czym da się pracować. Na jednym pliku 47 z 54 pozycji wyglądających na ocalałe okazało się liniami, których nikt nie wykonuje, w trzech metodach, których nikt nie woła. Zlepione czytają się jako 54 tickety i wynik 27 procent. Rozdzielone czytają się jako trzy znaleziska i wynik 74 procent tego, do czego testy w ogóle docierają. Druga wersja jest prawdziwa. Pierwsza brzmi groźniej.
Ta sama zasada rozdziela dwie różne prace. Ocalały mutant to test, który coś wykonuje i o tym milczy, więc poprawką jest mocniejsza asercja w teście, który już istnieje. Mutant na linii bez pokrycia to brak testu, więc poprawką jest test, którego nie ma. Dwie różne prace, dwa różne budżety.
I jedna rzecz, bez której cała reszta jest zgadywaniem. Zanim policzysz cokolwiek, uruchom testy na niezmutowanym pliku. Jeśli to nie jest zielone, żadna liczba dla tego pliku nic nie znaczy. U mnie ten krok złapał plik, który po ponownym załadowaniu kasuje własny mutex. Bez tej kontroli raport pokazałby ścianę zabitych mutantów i piękny wynik. Każdego zabiłby efekt uboczny ładowania, żadnego test.
Kiedy po obu stronach stoi model, to przestaje być higiena. Staje się warunkiem, bez którego żadna z tych liczb nic nie znaczy. Agent, który pisze kod, testy i mutanty, zamknął pętlę wokół siebie. Miara przeżyje ten układ tylko wtedy, kiedy umie powiedzieć „nie wiem”. I kiedy odmawia zaliczenia obrony, której nie potrafi udowodnić.
Jak uruchomić testy mutacyjne i nie zabić CI
Mutacja jest droga z definicji, bo jeden mutant to jedno uruchomienie testów. Kilkaset mutantów na plik razy czas startu zestawu daje nockę zamiast przerwy na kawę. Google robi to od ponad dekady i opublikował rachunek. Prawie 17 milionów mutantów przy 776 tysiącach zmian. 2,1 miliona z nich pokazano ludziom w code review, u ponad 24 tysięcy programistów.
Najważniejsza liczba z tego rachunku nie dotyczy wydajności. Na starcie programiści uznali 85 procent pokazanych im mutantów za bezużyteczne. Dopiero sześć lat zbierania opinii i reguły wygaszające całe klasy mutantów podniosły udział przydatnych z 15 do 89 procent. Mediana liczby mutantów na jedną zmianę spadła przy tym z 820 do siedmiu.
Wniosek jest niewygodny dla każdego, kto chce po prostu włączyć narzędzie. Surowy audyt produkuje głównie hałas, a ten hałas kosztuje czas najdroższych ludzi w firmie. Cztery rzeczy sprowadzają go do rozsądnego rachunku.
- Mutuj diff, nie repozytorium. Jedyny tryb, który przeżyje kontakt z zespołem, to „tylko linie, które ta gałąź zmieniła”. W wersji dla agentów brzmi to prościej: mutuj to, co model właśnie napisał. Google zmierzył przy okazji, ile to kosztuje: przy zmianach średnich i dużych mutacja na poziomie commita nie spowalnia review bardziej niż liczenie pokrycia, a widoczny narzut zostaje tylko przy zmianach najmniejszych.
- Puszczaj tylko te testy, które mogą zabić. Mutant na linii, której dany plik testowy nie wykonuje, to spalony budżet. Pokrycie na poziomie linii mówi, które testy w ogóle mają szansę.
- Startuj proces raz. Świeży start zestawu na każdego mutanta kosztuje u mnie 1,98 sekundy przy testach bez frameworka i 17,24 sekundy z pełnym bootem aplikacji. Z tych 17,24 aż 16,15 to ładowanie, które nie zależy od tego, którego mutanta zaraz sprawdzamy. Fork już wystartowanej sesji kosztuje około pół sekundy.
- Wybierz pliki ręcznie. Listę audytowanych plików i listę testów, które mają być ich pokryciem, deklaruję wprost. Konwencja nazw nie działa: w realnym repozytorium część testów nazywa się od scenariusza, a nie od klasy, a bywa i tak, że jeden pokrywa plik, którego żadna reguła nazewnicza nigdy by z nim nie skojarzyła.
Narzędzia są gotowe i dojrzałe. Java ma PIT. JavaScript i TypeScript mają Strykera, C# Stryker.NET. PHP ma Infection. Python ma mutmut i cosmic-ray. Rust ma cargo-mutants. Ruby ma gem mutant. Zanim któreś wybierzesz, sprawdź dwie rzeczy, na których się przewróciłem: minimalną wersję języka i licencję dla repozytorium prywatnego.
Czego testy mutacyjne nie złapią
Nie zastąpią testów, których nie ma. Przy pokryciu na poziomie dwudziestu procent mutacja powie Ci to, co już wiesz, tylko drożej. To narzędzie do oceny testów, które istnieją.
Nie dochodzą do stu procent i nie o to chodzi. Część ocalałych to nie są znaleziska. Zmutowany kod bywa naprawdę równoważny oryginałowi i żaden test nie może ich odróżnić. Dwa kształty wracają regularnie. Porównanie, którego operandy i tak dzieli epsilon. Zbędny wczesny return, po którego pominięciu wychodzi dokładnie to samo. Równoważności nie da się w ogólności rozstrzygnąć, więc niezerowa liczba ocalałych jest normalna nawet przy wzorowych testach. Zespół Google’a napisał to wprost we wcześniejszej pracy: pełna adekwatność mutacyjna nie jest ani praktyczna, ani pożądana.
Wynik mutacyjny jest fatalnym celem. Ustaw próg w CI, a dostaniesz dokładnie to, o co prosiłeś, i nic więcej: testy pisane pod zabijanie mutantów. Traktuj tę liczbę jak termometr. Pracuje lista ocalałych czytana metodami, a nie słupek na dashboardzie.
Nie każde „obronione” znaczy obronione. U mnie mutant, który się zawiesił albo wysadził proces, liczy się jako obroniony. Popsuty kod, który wiesza się albo wybucha, też nie dojedzie na produkcję. Ale żadne z tych dwóch nie dowodzi, że jakakolwiek asercja cokolwiek stwierdziła. Plik z dużą liczbą takich wyników ma wynik zawyżony i raport musi to pokazać.
Nie wszystko da się zmierzyć. Modeli ActiveRecord mój harness odmawia po nazwie. Instaluje mutanta przez ponowne wykonanie całego pliku, a na modelu to nie zastępuje definicji, tylko je dokłada. Walidacje idą 9, 18, 27. Test bazowy dubluje się tak samo, więc zostaje zielony. Potem każdego mutanta zabija zduplikowany komunikat walidacji, a nie test. Ściana fałszywych zabójstw i świetny wynik. Odmowa jest tu jedyną uczciwą odpowiedzią.
I ograniczenie najważniejsze, o którym w tekstach o AI się nie pisze. Mutacja to instrument na poziomie jednostki. Najczęstsza awaria kodu pisanego przez agenta nie siedzi w jednej linii. Siedzi w tym, że dwa poprawne kawałki nie składają się w całość. Tego mutacja nie zobaczy i nie ma zobaczyć.
Od czego zacząć
Weź jeden plik. Najlepiej ten, który boisz się ruszyć, i ten, w którym siedzą reguły zaufania do modelu. Puść na nim mutację. Nie patrz na procent, tylko na listę ocalałych pogrupowaną metodami. Najgęstsza grupa to jedna historia i jeden test do dopisania, nie dwadzieścia ticketów.
Potem wepnij to w diff. Dokładnie tam, gdzie agent oddaje robotę.
Najczęstsze pytania
Czym są testy mutacyjne?
Testy mutacyjne to technika oceny jakości zestawu testów. Narzędzie wprowadza do kodu produkcyjnego drobną, celową zmianę, na przykład zamienia większe lub równe na większe albo usuwa instrukcję, i uruchamia testy. Jeśli któryś test się wywali, mutant jest zabity, a testy tę decyzję pilnują. Jeśli wszystkie przejdą, mutant przeżył i masz linię, o której testy nie twierdzą nic. Wynik mutacyjny to udział mutantów zabitych w tych, które dało się osądzić.
Czym różni się wynik mutacyjny od pokrycia kodu?
Pokrycie odpowiada na pytanie, czy dana linia wykonała się podczas testów. Wynik mutacyjny odpowiada na pytanie, czy jakakolwiek asercja by pękła, gdyby ta linia była błędna. To dwie różne wielkości. Test bez asercji, test asertujący na podstawionym obiekcie i test, który zapisał aktualne wyjście jako oczekiwane, dają pełne pokrycie i zerową wartość diagnostyczną.
Dlaczego testy pisane przez AI mają wysokie pokrycie i słaby wynik mutacyjny?
Model piszący test do gotowego kodu ma ten kod w kontekście, więc najtańszą drogą do zielonego jest test powtarzający implementację zamiast sprawdzający ją. Badania pokazują, że modele piszą asercje opisujące zachowanie aktualne, a nie oczekiwane, i częściej niż programiści bez agenta podstawiają zależności zamiast testować rzeczywistą interakcję. Taki test podnosi pokrycie i przeszedłby również z odwróconym warunkiem.
Czy testy mutacyjne są warte swojego kosztu?
Tak, pod warunkiem że nie uruchamiasz ich na całym repozytorium. Jeden mutant to jedno uruchomienie testów, więc surowy audyt liczy się w godzinach. Wersja, która się opłaca, mutuje wyłącznie linie zmienione w danej gałęzi, uruchamia tylko te testy, które mogą zabić, i startuje proces raz zamiast raz na mutanta. W tej postaci koszt jest porównywalny z liczeniem pokrycia w tym samym procesie code review.
Jaki wynik mutacyjny jest dobry?
Nie ma jednego progu i sto procent nie jest celem, bo część mutantów jest równoważna oryginałowi i żaden test nie może ich odróżnić. Sensowniej ustawić oczekiwanie osobno dla każdego pliku, według tego, ile kosztuje jego błąd. Wysoko dla kodu, który decyduje o pieniądzach, granicach uprawnień i zaufaniu do odpowiedzi modelu. Nisko dla warstw czysto przenoszących dane. Wartość ma lista ocalałych czytana metodami, a nie sam procent.
Czy testy mutacyjne zastąpią code review?
Nie. Mutacja działa na poziomie pojedynczej jednostki i odpowiada na jedno pytanie: czy testy pilnują tej decyzji. Nie zobaczy błędu, który polega na tym, że dwa poprawne kawałki nie składają się w spójną całość, a to najczęstsza awaria kodu pisanego przez agenta. Mutacja jest wejściem do review, nie jego zamiennikiem.