1. listopadu 2017

Trampoty s JUnit 5

Poslední dobou jsem nepsal moc unit testy... v Javě. Jednak jsem posledního půl roku hodně prototypoval - a tam moc testů nenapíšete - a když už jsem testy psal, tak to bylo převážně ve Scale, nebo v Clojure.

Teď ale naše firma projevila sklony k evoluci, se snahou trochu více zautomatizovat vytváření prostředí a zakládání projektů. Sice to jde mimo mě, ale když jsem byl požádán, ať napíšu testovací projekty v Javě pro Gradle a Maven, chopil jsem se příležitosti a ponořil se do (povrchního) studia JUnit 5.

Vyznání

Obecně musím říct, že pro JUnit mám slabost - začal jsem ho používat na začátku své Java kariéry ve verzi 4.2 (pro pamětníky únor 2007) a tak vlastně celý můj Java-produktivní věk jsem strávil se čtyřkovou verzí. Naučilo mě to hodně - za to, že jsem dnes takový skvělý programátor (ha, ha, ha) vděčím tomu, že mě unit testy naučily psát dobrý design.

Samozřejmě jsem si k tomu občas něco přibral. Už v roce 2008 jsem si mistrně osvojil (tehdy progresivní) jMock 2 a naplno se oddával neřesti BDD. Taktéž TestNG jsem si na pár projektech zkusil. Ale gravitační síla tradičního JUnit a TDD mě vždy přivedla zpátky.

A teď zažívám něco jako déjà vu. Je to podobný, jako když přišla Java 5 - skoro všechny nástroje s tím mají menší nebo větší problém. Java komunita to ještě moc neadaptovala. Když narazíte na problém, StackOverflow často nepomůže. Atd.


Pár aktuálních problémů JUnit 5 se mi podařilo vyřešit ke své spokojenosti. Tady je máte na stříbrném podnose.

Zadání

Zadání, které jsem dostal, bylo triviální - napsat miniaturní Java projekt, buildovatelný Gradlem a Mavenem, který bude mít unit testy. Projekt se bude buildovat na Jenkinsu, potřebuje změřit pokrytí testy pomocí JaCoCo a projít statickou analýzou kódu na SonarQube.

Jak říkám, bylo by to triviální, kdybych si pro testy nevybral JUnit 5.

Gradle

Odpírači pokroku a milovníci XML se mnou nebudou souhlasit, ale já považuju Gradle za základ moderní automatizace na JVM. Včetně (a primárně) buildů. Jak tedy zkrotit Gradle, aby se kamarádil s JUnit 5?

Zatím jsem se v tom nějak moc nevrtal, pač nemám ambice se stát JUnit 5 guru, jen potřebuju běžící testy. Ale je dobré vědět, že:
JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage (JUnit 5 User Guide)
JUnit Vintage je pro JUnit 4, což nás dnes nezajímá. Zbývá tedy JUnit Platform pro spouštění unit testů a JUnit Jupiter pro samotné psaní testů.

Protože JUnit 5 změnilo pravidla hry, nestačí do Gradlu jenom přidat závislosti - současný Gradle novým unit testům nerozumí a zůstaly by nepovšimnuty. Naštěstí je k dispozici je nový plugin, který přidá do build life-cyclu nový task junitPlatformTest a který umí testy spustit.

Bohužel, plugin ještě pořád není dostupný na Gradle Plugins Portal, ale jen v Maven Central. Tím pádem se zatím nedá použít Plugins DSL :-(

V následujícím minimalistickém Gradle skriptu si povšimněte různých konfigurací pro jednotlivé závislosti:
  • testCompile pro api
  • testRuntime pro engine.


Závislost apigurdian-api je optional a je tam jenom proto, aby se ve výstupu nevypisovalo varování:
warning: unknown enum constant Status.STABLE
    reason: class file for org.apiguardian.api.API$Status not found

Task juPlTe je "zahákovaný" na standardní test task, který se dá použít také.

Spuštění JUnit 5 testů Gradlem

Jedna z killer feature Gradlu je incremental build - pokud nešáhnete na produkční kód, nebo na testy, Gradle testy nespouští. Je prostě chytrej ;-)

Gradle incremental build přeskočí testy, pokukd se kód nezměnil


Maven

Tradicionalisti milují Maven a protože jsem shovívavý lidumil, podělím se i o toto nastavení. Pro Maven platí totéž, co pro Gradle:
  • nový plugin (přesněji Surfire provider)
  • závislost na api a engine v různém scopu


Velký rozdíl mezi Mavenem a Gradlem je, že Maven incremental build (moc dobře) neumí - tupě spouští testy, kdykoliv mu řeknete.

Spuštění JUnit 5 testů Gradlem


JaCoCo pokrytí testy

Zbuildovat a spustit JUnit 5 testy byla ta jednodušší část. S čím jsem se trochu potrápil a chvilku jsem to ladil, bylo pokrytí testy. Vybral jsem JaCoCo, protože mi vždycky přišlo progresivnější, než Cobertura (jen takový pocit, či preference).

Dále budu uvádět jen nastavení pro Gradle, protože Maven je hrozně ukecaný. Pokud vás ale Maven (ještě pořád) zajímá, podívejte se do pom.xml v projektové repository.

Zkrácená JaCoCo konfigurace vypadá takto:


V předešlém výpisu jsou podstatné tři věci: (1) generování JaCoCo destination file je svázáno s taskem junitPlatformTest. (2) Definujeme název destination file. Název může být libovolný, ale aby fungovalo generování JaCoCo reportů, je potřeba, aby se soubor jmenoval test.exec. A za (3), pokud chceme některé soubory z reportu exkludovat, dá se to udělat trochu obskurně přes life-cycle metodu afterEvaluate. (Tohle by chtělo ještě doladit.)

JaCoCo pokrytí testy


SonarQube statická analýza

Sonar vlastně s JUnit nesouvisí. Pokrytí testy už máme přece vyřešeno. No, uvádím to proto, že opět je potřeba jít tomu štěstíčku trochu naproti.

Zkrácená verze Sonar konfigurace je následující (Maven opět hledejte v repo):


Tady jsou důležité dvě věci: (1) říct Sonaru, kde má hledat coverage report (klíč sonar.jacoco.reportPath) a za (2) naznačit, co má Sonar z coverage ignorovat (sonar.coverage.exclusions) - bohužel, JaCoCo exkluduje jenom z reportu, v destination file je všechno a tak to Sonaru musíte říct ještě jednou.

SonarQube statická analýza


Má smysl migrovat?

Jak je vidět, popsal jsem spoustu papíru, není to úplně easy peasy. A tak se nabízí hamletovská otázka: má smysl upgradovat z JUnit 4 na verzi 5?


Výše už jsem zmínil velmi přesnou analogii s Javou 5. Tehdy šlo hlavně o anotace a generické kolekce. Můj povrchní dojem je, že u JUnit 5 může být tahákem Java 8 (na nižších verzích Javy to neběží), takže primárně lambdy a streamy.

Pokud máte stávající code base slušně pokrytou pomocí JUnit 4, tak se migrace nevyplatí. Protože ale JUnit 5 umí (pomocí JUnitPlatform runneru) spouštět obě verze simultánně, je možné na verzi 5 přecházet inkremenálně.

Projekt repository

Na Bitbucket jsem nahrál repozitory jednoduchého projektu, kde si můžete v Gradlu a v Mavenu spustit JUnit 5 testy, vygenerovat JaCoCo report a publikovat výsledek do SonarQube.

12 komentářů:

  1. Díky za dobrý a užitečný blogpost. Jak jinak pětkovou řadu hodnotíš a co ti přijde nejužitečnější vlastnost?

    OdpovědětVymazat
    Odpovědi
    1. Díky. Teď je ještě příliš brzo, abych JUnit 5 hodnotil - zatím jsem řešil hlavně ty nástrojové věci okolo a samotnému testování jsem se moc nevěnoval. Tzn. že jsem si proběhnul User Guide a napsal si hello world.

      Co se mi zatím líbilo, jsou parameterized tests, i když třeba tak elegantní jako tabular facts v Midje (Clojure) to ještě není.

      Vymazat
  2. Díky za návod, přesto mě nic nenutí přejít. Něco jiného bylo přechod z JUnit 3 na verzi 4, ale je to holt vyspělý framework, který asi už nemůže přijít s ničím zásadním. Sám jsem si zkoušel to, aby test neselhal na první assert, ale to už fungovalo i v JUnit4.

    Co se mavenu týče, tak ano, ukecaný je, ale sám tam píšeš, co tam není potřeba. Defaultní packaging je jar. Lze ubrat i maven-compiler-plugin a použít jen property maven.compiler.target a maven.compiler.source.

    OdpovědětVymazat
    Odpovědi
    1. Tak on by JUnit s ničím zásadním ani přijít neměl, protože by to byla změna filozofie. Naopak bych očekával adaptaci na evoluci technologií - tady momentálně Java 8.

      Přechod mezi JUnit 3 -> 4 a přechod mezi JUnit 4 -> 5 vnímám jako naprosto ekvivalentní. A historie se opakuje jako přes kopírák: "proč bych měl přecházet?" Tak jako má Java milníky ve verzích 2, 5 a 8, tak podobně to má JUnit - jen ty milníkové verze kopírují major releasy.

      Možná, že jako hlavní důvod, proč výhledově přejít, by mohlo být: adaptovat zavčasu a ne až pod tlakem okolností.

      Vymazat
  3. Dalo se čekat, že přizpůsobení infrastruktury bude nějakou dobu trvat. Ale JUnit 5 to má podle mne vyřešené velice dobře, protože jej lze spouštět přes JUnitPlatform runner. Takže já ten jejich Gradle plugin ani nepoužívám, v IntelliJ Idea jdou JUnit 5 testy spouštět přímo a jinde to zatím spouštím přes ten JUnit 4 runner, což by mělo fungovat všude, kde funguje JUnit 4. Navíc JUnit 5 přišel s tím, že je oddělená platforma pro spouštění testů od samotného testovacího frameworku. Takže příště už by měl být přechod na novou verzi nebo jinou knihovnu snazší, protože se změní testovací framework, ale platforma, na kterou jsou napojené ostatní nástroje, zůstane.

    Nesouhlasím s tím, že JUnit 4 je vyspělý framework a s ničím zásadním by nový JUnit přijít neměl. Já to vidím přesně opačně, když jsem viděl JUnit 5, první, co mne napadlo, bylo: „No konečně, konečně JUnit udělaný správně.“ A začal jsem ho používat už v milestone verzích. JUnit 4 se mi nikdy nelíbil, TestNG bylo o fous lepší, ale taky žádná sláva. Podle mne by testovací kód měl být stejně kvalitní, jako testovaný kód, a tomu se JUnit 4 dost úspěšně bránil – ve spoustě detailů. JUnit 4 mi připadal jako testovací framework pro C, který byl omylem napsán v Javě.

    Např. testovací třídy a metody v JUnit 4 musely být veřejné – přitom nebyly součástí API, neměl je nikdo volat z jiného kódu. Testy jsou čistě záležitost interní implementace, takže mají být package private. Nebo parametrické testy – používalo se pole polí, jako by Java neznala objektové kolekce nebo iterátory. A navíc všude byl jen typ Object a jméno metody se předávalo jako text – takže žádná kontrola typů kompilátorem, dokonce ani kontrola, zda metoda vůbec existuje. Dále podle mne má být pokud možno každá třída po dokončení konstruktoru plně připravená k použití, pokud na něčem závisí, má to dostat jako parametr konstruktoru a mít uložené ve final fieldech. V JUnit 5 je to snadné, s JUnit 4 se místo toho používaly @BeforeClass anotace a statické ne-final fieldy, což už samo o sobě svědčí, že si to na objekty jen hraje. Navíc pak klidně bylo možné spustit testy na špatně inicializovaném objektu, na což se v lepším případě přišlo, když test spadnul na výjimku.

    Velký krok vpřed také vidím v organizaci testů. Aby test neselhal na první assert už bylo zmíněno, ale je možné vytvářet celou hierarchii assertů – např. první tři asserty jsou na sobě nezávislé, mají proběhnout všechny tři; ale pokud neprojde třetí assert, nemá smysl kontrolovat další asserty, protože by dávaly jen falešný výstup. Dále třeba možnost snadného vytváření vlastních testů, klidně i v hierarchii – např. pokud potřebuju nějaký test spustit pro každý testovací soubor, za běhu pro každý soubor vytvořím test v takové hierarchii, jak jsou soubory uspořádané v adresářích. Nebo testy v embedded třídách – někdy může být testovací třída podstatně větší, než testovaná, takže se vyplatí testy spojit do skupin a každou skupinu dát do samostatné embedded třídy, a nemusím to řešit dlouhými názvy testovacích metod se společnými prefixy. Je pak přehlednější testovací kód, je přehlednější výstup testů.

    OdpovědětVymazat
    Odpovědi
    1. Díky za obšírný komentář. Jestli JUnit 5 přišel/měl přijít s něčím zásadním, je samozřejmě věc názoru a taky perspektivy.

      Co jsem měl já na mysli - jak moc se JUnit odchyluje od původní TDD filozofie a přerůstá někam jinam. Jsem toho názoru, že JUnit by se měl držet toho, co dělá dobře (k tomu lze mít výrady) - unit testování. Ostatní věci by měl přenechávat specializovaným nástrojům a frameworkům, tj. mockování (BDD), spec atd.

      Pro mne je např. kontroverzní ta závislost assertů. To jde proti unit test filozofii. Ale chápu, že to lidi potřebují/vyžadují. Na druhou stranu je otázka, jestli to nechtějí jenom proto, že jsou pohodlní a nechce se jim přemýšlet o designu. Anebo, jestli místo unit testů, nepíšou spíše funkční testy.

      Vymazat
  4. Na podpoře principu jednotkových testů asi už opravdu není v JUnitu co vylepšovat – pro mne je ale na JUnit 5 podstatné to, že nyní můžu mít na kód testů stejné nároky, jako na produkční kód (což podle mne k původní TDD filozofii patří).

    Souhlasím, že jsem při psaní toho komentáře myslel spíš na funkční testy – které jsou podle mne základem TDD. Jednotkové testy by často byly triviální a pak nevidím jejich přínos.

    Když uvedu jako příklad přidání prvku do kolekce – chci otestovat, že pokud se přidání podaří, kolekce bude neprázdná, počet prvků v kolekci se zvětší o jeden a vkládaný prvek bude v kolekci. To jsou tři předpoklady, každý bych měl otestovat nezávisle na splnění ostatních dvou předpokladů, ale zároveň by to měl být jeden test, který testuje úspěšné přidání prvku do kolekce. Bylo by možné ty tři testy zopakovat v jednotkových testech, ale ty by netestovaly nic reálného, protože ta funkce „přidání do kolekce“ se neskládá ze tří částí, je jen jedna, ale má tři různé dopady. Je klidně možné, že je to moje chyba v přemýšlení o designu té funkce, ale já jí nevidím – pak bych prosil o naťuknutí, jak by to mělo být správně.

    OdpovědětVymazat
    Odpovědi
    1. Za sebe bych řekl, že u toho případu s kolekcí, by mi víc sedělo agregování více assertů pro jednu "business" metodu - metoda by měla deklarovat svoje chování a to že tuto, de facto "atomickou", akci můžu ověřit pomocí třech tvrzení, je v pořádku.

      Obecně, jestli asserty agregovat, nebo separovat, je věc designu testů - někdy to může být tak, nebo tak, někdy i kombinovaně.

      Vymazat
    2. Nevím, co přesně myslíte tou agregací – JUnit 5 mi právě umožní snadno ty tři asserty testovat v jedné metodě, ale zároveň jsou na sobě nezávislé – pokud dva ze tří předpokladů neprojdou, vím přesně, které dva neprošli a že třetí je v pořádku. V JUnit 4 to tak jednoduše nešlo, tam platilo, že první nesplněný assert ukončil celou testovací metodu.

      Vymazat
  5. Mně se na JUnit 5 nejvíc líbí jeho rozšiřitelnost. Rozšiřovat JUnit 4 bylo peklo, Rule nebo ClassRule jsou dost omezené a neohrabané, a psát vlastní Runner je hodně overkill (a navíc může být jenom jeden). JUnit 5, to je úplně jiné kafe.

    OdpovědětVymazat
    Odpovědi
    1. To je zajímavý aspekt - rozšiřitelnost. Můžeš uvést konkrétní případ, kdy je to vhodné?

      Vymazat
    2. V mém případě to souvisí s tím, co bylo diskutováno výše -- funkční testování, integrační testování atd. Já považuju za velmi vhodné co možná nejvíc používat stejný framework pro všechny druhy testů, ale pak potřebuju různé možnosti rozšíření: vytvořit dočasný adresář, deploynout aplikaci na server, atd.

      To souvisí s další věcí: lifecycle. JUnit 5 má rozumně definovaný životní cyklus testu a já se můžu navěsit na různé události/fáze; v JUnit 4 opět bída.

      Vymazat