Blog Dla Programistów C#/.NET

Testy jednostkowe pisane przez AI w .NET: 5 grzechów, mutation testing ze Stryker.NET i prompt, który działa

niedziela, 8 listopada 2026 Tagi: C#/.NETTesty JednostkoweProgramowanieAI

Niedawno poprosiłem AI o testy jednostkowe do jednego serwisu w moim projekcie. Dostałem 40 testów. Wszystkie zielone za pierwszym uruchomieniem, a pokrycie kodu skoczyło z 61% do 94%. Wyglądało to tak dobrze, że zrobiłem screenshota. A potem skasowałem wszystkie 40.

Nie dlatego, że nie przechodziły. Dlatego, że przechodziły... i przeszłyby też wtedy, gdybym celowo zepsuł kod. Sprawdziłem to - wynik zobaczysz za chwilę. I stawiam kawę, że w Twoim repo takie testy też już siedzą.

W tym artykule pokażę Ci 5 grzechów testów pisanych przez AI na konkretnym kodzie w C#, 3 pytania, którymi wyłapiesz je w 30 sekund na code review, oraz darmowe narzędzie, które robi to automatycznie: Stryker.NET. Na koniec dostaniesz workflow i gotowy prompt, dzięki którym AI zacznie pisać testy, które naprawdę czegoś pilnują.

Przykłady w artykule używają xUnit i Moq, ale zasady są takie same w NUnit czy MSTest - i nie zależą od tego, czy testy generuje Ci GitHub Copilot, Claude Code, Cursor czy ChatGPT. Stryker.NET jest open source, instaluje się jako narzędzie dotnet (dotnet tool) i współpracuje z xUnit, NUnit oraz MSTest.

Testy jednostkowe pisane przez AI w .NET: 5 grzechów, mutation testing ze Stryker.NET i prompt, który działa

Pułapka zielonych testów


Znasz ten workflow. Wklejasz klasę do czata i piszesz: "napisz mi do tego testy". 30 sekund później masz 40 testów - wzorzec AAA (Arrange, Act, Assert), ładne nazwy, pełen komplet. Odpalasz dotnet test - wszystko zielone. Commit, push, merge. Dopamina.

Liczby też wyglądają pięknie. Coverage z 61% na 94%. Pull request zatwierdzony od ręki. Pół godziny roboty zamiast całego popołudnia. Sprint review Cię pokocha... na razie.

Mały eksperyment: psuję kod celowo. Właśnie w takiej sytuacji postanowiłem przetestować same testy. Celowo zepsułem kod produkcyjny:

• w 3 miejscach zmieniłem znak większości (>) na większe-równe (>=),
• usunąłem walidację danych wejściowych,
• odwróciłem jeden warunek.

Odpaliłem testy. 40 na 40. Zielone. Żaden test nie zauważył, że logika jest zepsuta.

Po co w ogóle istnieje test?


Tu jest sedno całego tematu. Test nie istnieje po to, żeby świecić na zielono w raporcie CI. Zapamiętaj zasadę numer 1:

Test istnieje po to, żeby oblać. Dokładnie wtedy, gdy ktoś - Ty za pół roku, junior z zespołu albo AI - zepsuje zachowanie. Wartość testu mierzysz jego zdolnością do porażki. Do tego zdania jeszcze wrócimy.

Dobrze to widać na prostej analogii. Test to alarm antywłamaniowy. Alarm, który fizycznie nie umie się włączyć, to nie jest zabezpieczenie, tylko dioda na ścianie. Świeci. Uspokaja. Nie chroni. I dokładnie takie diody produkuje AI, kiedy prosisz je o testy w zły sposób.

Poniżej 5 grzechów testów pisanych przez AI. Każdy pokażę na kodzie - to wzorce, które regularnie widuję na code review. Licz w głowie, ile z nich masz u siebie w repo.

Grzech #1: test-lustro


Zacznijmy od prostego serwisu. Klienci premium dostają 20% rabatu, czyli cena razy 0,8. Nic podejrzanego:

public class PriceCalculator
{
public decimal CalculatePrice(decimal price, bool isPremium)
{
if (isPremium)
return price * 0.8m; /* rabat 20% dla klientów premium */

return price;
}
}

AI wygenerowało do tego taki test:

[Fact]
public void CalculatePrice_PremiumCustomer_AppliesDiscount()
{
/* Arrange */
var calculator = new PriceCalculator();

/* Act */
var result = calculator.CalculatePrice(100m, isPremium: true);

/* Assert */
Assert.Equal(100m * 0.8m, result); /* ten sam wzór, co w implementacji */
}

Wygląda porządnie: Arrange, Act, Assert, sensowna nazwa, test jest zielony. Ale spójrz na asercję. 100 razy 0,8 to dokładnie ten sam wzór, który siedzi w implementacji.

Dlaczego to atrapa? Skąd AI wzięło 0,8? Z Twojego kodu. Czyli ten test sprawdza, czy kod robi to, co robi. A to robi zawsze. Gorzej: jeśli wzór jest błędny od pierwszego dnia, bo rabat miał wynosić 25%, a nie 20%, test przyklepie błąd z czystym sumieniem.

Poprawka. Banalna. Wpisz 80 - liczbę policzoną z wymagania, a nie przepisaną z kodu:

Assert.Equal(80m, result); /* 100 zł - 20% rabatu = 80 zł, policzone z wymagania */

Zasada jest prosta: oczekiwana wartość w teście musi pochodzić z innego źródła niż implementacja. Zawsze.

Grzech #2: beton na bugu


Mój ulubiony grzech, bo najlepiej pokazuje, że zielony test potrafi być gorszy niż brak testu.

Wymaganie z Jiry: "Darmowa dostawa od 200 zł." Od, czyli włącznie. Koszyk za równo 200 zł - klient nie płaci za dostawę. Prościej się nie da.

A to kod, który trafił na produkcję:

public class ShippingService
{
public bool IsFreeShipping(decimal cartTotal)
{
return cartTotal > 200m; /* BUG: wymaganie mówi "od 200 zł", powinno być >= */
}
}

Widzisz? Znak większości, a powinno być większe-równe. Wymaganie mówi "od 200 zł", kod mówi "powyżej 200 zł". Jeden znak różnicy - klasyczny błąd na granicy przedziału (off-by-one).

Test, który betonuje buga. I teraz najlepsze. AI dostało ten kod i wygenerowało test:

[Fact]
public void IsFreeShipping_CartTotalExactly200_ReturnsFalse()
{
var service = new ShippingService();

var result = service.IsFreeShipping(200m);

Assert.False(result); /* oczekiwany wynik wzięty z zabugowanego kodu */
}

Zwróć uwagę: AI przetestowało dokładnie wartość graniczną, czyli 200 zł. Brawo, tak się robi testy granic... Tylko że oczekiwany wynik wzięło z Twojego zabugowanego kodu. Assert.False, test zielony. Bug właśnie dostał osobistego ochroniarza.

Kronika katastrofy. Zobacz, jak to się rozwija w czasie:

• Dzień 1. AI generuje testy z kodu, wszystko zielone.

• Dzień 30. Pisze klient: "Koszyk za 200 zł i każecie mi płacić za dostawę?!"

• Dzień 31. Junior znajduje buga i poprawia znak... a test robi się czerwony. Kwadrans później myśli: "mój fix psuje testy, lepiej cofnę zmianę". I bug wraca na produkcję. Tym razem pod ochroną testu.

Bo AI nie zna Twoich wymagań. Zna tylko Twój kod. A Twój kod właśnie skłamał. Test generowany z implementacji to zdjęcie stanu obecnego - razem z bugami.

Poprawka. Oczekiwany wynik bierzesz z wymagania, a granicę sprawdzasz z obu stron: tuż poniżej, dokładnie na niej i tuż powyżej:

public static TheoryData<decimal, bool> BoundaryCases => new()
{
{ 199.99m, false },
{ 200m, true }, /* "od 200 zł" = włącznie */
{ 200.01m, true }
};

[Theory]
[MemberData(nameof(BoundaryCases))]
public void IsFreeShipping_BoundaryValues_FollowsRequirement(decimal cartTotal, bool expected)
{
var service = new ShippingService();

var result = service.IsFreeShipping(cartTotal);

Assert.Equal(expected, result);
}

TheoryData zamiast InlineData, bo wartości typu decimal nie da się przekazać w atrybucie. Taki test od razu zrobi się czerwony na kwocie 200 zł i pokaże buga, zamiast go chronić.

Grzech #3: teatr asercji


Test asynchroniczny, ładna nazwa, 2 asercje. Tylko spójrz, co one sprawdzają:

[Fact]
public async Task CreateInvoiceAsync_ValidRequest_CreatesInvoice()
{
var service = new InvoiceService();

var invoice = await service.CreateInvoiceAsync(_request);

Assert.NotNull(invoice); /* serwis tworzy fakturę przez new - nigdy null */
Assert.True(invoice.Items.Count >= 0); /* Count nie może być ujemny */
}

Assert.NotNull na obiekcie, którego serwis nigdy nie zwróci jako null, bo tworzy go przez new. I Count większe-równe 0, a Count z definicji nie umie być ujemny. Obie asercje są prawdziwe zawsze. Matematycznie.

Ten test przejdzie także dla faktury:

• na minus 500 zł,
• bez NIP-u,
• z datą z 1970 roku,
• z VAT-em 230%.

Assert.NotNull nie sprawdza logiki biznesowej. Sprawdza, że C# nadal działa. A za to odpowiada Microsoft, nie Ty.

Poprawka. Asercje mają dotyczyć konkretnych wartości biznesowych, policzonych z wymagań:

Assert.Equal(1000m, invoice.NetTotal);
Assert.Equal(230m, invoice.VatAmount); /* stawka 23% z wymagań */
Assert.Equal(1230m, invoice.GrossTotal);

A faktura na minus 500 zł czy bez NIP-u to osobne testy, które oczekują błędu walidacji (np. przez Assert.ThrowsAsync). Jeśli takich testów nie ma, to znak, że AI w ogóle nie wiedziało o tych regułach - i trzeba mu je podać.

Grzech #4: testujesz Moq, nie swój kod


Klasyka gatunku. Mock, wywołanie metody i Verify, że mock został zawołany:

[Fact]
public async Task SendInvoiceAsync_ValidInvoice_SendsEmail()
{
var emailSender = new Mock<IEmailSender>();
var mailer = new InvoiceMailer(emailSender.Object);

await mailer.SendInvoiceAsync(_invoice);

emailSender.Verify(x => x.SendAsync(
It.IsAny<string>(), /* do kogokolwiek */
It.IsAny<EmailMessage>()), /* z czymkolwiek w środku */
Times.Once);
}

It.IsAny, czyli dosłownie "cokolwiek" - i to 2 razy. Wysłano jakiegoś maila do kogokolwiek z czymkolwiek w środku? Test zaliczony.

Czego ten test nie sprawdza?

• Do kogo poszedł mail.
• Czy kwota na fakturze się zgadza.
• Czy załącznik w ogóle istnieje.
• Co się stanie, gdy wysyłka rzuci wyjątkiem.

Gratulacje - właśnie przetestowałeś bibliotekę Moq. Ona ma już własne testy. Naprawdę nie musisz jej pomagać.

Poprawka. W Verify sprawdzaj konkretne wartości, a tam, gdzie potrzebujesz warunku, użyj It.Is z predykatem:

emailSender.Verify(x => x.SendAsync(
_invoice.CustomerEmail, /* właściwy adresat */
It.Is<EmailMessage>(m =>
m.Subject.Contains(_invoice.Number) && /* właściwa faktura */
m.Attachments.Count == 1)), /* załącznik istnieje */
Times.Once);

Scenariusz awarii to osobny test: konfigurujesz mocka tak, żeby rzucał wyjątkiem (Setup(...).ThrowsAsync(...)), i sprawdzasz, co wtedy robi Twój kod - czy loguje błąd, oznacza fakturę jako niewysłaną, czy przekazuje wyjątek dalej. It.IsAny w Setup to nic złego. Czerwona lampka zapala się wtedy, gdy It.IsAny jest jedyną treścią asercji.

Grzech #5: autostrada happy path


AI uwielbia produkować dużo testów. 8, 12, 20. Spójrz na takie nazwy:

CalculateTotal_ValidOrder_ReturnsCorrectTotal
CalculateTotal_TypicalOrder_ReturnsCorrectTotal
CalculateTotal_TwoItems_ReturnsCorrectTotal
CalculateTotal_ThreeItems_ReturnsCorrectTotal
CalculateTotal_StandardUser_ReturnsCorrectTotal
CalculateTotal_PremiumUser_ReturnsCorrectTotal
CalculateTotal_RegularOrder_ReturnsCorrectTotal
CalculateTotal_SampleOrder_ReturnsCorrectTotal

To 8 razy ten sam scenariusz w innym przebraniu. 1 ścieżka. Zero szans, że którykolwiek z tych testów wykryje coś, czego nie wykryłby pierwszy. Ale coverage rośnie, więc wszyscy są zadowoleni.

Gdzie mieszkają bugi? Na pewno nie na happy path. Bugi mieszkają tu:

• null i pusta lista pozycji zamówienia,

• 0 zł i kwoty ujemne, np. przy zwrotach,

• decimal.MaxValue i przepełnienie - arytmetyka na decimal przy przepełnieniu rzuca OverflowException,

• przecinek kontra kropka, czyli "1,5" vs "1.5" - pozdrawiam polską kulturę i parsowanie liczb. decimal.Parse("1,5", CultureInfo.InvariantCulture) zwraca 15, a nie 1,5. Każdy polski programista .NET ma jakąś bliznę po CultureInfo,

• strefy czasowe i przełom roku - 31 grudnia o 23:30 UTC w Polsce jest już 1 stycznia, więc zamówienie może trafić do złego roku w raporcie.

AI testuje to, co widzi w kodzie. A bugi żyją w tym, czego w kodzie nie ma. Dlatego przypadki brzegowe musisz wymusić - wrócimy do tego przy prompcie.

Jak wykryć testy-atrapy w 30 sekund na code review


Jak to wyłapać, zanim wjedzie na maina? Wystarczą 3 pytania i 30 sekund przy przeglądaniu pull requesta:

1. Skąd pochodzi oczekiwana wartość? Z wymagania czy przepisana z implementacji?

2. Czy ta asercja w ogóle umie oblać? Assert.NotNull, Count >= 0, It.IsAny - to są czerwone lampki.

3. Gdyby ktoś dziś zepsuł logikę - który z tych testów zrobi się czerwony? To pytanie najważniejsze. Jeśli odpowiedź brzmi "żaden", to nie są testy. To dekoracje.

Najprostszy wykrywacz kłamstw. A jeśli nie masz pewności, jest metoda brutalna - ta sama, od której zacząłem: zepsuj kod specjalnie. Zmień znak. Zakomentuj linię walidacji. Odpal testy. Nadal zielone? Masz odpowiedź.

Stryker.NET: mutation testing, czyli robot, który psuje kod za Ciebie


Ręcznie nie zrobisz tego przy 400 testach. Dobra wiadomość: jest narzędzie, które psuje kod za Ciebie. Stryker.NET to mutation testing dla .NET - open source, darmowy i dojrzały. Robi dokładnie to, co mój eksperyment z psuciem kodu, tylko tysiąc razy i automatycznie.

Jak działa mutation testing? Stryker wprowadza do Twojego kodu setki drobnych sabotaży, nazywanych mutantami. Zamienia > na >=. Mnożenie na dzielenie. Neguje warunki. Usuwa całe instrukcje. Po każdej takiej zmianie uruchamia Twoje testy:

• jeśli któryś test obleje - mutant zabity, testy działają,

• jeśli wszystko dalej jest zielone - mutant przeżył. Czyli da się zepsuć Twój kod, a testy nawet nie mrugną.

Start w 2 minuty. Instalujesz narzędzie, wchodzisz do katalogu projektu testowego i uruchamiasz analizę:

dotnet tool install -g dotnet-stryker
cd tests/Sklep.Tests
dotnet stryker

Na wyjściu dostajesz raport HTML (w katalogu StrykerOutput), który co do linii pokazuje, gdzie można bezkarnie psuć Twój kod. Na dużym projekcie analiza chwilę trwa, bo testy są uruchamiane dla wielu mutantów, więc na start wybierz 1 projekt z najważniejszą logiką biznesową. Dokumentację i listę wszystkich mutacji znajdziesz na stronie projektu: stryker-mutator.io.

Raport bez znieczulenia. Przykładowy wynik: 127 mutantów zabitych, 48 przeżyło, co daje mutation score na poziomie ok. 73%. A teraz wróćmy do moich 40 testów z początku artykułu:

• coverage: 94%,
• mutation score: 31%.

69% sabotaży przeszło niezauważone. Dlatego te testy poleciały do kosza.

Zasada numer 2: coverage to nie ochrona. Coverage mówi tylko tyle, że linia została wykonana podczas testu. Mutation score mówi, że linia jest naprawdę sprawdzana. Da się mieć 100% coverage i zero realnej ochrony.

Jak używać AI do testów dobrze: workflow w 5 krokach


Czyli co - nie używać AI do testów? Używać. Sam używam codziennie. Ale trzeba odwrócić kolejność.

Zły prompt vs dobry prompt. Wszystko zaczyna się od prompta:

• Zły prompt: "napisz testy do tej klasy" plus Ctrl+V całej implementacji. Dostajesz lustro kodu - razem z bugami.

• Dobry prompt: dajesz wymagania, np. 3 zdania z Jiry, i prosisz o przypadki testowe. Dostajesz testy specyfikacji, a nie implementacji.

Jedna zmiana, zupełnie inny wynik.

Workflow w 5 krokach. U mnie cały proces wygląda tak:

1. Ty wklejasz wymagania, nie kod.

2. AI proponuje listę przypadków testowych - samą listę, bez kodu.

3. Ty tę listę tniesz i dopisujesz granice. Tu dzieje się inżynieria.

4. AI pisze kod testów. Tu dzieje się boilerplate.

5. Stryker sprawdza, czy te testy w ogóle cokolwiek łapią.

Człowiek decyduje, co testować. Maszyna pisze, jak.

Gotowy prompt do skopiowania. Tego promptu używam sam. Podmień nazwę metody i wymagania:

"Oto wymagania metody [NAZWA]:
1. [wymaganie 1]
2. [wymaganie 2]
Krok 1: wypisz listę przypadków testowych (happy path + granice + błędne dane). Bez kodu.
Krok 2: poczekaj na moją akceptację listy.
Krok 3: wygeneruj testy: xUnit, wzorzec AAA, nazwy Metoda_Scenariusz_OczekiwanyWynik.
Zasada: oczekiwane wartości wyliczasz z wymagań. Nie opieraj asercji na obecnej implementacji."


Kluczowe są w nim 2 rzeczy. Po pierwsze, AI ma najpierw pokazać listę przypadków i poczekać na Twoją akceptację - dzięki temu to Ty decydujesz o zakresie, zanim powstanie choćby linijka kodu. Po drugie, przy wyliczaniu oczekiwanych wartości AI ma nie zaglądać do implementacji. To właśnie ta zasada zabija test-lustro i beton na bugu.

Dla przykładu z darmową dostawą wymagania mogą wyglądać tak: "1. Dostawa jest darmowa dla koszyka o wartości od 200 zł włącznie. 2. Dla koszyka poniżej 200 zł dostawa jest płatna." Z takim wejściem AI nie ma skąd przepisać buga, bo oczekiwane wartości liczy z wymagań, a nie z kodu.

3 zasady na lodówkę. Na koniec wszystko w 3 zdaniach:

1. Dobry test oblewa, gdy psujesz zachowanie.
2. Oczekiwane wartości biorą się z wymagań, nie z kodu.
3. Coverage mierzy wykonanie, mutation score mierzy ochronę.

Ściąga: 5 grzechów testów od AI


Wszystkie grzechy w jednym miejscu - przyda się przy najbliższym code review:

GrzechJak go rozpoznaćJak naprawić
Test-lustroOczekiwana wartość liczona tym samym wzorem, co w kodzie (100m * 0.8m)Wpisz wynik policzony z wymagania (80m)
Beton na buguTest granicy z wynikiem przepisanym z obecnego koduWynik z wymagania, granica sprawdzona z obu stron (199,99 / 200 / 200,01)
Teatr asercjiAsercje zawsze prawdziwe: Assert.NotNull, Count >= 0Asercje na konkretnych wartościach biznesowych + osobne testy błędnych danych
Testujesz Moq, nie swój kodVerify z samymi It.IsAnyKonkretny adresat, It.Is z warunkiem na treść, test scenariusza z wyjątkiem
Autostrada happy pathWiele testów, 1 ścieżka, różne tylko nazwynull, 0, kwoty ujemne, decimal.MaxValue, CultureInfo, strefy czasowe


Podsumowanie


Zielony test to jeszcze nie dobry test. Zbierzmy wszystko w jednym miejscu:

• Test istnieje po to, żeby oblać - jego wartość mierzysz zdolnością do porażki, a nie kolorem w raporcie CI.

• 5 grzechów testów od AI: test-lustro, beton na bugu, teatr asercji, testowanie Moq zamiast własnego kodu i autostrada happy path.

• 3 pytania na code review: skąd oczekiwana wartość, czy asercja umie oblać i który test zrobi się czerwony, gdy ktoś zepsuje logikę.

• Stryker.NET automatycznie psuje Twój kod i pokazuje, ile sabotaży przeżyło. Coverage 94% może oznaczać mutation score 31%.

• Odwrócona kolejność: wymagania zamiast kodu, lista przypadków przed kodem testów. Człowiek decyduje, co testować, a AI pisze, jak.

Jeśli masz zapamiętać z tego artykułu jedno zdanie, niech to będzie to: test nie jest dobry dlatego, że przechodzi. Test jest dobry wtedy, kiedy oblewa dokładnie w momencie, gdy ktoś psuje zachowanie. AI napisze Ci testy szybciej niż kolega z zespołu. Ale to Ty odpowiadasz za to, czego one pilnują.

Jeśli chcesz więcej takich konkretów, mam coś dla Ciebie. Przygotowałem darmowy PDF: 25 promptów AI dla programistów C#/.NET. 5 obszarów, w których AI oszczędza najwięcej czasu: refaktoryzacja, testy jednostkowe, debugowanie, code review i praca z legacy. Kopiujesz, wklejasz - działa w Claude Code, GitHub Copilocie i Cursorze. Jest tam zresztą cały rozdział z promptami do testów, w tym rozwinięcie promptu z tego artykułu.

Odbierzesz go tutaj: modestprogrammer.pl/25-promptow. Zostawiasz maila i PDF przychodzi od razu. Przy okazji dołączasz do społeczności ponad 10 000 programistów .NET: raz w tygodniu jeden konkretny mail z rzeczami, które realnie przydają się w robocie, bez lania wody, plus rabaty na moje kursy, zanim ogłoszę je gdziekolwiek indziej. Zero spamu - wypisujesz się jednym kliknięciem.

Powodzenia w polowaniu na mutanty :)

Autor artykułu:
Kazimierz Szpin
Kazimierz Szpin
CTO & Founder - FindSolution.pl
Programista C#/.NET. Specjalizuje się w Blazor, ASP.NET Core, ASP.NET MVC, ASP.NET Web API, WPF oraz Windows Forms.
Autor bloga ModestProgrammer.pl
Dodaj komentarz
© Copyright 2026 modestprogrammer.pl | Sztuczna Inteligencja | Regulamin | Polityka prywatności. Design by Kazimierz Szpin. Wszelkie prawa zastrzeżone.