Běžné projekty netrpí nedostatkem testů, ale dominancí unittestů
V mnoha projektech nejsou testy problém proto, že by chyběly. Problém je, že jich je hodně právě tam, kde chrání málo.
Build padá, ale jeho pád je hlučný a málo užitečný. Build projde, ale nikdo mu úplně nevěří. Refaktor je stres, přestože „přece máme testy“. A když se člověk podívá dovnitř, zjistí, že většina té údajné ochrany jsou izolované unittesty, často dopsané až na hotový kód, často silně mockované, často motivované coverage.
Tohle není nedostatek testů. Tohle je špatně složená testovací sada.
Dogma, které zní nevinně
Spousta programátorů zná skoro jen unittesty. Když se řekne „dopiš testy“, téměř vždy se tím myslí „dopiš unittesty“. Když se řekne „kvalita“, často se tím myslí „coverage“. Když se řekne „správný test“, často se tím myslí „izolovaný test jedné třídy s mocky okolo“.
Jenže tohle není technická pravda. To je kulturní zvyk.
Unittesty jsou dominantní ne proto, že by vždy nejlépe chránily projekt. Jsou dominantní proto, že se dobře učí, dobře měří a dobře mechanicky vyrábějí. A právě proto jich bývá v projektech příliš mnoho vůči zbytku testovací sady.
Největší problém běžných projektů není absolutní počet testů. Je to jejich poměr.
Šest set unittestů a tři integrační testy není silná testovací sada. Je to známka toho, že tým testuje hlavně to, co se nejsnáze izoluje, ne to, co se nejsnáze rozbije.
Ano, unittesty mají své místo
Unit test je dobrý nástroj pro lokální pravidlo. Pro hraniční podmínku. Pro malou transformační logiku. Pro deterministický výpočet. Tam funguje dobře.
Jenže takového kódu bývá v běžném projektu překvapivě málo.
Typický backend netvoří hlavně elegantní izolovaná pravidla. Tvoří ho orchestrace, persistence, serializace, DTO, framework, glue code, side effecty a rozhraní mezi částmi systému. A právě tam vznikají chyby, které při změně bolí.
Unit testy tedy nejsou zbytečné. Jen bývají přeceněné. Jsou dobré na úzký typ problému. Spousta projektů se ale tváří, jako by ten úzký problém byl celý systém.
Mentální experiment: jaký projekt bych chtěl převzít?
Představ si, že jako nově příchozí člen týmu přebíráš cizí projekt. Máš ho měnit, opravovat a refaktorovat.
Co chceš najít?
Nechceš najít stovky testů, které mockují vlastní vrstvy aplikace a dokazují, že controller zavolal service a service zavolala repository.
Chceš najít projekt, kde červený build něco znamená.
Chceš vidět testy, které hlídají, že:
- endpoint, service a repository si opravdu rozumí,
- důležitý flow projde od vstupu k výsledku,
- hranice mezi moduly drží kontrakt,
- historické bugy se nevracejí,
- nebezpečný refaktor rozbije testy tehdy, když rozbije systém, ne když jen přeskupí vnitřní volání.
A teď druhá část experimentu: jak takové projekty většinou vypadají doopravdy?
Přesně opačně.
Najdeš hodně unittestů. Často izolovaných. Často mock-heavy. Často dopsaných až po dokončení kódu. Málo integračních testů. Téměř žádné contract testy. Pár E2E scénářů, pokud vůbec. A build, kterému tým věří méně, než by odpovídalo množství testů.
To není detail. To je jádro problému.
V projektech se většinou nerozbije izolovaná funkce
Nejnebezpečnější chyba při změně často nevypadá takto:
„Napsal jsem špatný if.“
Mnohem častěji vypadá takto:
„Změnil jsem něco lokálně správně, ale okolí čekalo něco jiného.“
Změní se shape dat. Jiný název pole. Jiný význam pole. None místo výjimky. Decimal místo integeru v centech. Jiný payload eventu. Jiný invariant. Jiná serializace. Jiná interpretace stavu.
Lokálně je kód korektní. Systémově je rozbitý.
To je důležitý rozdíl. Jedna chyba vzniká při psaní nové logiky. Druhá vzniká při změně systému, když jinak korektní kód naruší tiché předpoklady okolí. A právě ta druhá bývá v praxi nebezpečnější.
Proto nestačí říct „máme unittesty“. Záleží, co vlastně chrání.
Mnoho unittestů má největší hodnotu v den svého vzniku
Tohle je nepříjemné, ale důležité přiznání.
Mnoho unit testů je opravdu užitečných ve chvíli, kdy vznikají spolu s kódem. Autor si jimi ověřuje, že právě napsané lokální pravidlo funguje. V ten moment dávají smysl. Pomáhají při tvorbě.
Jenže jejich dlouhodobá hodnota bývá přeceňovaná.
Jakmile je kód hotový a projekt dál žije hlavně změnami na rozhraních, mnoho takových testů už nepřináší reálnou ochranu proti budoucím regresím. Zůstávají v projektu spíš jako pomníček tehdejší implementace. Archiv lokální snahy. Důkaz, že jsme kdysi chtěli být pečliví.
Projekt pak nenese jejich přínos. Nese jejich údržbu.
To neznamená, že všechny unit testy jsou po týdnu zbytečné. Znamená to, že jejich dlouhodobý přínos bývá systematicky nadhodnocený a jejich údržbový náklad podceňovaný.
Co myslím mock-heavy testem
Mock-heavy test je test, který nahradí většinu vnitřních spolupracovníků aplikace mocky a pak hlavně ověřuje, kdo koho zavolal, s jakými argumenty a v jakém pořadí.
Controller mockuje service. Service mockuje repository. Repository mockuje klienta. Test nakonec skončí sérií assert_called_once_with(...).
Takový test často netestuje pozorovatelné chování systému. Testuje choreografii jeho vnitřních volání.
A právě proto bývá křehký při refaktoru a slabý při chytání skutečných regresí.
Mock-heavy testy mají často zápornou hodnotu
Tohle je potřeba říct naplno.
Mock-heavy testy často změny neulevňují, ale prodražují.
Nejen že často nechytnou důležitou regresi. Ony navíc při změně samy vytvářejí práci. Neopravuješ chybu v systému. Opravuješ testy, které byly napsané na starou strukturu volání.
Takový test často neříká: „Systém přestal fungovat.“
Říká spíš: „Implementace už nevypadá tak, jak jsem si ji kdysi představoval.“
To je jejich záporná hodnota pro projekt. Platíš jejich psaní. Platíš jejich údržbu. A přitom ti často neposkytují odpovídající ochranu tam, kde se systém opravdu láme.
Největší slabina mock-heavy testů není jen to, že občas něco nechytnou. Je to to, že při změně samy vytvářejí práci.
Nemockujte vnitřek aplikace. Mockujte její okolí.
Pokud má test opravdu chránit projekt při změně, neměl by co nejvíc odřezávat vnitřek aplikace. Měl by naopak nechat spolupracovat co nejvíc skutečných částí systému a mockovat až to, co leží za jeho hranou.
Uvnitř aplikace chceme co nejvíc reality, ne co nejvíc izolace.
Právě uvnitř aplikace totiž nejčastěji vznikají regresní chyby v návaznosti částí. Když tyto návaznosti nahradíme mocky, netestujeme systém. Testujeme představu o systému.
Dobrými kandidáty na mock nebo náhradu jsou hlavně:
- externí HTTP API,
- email a SMS brány,
- čas, UUID, random,
- message broker mimo proces,
- filesystem nebo cloud storage,
- platby a jiné nevratné side effecty.
To jsou místa, kde izolace opravdu pomáhá. Ne proto, že „správný unittest musí vše mockovat“, ale proto, že tyto závislosti jsou mimo naši aplikaci, jsou drahé, pomalé, nedeterministické nebo nebezpečné.
Když už izolovat, často je lepší fake než mock
Mock jen tvrdí, že se něco zavolalo. Fake se snaží chovat jako jednoduchá, ale skutečná implementace.
Mock repository ti řekne, že někdo zavolal save(order). Fake repository ti umožní opravdu objednávku uložit do in-memory kolekce a pak ověřit výsledek.
To je důležitý rozdíl.
Mock hlídá volání. Fake hlídá chování.
Právě proto bývá fake do budoucna cennější. Méně lže. Méně fixuje vnitřní call-flow. Častěji přežije refaktor. Když už tedy něco izolovat, je často lepší použít jednoduchou náhradní realitu než prázdnou atrapu.
Přestaňme řešit, co je „správný unittest“
V mnoha týmech se zbytečně řeší, jestli test spadá do správné škatulky. Jestli je to ještě unit test, nebo už integrační test. Jestli je dost izolovaný. Jestli sahá na moc vrstev.
Tohle není podstatná otázka.
Podstatná otázka zní jinak: zachytí ten test důležitou regresi za rozumnou cenu?
Test, který projde přes controller, service, mapper a repository, může být pro projekt výrazně cennější než deset čistých unittestů na jednotlivé třídy. I když tím porušuje učebnicová pravidla.
Projekt nechrání ten nejčistší test. Projekt chrání ten test, který zachytí skutečný způsob poruchy.
Coverage je metrika aktivity, ne ochrany
Coverage se dobře ukazuje v dashboardu. Dobře se porovnává. Dobře se tlačí v code review. A právě proto je nebezpečná.
Coverage neříká, že je projekt chráněný. Říká jen, že se kód nějak vykonal.
Coverage neříká:
- že test hlídá správný kontrakt,
- že test dává důvěryhodný fail,
- že test chrání důležitou hranici systému,
- že build má skutečnou vypovídací hodnotu.
Proto můžeš mít projekt s vysokou coverage a nízkou důvěrou v testy. To není paradox. To je běžná realita.
Když tým optimalizuje na coverage, začne snadno vyrábět testy, které se dobře počítají, ale málo chrání.
Lepší model: testy podle typu poruchy
Nemáme se ptát: „Kde dopíšeme unittesty?“
Máme se ptát: „Jaký typ poruchy tato změna nejspíš zavede?“
Když je riziko v lokálním pravidle, napiš unit test.
Když je riziko v návaznosti vrstev, napiš integrační test.
Když je riziko na hranici modulů nebo služeb, napiš contract test.
Když je riziko v kritickém flow, napiš E2E nebo aspoň smoke test.
Když opravuješ historický bug, přidej regresní test na místě, kde chyba skutečně vznikla.
Test se nemá vybírat podle tradice. Má se vybírat podle místa, kde se to opravdu rozbije.
Co bych chtěl po týmu a po LLM agentech
Nechci, aby automaticky dopisovali unittesty na hotový kód.
Chci, aby se nejdřív ptali:
- kde je skutečné regresní riziko,
- co tady mock skryje,
- co má být v testu reálné,
- která nejnižší vrstva ten problém opravdu odhalí,
- a jestli ten test chrání systém, nebo jen aktuální implementaci.
Nechci testy, které dokazují, že controller zavolal service.
Chci testy, kterým budu věřit, když přepíšu půlku flow objednávky.
To je úplně jiná motivace. A právě tu dnes v mnoha projektech postrádáme.
Závěr
Unittesty nejsou špatné. Jejich dominance ano.
Běžné projekty netrpí nedostatkem testů. Trpí tím, že mají testy ve špatném poměru. Příliš mnoho unittestů. Příliš mnoho mocků uvnitř systému. Příliš málo testů na hranách a návaznostech, kde se systém skutečně láme.
Dokud budeme na tento typ poruchy odpovídat hlavně unittesty, budeme mít víc testů, víc coverage a méně skutečné jistoty.
Silná testovací sada nevzniká tím, že má hodně testů. Vzniká tím, že má testy na správných místech.