Mockito

A héten JMS-en kommunikáló alkalmazást kellett tesztelnem. Azt kellett ellenőrizni, hogy a sorokon beérkező XML formátumú üzeneteket megfelelőképpen képes fogadni, feldolgozni, ellenőrizni. A választ szintén JMS-en küldi el. Ehhez nem akartam valami JMS provider (pl. WebSphere MQ) telepíteni, helyette csak a fogadást, feldolgozást és ellenőrzést végző modult (osztályt) szerettem volna teszteseteknek alávetni. A környezet Spring, a teszteseteket JUnit 4-ben implementáltam.

Használhattam volna a Mockrunner eszközt, mely egy teljes JMS providert szimulál, azaz a JMS API-t stub objektumokkal (lásd később) valósítja meg. Ez gyakorlatilag egy olyan megvalósítás, mely a Destination objektumokat (Queue, Topic) kollekciókkal valósítja meg, melyekbe a JMS API hívásokkal lehet üzeneteket tenni, de vannak külön metódusok, melyekkel aztán ezeket ellenőrizni lehet a tesztesetben.

Ez azonban már inkább integrációs teszt lett volna, és nem az üzenetkezelést akartam tesztelni, csupán az üzeneteket feldolgozó logikát.

A JMS műveletek már külön osztályba voltak csoportosítva (JmsCommService), elválasztva a feldolgozástól, így csupán ezt kellett lecserélnem. A Springhez illeszkedve ezen osztály egy interfészt valósított meg (CommService), melyre a tesztelendő osztályom (DefaultProcessService implements ProcessService) dependency injectionnel hivatkozott. A komponensek ezen laza csatolása lehetővé teszi (hiszen az egyik csak a másiknak az interfészét ismeri), hogy a konténernek (jelen esetben a pehelysúlyú Spring) megadjuk, hogy JUnit tesztelésnél ne az alapértelmezett implementációt töltse be, hanem helyette egy tesztelésre előkészített osztályt.

Használhattam volna un. stub osztályt, ami az interfésznek (CommService) egy saját implementációja, és az adott tesztesetre van felkészítve. Ennek viszont több hátulütője is van. Egyrészt bizonyos logikát külön osztályba kell szervezni, így a teszt kód nem csak a JUnit tesztben van, így kevésbé átlátható. Valamint tesztesetenként (vagy legalábbis bizonyos csoportonként) különböző stub osztályokat kellett volna létrehoznom. Ekkor, ha egy interfésznek sok metódusa van, javasolt absztrakt adapter osztályt készíteni a Swinghez hasonlóan (nincs köze az adapter tervezési mintához), mely üres metódusokkal implementálja az interfészt, és ebből kell csak leszármaztatni, és a teszteléshez szükséges metódusokat implementálni.

Az “The art of unit testing” könyv megkülönbözteti a stub objektumtól a mock objektum fogalmát, mely a teszteset során azt ellenőrzi, hogy az adott objektummal történt-e tényleges interakció. A stubnál annyival több, hogy saját magára is állapít meg feltételeket, melyeknek a teszt során teljesülnie kell, pl. milyen metódusai lettek meghívva, hányszor, milyen paraméterekkel, stb.

Martin Fowler oldalán ennél több definíció is található. Gerard Meszaros a “XUnit Test Patterns” könyvben ezen segéd objektumokat “Test double” gyűjtőnéven illeti, és a következő kategóriákba sorolja: dummy, fake, stub, spy, mock.

A Mockrunner framework elnevezéséből is látszik, hogy a fogalmakat gyakran keverik. A Mockrunner a JMS API interfészeit stubolja. Ugyanígy pl. a spring-test modul korábbi neve spring-mock volt, pedig az is a Java EE interfészeit stub-olja.

A stub/mock objektumokat használhatjuk, ha

  • Az eredeti objektum állapota nem megjósolható, külső tényezőktől függő
  • Az eredeti objektum felépítése bonyolult, lassú, sok erőforrás igénylő művelet
  • Az eredeti objektumok külső erőforrásokhoz fér hozzá, melyek állapotát nehéz befolyásolni. Pl. hálózati kapcsolatot használó objektum esetén mock objektum szimulálhatja a hálózat megszakadást, stb.

Mock framework használatával megtakaríthatjuk, hogy az interfészeket magunk implementáljuk. Ezt megteszi a framework, az általunk megadott szabályok alapján. A két legelterjedtebb mock framework az EasyMock és jMock. A Mockito framework íróját is ezek ihlették, de ezeknél is egyszerűbb API-val rendelkező eszközt készített. Az előbbieket expect-run-verify library-knek nevezi. Azoknál először definiálni kell, hogy mit vársz el, majd lefuttatni a tesztet, és ellenőrizni az elvártakat. A Mockitonál ezzel szemben a futtatás előtt stubbolsz (adod meg, hogy hogy legyen a metódus implementálva), és az után teszel fel kérdéseket, azaz ellenőrzöd le a működést.

Az alkalmazás egyszerűsített osztálydiagramja az alábbi ábrán látható.

Osztálydiagram

A DefaultProcessService kapja az üzenetet (ping), és meghívja a CommService sendMessage() metódusát, átadva egy szöveges üzenetet (pong).

Amennyiben a processService.processMessage() metódus egy String objektumot várna, egyszerű lenne a teszt metódusunk.

...
import static org.mockito.Mockito.*;
import static org.junit.Assert.*;

public class ProcessServiceTest {

@Test
public void testProcessMessage() throws Exception {
 // Tesztelendő objektum előkészítése
 DefaultProcessService processService = new DefaultProcessService();

 // Mock objektum előállítása
 CommService commService = mock(CommService.class);

 // Mock objektumra hivatkozás beállítása
 processService.setCommService(commService);

 // Futtatás
 processService.processMessage("ping");

 // Ellenőrzés
 verify(commService).sendMessage(eq("pong"));
}

}

Hogy a kód könnyebben olvasható legyen, statikusan importálva vannak a Mockito metódusai. Az egyszerűség kedvéért nem a Spring SpringJUnit4ClassRunner osztálya futtatja a tesztesetet, hanem szerepel benne a DefaultProcessService osztály példányosítása. A teszteset a CommService interfészből készít egy mock objektumot, és erre állítja a DefaultProcessService hivatkozását. Aztán lefuttatja a processMessage metódust. Ez a háttérben meghívja a mock DefaultProcessService sendMessage() metódusát. Ezt követi az ellenőrzés. Ez azt mondja, hogy a hívás során meg kellett hívni a CommService sendMessage() metódusát úgy, hogy a paraméternek meg kellett egyeznie a pong szöveggel.

Nézzük a tesztesetet, ha a processMessage() TextMessage paramétert vár.

...
import static org.mockito.Mockito.*;
import static org.junit.Assert.*;

public class ProcessServiceTest {

@Test
public void testProcessMessage() throws Exception {
 // Eredeti objektum példányosítása
 DefaultProcessService processService = new DefaultProcessService();

 // Mock objektum előállítása
 TextMessage message = mock(TextMessage.class);
 CommService commService = mock(CommService.class);

 // Mock objektumra hivatkozás beállítása
 processService.setCommService(commService);

 // Stub-bolás
 when(message.getText()).thenReturn("ping");

 // Futtatás
 processService.processMessage(message);

 // Ellenőrzés
 verify(commService).sendMessage(eq("pong"));
}

}

Itt a teszteset a TextMessage interfészből is csinál egy mock objektumot, és ezután stubbolja azt, méghozzá úgy, hogy amennyiben meghívják a getText() metódusát, adja vissza a ping szöveget.

Amennyiben a sendMessage() metódus hívásának paraméterét egyéb ellenőrzéseknek is alá akarjuk vetni, az ArgumentCaptor osztályt kell használnunk.

ArgumentCaptor argument = ArgumentCaptor.forClass(String.class);
verify(commService).sendMessage(argument);
String param = argument.getValue();
assertEquals("pong", param);

A Mockito ezen kívül rengeteg egyéb dolgot is tud, konkrét osztályt is tud mockolni, a when feltételben a paraméter értékétől függően adni vissza eredményt, a hívások számát és sorrendjét ellenőrizni, valamint akár nem mockolt valós objektumok hívásait is lehallgatni (Spy), stb.

A mock objektumoknak persze hátulütőik is akadnak, könnyen beleeshetünk a csapdába, hogy nem black box tesztelést végzünk az interfész alapján, hanem az implementáció alapján nézzük meg, hogy melyik metódust kellett volna hívni, és hányszor. Így egy refaktoring után hibára futhatnak a teszteseteink, és ilyenkor azokat is karban kell tartani. Gyakorlatilag nem az objektum viselkedését teszteljük, hanem annak kölcsönhatásait más objektumokkal.