TSM - Comoara din suita JVM - JUnit 5

Mihai Anghel - Senior Java Developer @ Kindred Group

Zilele sunt numărate pentru versiunea a 4-a a librăriei JUnit. Cel mai cuprinzător framework de testare în Java va fi îmbunătăţit în curând și ceea ce urmează îl face cu adevărat performant. JUnit 5 este mai mult decât o librărie, este o platformă pentru testare. Nu doar că rezolvă problemele versiunilor anterioare oferind o interfaţă îmbunătăţită, dar introduce un mecanism nou pentru executarea și descoperirea testelor. Arhitectura stratificată asigură o separare a componentelor și permite conectarea a diferite motoare de executare pentru a menţine compatibilitatea cu versiunile anterioare. Lansarea oficială este planificată pentru primul trimestru al anului 2017, dar până atunci să aruncăm o privire și să identificam motivele pentru care poate fi un candidat bun pentru unit-testing în proiectul următor.

În prima parte, vom urmări cum se scriu testele și vom identifica îmbunătăţirile pe care le aduce versiunea a 5-a și vom încheia prin a înţelege motivele pentru care noua arhitectură este inovatoare.

Migrarea de la JUnit 4

1. Adnotări

Majoritatea adnotărilor sunt redenumite, iar clasele se pot găsi în pachetul org.junit.juniper.api.

JUnit 4 JUnit 5
@Before @BeforeEach
@After @AfterEach
@BeforeClass @BeforeAll
@AfterClass @AfterAll
@Ignored @Disabled("reason")
@RunWith @ExtendWith

2. Aserțiuni

Clasele de aserţiuni au fost mutate din pachetul org.junit.Assert în org.junit.jupiter.api. Assertions și au fost aduse trei îmbunătăţiri.

a) Mesajul în cazul unei erori a fost mutat ultimul în lista de parametri.

 assertEquals(expected, actual, "error message");

b) Expresii lambda

 assertTrue(false, () -> "Error. Expected true");

Exemplul precedent este trivial, dar expresiile lambda fiind evaluate cu întârziere economisesc resurse și timp în cazul în care logica de construcție a mesajului este costisitoare.

c) Asețiuni multiple

 assertAll("Person",
  () -> assertEquals("John", person.getFirstName()),
  () -> assertEquals("Doe", person.getSurname()),
  () -> assertEquals("SW19 5HP", person.getPostCode()));

Această îmbunătăţire este foarte utilă deoarece în precedentele versiuni ale librăriei, execuţia se opreşte după prima evaluare eșuată și nu oferă un rezultat final al aserțiunilor din test. În cazul unei erori, utilizatorului îi va fi afișat următorul mesaj:

AssertionsTest.multipleAssertionsTest:24 Person (2 failures)
  expected: <Doe> but was: <Toe>
  expected: <SW19 5HP> but was: <NW19 3RU>

3. Etichetarea și dezactivarea testelor

Testele pot fi etichetate cu adnotarea @Tag și dezactivate cu @Disabled adăugate la nivelul clasei sau metodei.

4. Supoziții (Assumptions)

Acestea sunt folosite în situaţiile în care aserţiunile trebuie evaluate doar dacă anumite criterii sunt îndeplinite:

 @Test
 void shouldEvaluateSeniors() {
  assumeTrue(person.getAge > 65);
  assertAll("Person",
   () -> assertEquals("John", person.getFirstName()),
   () -> assertEquals("Doe", person.getSurname()));
 }

5. Excepții

Testarea excepțiilor a fost ignorată de cele mai multe ori sau a fost realizată superficial. Vestea bună este aceea că JUnit 5 permite o testare facilă a tipului și a mesajului excepției.

 @Test
 void testException() {
  Throwable e = assertThrows(RuntimeException.class, () -> target.throwEx());
  assertEquals("message", e.getMessage());
 }

6. Teste imbricate (nested)

Librăria JUnit 5 introduce testele imbricate pentru a exprima relațiile complexe între diferite grupuri de teste. De asemenea, permite dezvoltarea într-un stil BDD prin adnotarea testelor cu @DisplayName și atribuirea unui nume lizibil. Clasele imbricate sunt marcate cu adnotarea @Nested pentru a permite integrarea corectă a acestora. Documentaţia oficială prezintă un exemplu foarte bun de astfel de teste.

7. Testarea dinamică

Testele dezvoltate în JUnit 4 sunt statice, în sensul în care acestea sunt definite complet în momentul compilării. Ideea nouă care a fost adusă în ultima versiune introduce un nou tip de test, cel dinamic, care este generat la runtime de către o metodă de tip factory adnotată cu @TestFactory. Această metodă nu reprezintă un test în adevăratul sens al cuvântului, dar este folosită pentru a genera în mod dinamic testele și poate returna Stream, Collection sau Iterable de instanțe ale clasei DynamicTest. Această clasă este @FunctionalInterface ceea ce înseamnă că implementarea testului poate fi creată printr-o expresie lambda.

 @TestFactory
 Collection<DynamicTest> generateDynamicTests() {
  return Arrays.asList(
   DynamicTest.dynamicTest("Test status", () -> assertTrue(p.isMarried())),
   DynamicTest.dynamicTest("Test age", () -> assertEquals(30, p.getAge()))
  );
 }

Avantajul testării dinamice apare când se adoptă testarea data driven.

8. Testarea data driven

Acest tip de testare permite excutarea de multiple iterații ale aceluiași test cu date de intrare diferite pentru a evita duplicarea codului. Această procedură se numește table driven testing în alte librării de testare sau limbaje de programare precum Go, Spock sau Cucumber. În JUnit 5 putem implementa astfel de teste cu ajutorul metodelor de tip factory prin parcurgerea unei colecții de date de test și crearea dinamică a testelor pentru fiecare set de date.

 @TestFactory
 Collection<DynamicTest> generateDynamicTests() {
  return testData()
   .stream()
   .map(d -> dynamicTest("Data:" + d, () -> {
     /** implementation */
   }))
  .collect(Collectors.toList());
 }

9. Extensii

După cum ne spune și numele, această funcționalitate permite programatorilor să administreze ciclul de viaţă al testelor și să extindă logica acestora fără a modifica funcționalitatea existentă. Acest lucru poate fi implementat cu așa numitele puncte de extensie (extension points) care sunt oferite în mod implicit. Din punct de vedere tehnic, acestea sunt interfețe în a căror implementare se definește logica de extensie. Implementările se numesc extensii și acestea pot fi înregistrate folosind adnotarea @ExtendWith la nivelul clasei sau metodei extinse.

Pentru a folosi extensiile trebuie să importăm pachetul org.junit.jupiter.api.extension.

Punct de extensie Rol
TestInstancePostProcessor Definește logica de postprocesare a
testului.
ContainerExecutionCondition Definește interfața pentru execuția
condiționată a containerului.
ParameterResolver Definește interfața pentru rezolvarea
dinamică a parametrilor la runtime.
TestExecutionExceptionHandler Definește interfața pentru a manipula
excepțiile din timpul execuției
testelor.
BeforeAllCallback, BeforeEachCallback, Definesc interfețe pentru administrarea
AfterAllCallback, AfterEachCallback ciclului de viață al testelor.

Arhitectura

JUnit 4 a fost lansat în urmă cu aproximativ 12 ani. De atunci, numeroase lucruri au evoluat în domeniul programării de la Java și JVM pana la IDE-uri și instrumentele de CI. Testele au devenit din ce în ce mai importante în munca de zi cu zi a programatorilor, rezultatele rapide și navigarea facilă fiind elemente cheie ale unei librării performante. Mai mult decât atât, IDE-urile și instrumentele de CI au devenit dependente de detaliile de implementare ale librăriei JUnit pentru a se integra cu aceasta. JUnit 4.12 (ultima versiune) este un artefact monolitic care conține doar interfața pentru testare și motorul de executare al testelor. Mecanismul de descoperire al testelor trebuie implementat separat de către fiecare instrument. Johannes Link, unul dintre iniţiatorii noului proiect, menționa într-un interviu "succesul JUnit ca o platforma previne dezvoltarea JUnit ca un instrument de testare" ("the success of JUnit as a platform prevents the development of JUnit as a test tool"). Ceea ce noul proiect (denumit iniţial JUnit Lambda) a încercat sa rezolve a fost separarea interfeței publice de motorul de executare.

Noua platformă de testare are două părți importante:

  1. interfața (API) pentru dezvoltarea testelor

  2. mecanismul pentru descoperirea și executarea testelor

Despre prima parte am discutat în introducerea articolului, iar în continuare vom detalia modul în care arhitectura a fost realizată. Aceasta este compusă din trei module, determinând astfel o arhitectură decuplată a noii platforme:

  1. un motor pentru descoperirea și executare al testelor;

  2. o interfaţă comună pe care toate motoarele trebuie să o implementeze pentru uniformitate;

  3. un mecanism de orchestrare al motoarelor.

Această abordare de decuplare conduce la arhitectura prezentată în Figura 1.

Figura 1 - Junit 5 Architecture

junit-juniper-api - interfața folosită de programatori pentru dezvoltarea testelor în JUnit 5;

junit-juniper-engine - implementare a junit-platform-engine care execută testele JUnit 5;

junit-vintage-engine - implementare a junit-platform-engine care execută testele JUnit 4;

junit-platform-engine - interfața comună pe care toate motoarele trebuie să o implementeze și să o utilizeze pentru a se înregistra la platforma de lansare (platform-launcher);

junit-platform-launcher - orchestrează motoarele, folosește ServiceLoader pentru descoperirea diferitelor implementări ale motorului (platform-engine) și oferă o interfață instrumentelor pentru a interacționa cu executarea testelor.

Diagrama precedentă poate fi sumarizată ca JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage. Trebuie menționat faptul că platforma oferă utilizatorului flexibilitatea de a folosi propriul motor de testare prin simpla implementare a interfețelor din artefactul junit-platform-engine și înregistrarea acestuia. Compatibilitatea cu versiunile anterioare (a 3-a și a 4-a) este realizată prin motorul JUnit Vintage, iar avantajul este că acesta poate exista în proiect simultan cu JUnit Juniper.

Ca o notă de final, pentru o inspectare a acestei platforme trebuie incluse în fișierul de configurare artefactele junit-juniper-api în secțiunea de dependențe și junit-juniper-engine în secțiunea de plugin.

Concluzii

După cum am observat, s-a realizat un progres major în ultima versiune de JUnit. Cea mai folosită librărie pentru testare în ecosistemul JVM a fost suferit un upgrade serios și a făcut un pas important spre a deveni o platformă de testare. Interfața îmbunătăţită a rezolvat principalele probleme din versiunile anterioare, iar arhitectura stratificată permite o separare clară a componentelor și chiar oferă fundamentele pentru folosirea unui motor de testare propriu.