Programátoři a jejich manažeři občas řeší, jaká míra pokrytí unit testy je dostatečná. Můžeme zaslechnout, že cokoliv pod 60% nestojí za zmínku, občas zahlédneme, že to magické číslo je 95% a někteří fundamentalisté dokonce tvrdí, že cesta k nirváně vede jen a pouze přes stoprocentní pokrytí.
Dnes jsem si řekl, že je ideální počasí na to, vyzkoušet jestli se dá sta procent dosáhnout. Povedlo se mi to během dopoledne a nyní bych se chtěl podělit o své zkušenosti.
Pracuji na jednom menším projektu, který v podstatě jen zpracovává REST volání a přeposílá je pomocí SOAP na další systémy. Není to nic složitého, ale není to ani žádná trivialita. Prostě ideální kandidát na pokusy. I před tím, než jsem se rozhodl dosáhnout vytoužené mety, jsem se snažil unit testy dělat poctivě. Jen občas jsem něco neotestoval s tím, že to je příliš triviální na to, abych se patlal s testem. Většinou jsem ale svoji lenost ovládl. Proto mě překvapilo, že moje pokrytí testy bylo něco kolem 60%.
Začal jsem pátrat po příčinách a zjistil jsem, že nejsem tak svědomitý, jak jsem si myslel. Narazil jsem například na neotestovanout třídu, kterou jsem jen odněkud zkopíroval Fůůůůůj. Ale občas je to potřeba. Znáte to, potřebujete něco podobného co umí nějaká metoda ve Springu, jenom je to potřeba trochu ohnout, má to implementovat jiné rozhraní a navíc je to v tom Springu private. Tak to vezmete, zkopírujete a trochu přiohnete. Pokud se pokoušíte o stoprocentní pokrytí, musíte to i otestovat. Pokud se spokojíte s pokrytím nižším, je velice lákavé si říci, že to už je vlastně otestovaný kód, tudíž není potřeba testovat. Ještě jednou fůůůj. Ještě že se stydím jenom před děvčaty. Nakonec jsem byl rád, že jsem tu zkopírovanou metodu musel otestovat. Díky tomu jsem se nad ní pořádně zamyslel a zkrátil ji na polovinu.
Pak jsem ještě napsal pár testů na metody, které jsem nepovažoval za hodné testu. Potěšilo mě, že jsem narazil jenom na jednu metodu, kterou jsem evidentně netestoval, protože by to bylo moc práce. Díky své nové stoprocentní mantře jsem se donutil otestovat i ji. Nakonec to ani moc nebolelo, jenom jsem se musel ponořit do vnitřností Spring-WS abych zjistil jak nejlépe vytvořit instanci jednoho rozhraní (SoapMessage). Alespoň jsem se něco naučil. Pak už zbývaly jen zapeklitější věci.
Get a Set metody
Jedním ze zádrhelů byly get metody. Poté co jsem otestoval všechen kód, který něco dělá, zbyla mi jedna skupina metod, které prostě v žádném testu nebyly potřeba. Byly to get metody u Spring bean. Když totiž potřebuji pomocí Springu něco injektnout, automaticky vygeneruji set a get metody. A to i když používám autowiring přímo do polí. Set metody využívám právě v unit testech. Ukázalo se, že pokud není v testu set metoda použita, je v tom testu něco špatně. A to bez výjimky. Buď to, co nastavuji, není vůbec potřeba nebo netestuji funkcionalitu, která danou hodnotu využívá. Takže si prosím zapamatujte:
Pokud není otestována set metoda, je něco špatně.
Ale zpátky ke get metodám u Spring bean. Ty pro test potřeba nejsou. Ony totiž nejsou potřeba vůbec. Jenom jsem měl hrozný psychický problém s tím je smazat. Pořád jsem si říkal, že je tam nechám, co kdyby byly potřeba. Co kdyby někdo potřeboval vědět, jak je daná třída nakonfigurována atp. Ale pak mě stoprocentní mantra donutila je smazat a myslím, že si na ně nikdo nevzpomene. Jenom je to divné mít set bez getu. Kdybych ty get metody opravdu někdy potřeboval, třeba kvůli JMX, tak je prostě přidám a otestuji.
Equals
Dalším špekem je equals metoda. Pokud byste náhodou nevěděli jak vypadá, tak vám ukáži, to co mi normálně generuje Eclipse
public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (!(obj instanceof Person)) return false; Person other = (Person) obj; if (firstname == null) { if (other.firstname != null) return false; } else if (!firstname.equals(other.firstname)) return false; if (secondname == null) { if (other.secondname != null) return false; } else if (!secondname.equals(other.secondname)) return false; return true; }
A teď si představte jak otestovat všechny možnosti takového krasavce. Abyste dosáhli plného pokrytí, museli byste napsat cca devět testů. To vše kvůli vygenerovanému kódu. K tomu mě nikdo nedonutí. Zkusil jsem použít EqualsBuilder, ale moc jsem si nepomohl. I s jeho pomocí jsou tam asi čtyři kombinace, které je potřeba otestovat. Mumlaje si pod vousy „sto procent, sto procent“ jsem se zeptal pana Googla a ten mě navedl na EqualsTester. Problém vyřešen, jsem zas o krok blíže k vytouženému cíli.
Nemožná výjimka
Zbývala mi jen jedna úplně poslední past nastražená na mě pány ze Sunu:
private byte[] toByteArray(String data) { try { return data.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { LOG.error("Can not happen",e); throw new RuntimeException(e); } }
Abych dosáhl kompletního pokrytí, musím otestovat i catch blok. Ten je bohužel dosažitelný jen pokud vaše JVM nepodporuje UTF-8. Vzhledem k tomu, že by pak JVM nesplňovalo specifikaci není tato situace moc pravděpodobná. Bohužel, tím pádem ani otestování není moc snadné. Nakonec jsem musel kód změnit takto:
private static final Charset UTF8 = (Charset)Charset.availableCharsets().get("UTF-8"); private byte[] toByteArray(String data) { return data.getBytes(UTF8); }
Není to moc elegantní, ale alespoň se mohu utěšovat tím, že je to výkonově efektivnější. Pokud znáte lepší cestu, podělte se o ni, rád se něco přiučím.
A to je vše, dosáhl jsem stoprocentního pokrytí. Zde mi samozřejmě hrálo do karet, že jsem nemusel řešit připojení k databázi. To bych se musel uchýlit k mockování JDBC template nebo k mé oblíbené in-memory databázi ve spojení s JPA. To už by sice nebyl unit test, ale třeba by to nikdo nepoznal.
Takže mám stoprocentní pokrytí, co mi to dalo? Donutilo mě to otestovat věci, na které bych se pravděpodobně jinak vykašlal. U některých z nich jsem byl rád, že jsem je nakonec otestoval. Přivedlo mě to k lepšímu kódu. Potěšilo mě, že jsem nemusel psát žádné nesmyslně testy, jenom kvůli pokrytí. Toho jsem se ze začátku trochu bál. Samozřejmě nesmím usnout na vavřínech. To že mám kód zcela pokryt neznamená, že v něm není chyba. Těch tam naprosto jistě ještě hodně zůstalo. Nicméně mám z toho docela dobrý pocit, myslím si, že 100% je docela dobrá laťka. Na jednu stranu vám nedovolí vymýšlet si výmluvy, proč něco netestujete, na stranu druhou se ukázala jako dosažitelná. Proto se příště jako správný úderník pokusím pokrýt na 105%.
Moc pěkné 🙂 Obzvláště přepsání kódu s vyjímkou, která nemá nikdy nastat. Pokud se opravdu bere testování důsledně, tak to podle mě může vést jedině ke kvalitnějšímu kódu. Držím palce, abys těch 105% dosáhl brzy…
Ještě mě napadá jedna otázka. Nenapsal jsi, jak vlastně chápeš pokrytí. Je rozdíl, zda v průběhu testu projde běh programu všemi řádky kódu, a tím, jestli opravdu testuješ výstupy. Dá se totiž jednoduše udělat i to, že unit test spustíš, ale uvnitř není ani jeden assert, a přesto je to započteno jako úspěšné pokrytí.
Už od začátku jsem se snažil dělat testy pořádně. Při dokrývání jsem v tom pokračoval. Dokonce mi to i ukázalo na místa, která jsem původně moc pořádně neotestoval. Rozhodně jsem se nechtěl snižovat k tomu zavolat kus kódu a pak se spokojit s tím, že to nespadlo. To by bylo k ničemu. Ale dovedu si představit, že i k tomu by člověk mohl sklouznout.
Hezké, díky. 100% pokrytí vypadá jako rozumný kompromis 🙂
No jo ste lidi o krok vepředu. Teď sem nastoupil do firmy kde sním sen o jednoprocentním pokrytí kometáři. Unit testy jsou velmi odvažné sci-fi.
Pro zajímavost bych docela rád věděl, kolik řádku runtime kódu máš a kolik řádků testu k nim? A celkově potom, kolik vývojového času včetně ladění chyb jsi na tom strávil? Máš tu nějakou takovouhle metriku?
Protože pro mě je prostě mantra Paretovo pravidlo – s množstvím chyb jsem docela spokojen a zároveň si připadám docela produktivní. Ale upřímně řečeno, Paretova pravidla 80:20 dost často taky nedosahuju, často se mi stává, že skutečně končím na těch 60 – tj. standardním pokrytím, když se na to vyloženě nesoustředíš.
No zase 20/80 nesmí být bráno jako matematické pravidlo a nelze jej bezhlavě uplatnit kdekoli. Dokonce by mohlo dojít k tomu, že některé takto vytvořené závěry si budou v podstatě logicky odporovat.
Co pokryju dvaceti procenty testů? Osmdesát procent kódu? Osmdesát procent chyb? Nebo osmdesát procent problémů? (Oprava 20% chyb prý předejde osmdesáti procentům problémů.)
@Honza Novotný – Spočítal jsem to a samotného mě to zprvu překvapilo. Mám 628 řádek normálního kódu (říkal jsem, že to je malý projektík) a 711 kódu testů! Když jsem se nad tím zamyslel, tak mi i došel důvod. V testech mám hodně copy and paste kódu. Každá test metoda má podobný kód pro nastavení testovacích dat a mocků, každá obsahuje volání testovaného kódu a podobný, opakující se kód pro ověření výsledků. Ještě jsem se nenaučil duplicity v testech likvidovat stejně dobře jako v normálním kódu. Takže je to nic nevypovídající metrika. Strávený čas bohužel neměřím. Odhadem trávím nejvíc času tím, že studuji jak volat ten backend systém, samotného kódování bude méně než polovina.
V minulosti jem musel delat pokratí kodu 100% zduvodu zákonů. Můžu říct, že jsem při tom ostranil velké množství chyb. Jeden projekt byl v jazyku C. Stalo se mi, že při prvních užitelských testech, že to obačas spadlo, ale díky Unit Testům jsem toto ostranil, asi jinak bych to hledal dost dlouho. Na druhou stranu moje škušenost je taková, že tyto testy vypovídaji o kvantitě, ale ne o kvalitě, protože mnoho testů jsem dělá hodně složitě a clověk je pak šidí. Takže prosím testuje tak, jak je potřeba, jeli to systém na kterém něco důležitého závisí, tak toto tam bude zapotřebi, pokud-li je to produkt, ktery zobazuje na obrazovce nadpis HELLO WORLD, tak to asi takle testovat nebudeme.
Testovani je super vec, ale nekdy je lepsi se nad tim zamyslet… treba ta generovana metoda equals je dost silena a navic chybne (vygeneruj ji do trid A a B extends A, a uz mas nesymetricky equals – chyba logicky i podle kontraktu Object.equals). Namisto instanceof tam patri rovnost trid (viz tez EqualsTester) . Me se libi asi takovydle equals
private static boolean same(Object a, Object b) {
return a==null ? b==null : a.equals(b);
}
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
if (!getClass().equals(obj.getClass())) return false;
final Person other = (Person) obj;
return true
&& same(firstname, other.firstname)
&& same(secondname, other.secondname);
}
Na tom hned vidim co to dela. Problem s testem je v tom ze zadny test neodhali kdyz zapomenes porovnat nakou promennou pripadne mnohy jiny problemy. Abych jen neremcal… nekritizuju clanek ale generovany equals, clanek se mi naopak dost libil (a na ten EqualsTester hned kouknu).
K equals: na generování je nejlepší asi http://projectlombok.org/ .
@Maartin Přemýšlel jsem o tom equals. Nejen, že jsem o tom přemýšlel, já se dokonce i podíval do knihy. To jak jsem to vygeneroval plně odpovídá tomu co pan Bloch píše (kromě zbytečného druhého testu na null). Nicméně máš pravdu v tom, že to je špatně. On tam také píše krásnou věc “Zkrátka nelze žádným způsobem rozšířit třídu, jejíž instance lze vytvářet, a přidat k ní nějaký aspekt a zároveň zachovat kontrakt metody equals”. Proto se equals pokud možno vyhýbám a když už ji implementuji, tak se snažím danou třídu udělat final. Nicméně o equals bylo popsáno hodně papíru a obávám se, že neexistuje jedna implementace vhodná pro úplně všechny případy.
Ono to lze. Při použití rovnosti getClass(). Ale má to své ale vyplývající z popisu.
Koneckonců, tak to generuje Lombok u @EqualsAndHashCode i @Data.
Máte samozřejmě pravdu, občas ale mohu potřebovat aby si byly dvě instance rovny i když nemají stejnou třídu. Jako příklad mě napadá List. Problém také může být s dynamicky generovanými proxy, třeba v Hibernate. Napadlo mě udělat samotnou metodu equals final, to by mohlo fungovat i s instanceof aniž bych se bál, že mi to někdo v potomkovi pokazí. Nenapadá mě, v čem by s tím mohla být potíž.
Jasně, pak je situace o něco složitější. BTW: Popis Collention.equals(Object) zní poněkud krkolomně 😀