Testing

Mockok és dependency injection Java tesztekben

Purece Bálint • 2020. február 03.

Annak, hogy érdemes-e tesztelni, mára egyértelműnek kell lennie.

Manuális vagy automata tesztelés?

Az is könnyen látható, hogy a kézi tesztelésnél sokkal előremutatóbb ha automatizált a teszthalmaz. A tesztelés célja, hogy bármikor gyorsan eldönthető legyen, azt csinálja-e a meglévő szoftver, amit elvárunk tőle. Akár minden kód módosítás után. Manuálisan végigtesztelni egy rendszert napokba de akár hetekbe is telhet. Még egy egyszerű kódon végigmenni kézzel is tarthat több tíz percig. Emellett a kézi folyamatban több a hiba lehetőség, míg automa tesztek esetében egy erős állítás az, ha a tesztek sikeresek. Az előbbi esetben kevésbé bízhatunk az eredményben, fals pozitívak és fals negatívak is könnyebben fordulhatnak elő. Ezek miatt amikor és ahol csak lehet, az automatizált teszteket kell előnyben részesíteni.

Hogy mikor íródjanak a tesztek, ez nagyban múlik a fejlesztők fegyelmén. Ha az utólagos tesztírást ugyanolyan érdeklődéssel végeznék mint magát a production kód írását, akkor elviekben mindegy lenne, mikor készülnek el. Általában viszont az utólagos tesztírás egy kötelezően elvégzendő rossz a fejlesztők szemében, amin igyekeznek mielőbb túladni, sokszor az sem segít, hogy a tesztelésre mint a szoftverfejlesztés egyfajta másodlagos fontosságú részére gondolnak mind technikai, mind vezetői szinten. Az sem rikta, hogy bizonyos lehetséges végrehajtási útvonalakat utólag elfelejtünk tesztelni.

Itt jön a képbe a Test Driven Development, azaz tesztvezérelt fejlesztés. A TDD szerint a tesztek megírásával kezdjük egy funkció fejlesztését. Így a teszt mint specifikáció és dokumentáció is szerepet játszik. Természetesen a teszteknek elég egyszerűnek kell lenniük, különben őket is tesztelnünk kéne. Nem mindig könnyű, és tanulást igényel ez a fordított sorrend, ahol előbb létezik egy teszt mint amit tesztel, viszont vannak egyértelmű előnyei. Ráveszi a fejlesztőt, hogy ami kódot ír, az tesztelhető legyen.

A TDD minden előnyével együtt mégsincs mindennapos használatban, legalábbis nem az eredeti formájában. Igen szigorú feltételeket szab, például hogy teszteket csak addig írhatunk, míg egy elbukik, majd csak annyi éles kódot szabad írni, amennyivel az összes teszt sikeresen lefut. Bizonyos esetekben kifejezetten hátráltathatna ez a megközelítés, például frontend teszteléskor. Ami fontos, hogy bármilyen módon tesztelünk is, a teszt eredményeknek legyen jelentőségük, megbízhassunk bennük.

Teszt lefedettség

Ez egy olyan metrika, ami arról próbál számszerű információt mondani, milyen minőségűek a tesztjeink.

Azt méri, a kódbázis mekkora részét érinti egy teszthalmaz. Hogy mekkora egységeket nézünk lefedettség szempontjából, változhat. Lehet például sorok, meghívott függvények, feltételek teljesült ágai szerinti egységekben gondolkodni.
Egy egyszerű példa teszthalmaz következik, ami definiálja is, mit várunk el a Greeter egységünktől.

@Before
public void setUp(){
    greeter = new Greeter();
}

@Test(expected = IllegalArgumentException.class)
public void testEmptyGreet() {
    greeter.Greet(null);
}

@Test
public void testGreetGuy() {
    Assert.assertTrue(greeter.Greet("John")
      .filter(this::callsMeLord).count() > 0);
}

@Test
public void testGreetGal() {
    Assert.assertTrue(greeter.Greet("Jane")
      .filter(this::callsMeLady).count() > 0);
}

@Test
public void testSomeone() {
    Assert.assertTrue(greeter.Greet("Jonathan")
      .filter(element -> element.equals("yeee")).count() > 0);
}

private boolean callsMeLord(String token) {
    if (token.equals("lord")) {
    return true;
    }
    return false;
}

private boolean callsMeLady(String token) {
    if (token.equals("lady")) {
    return true;
    }
    return false;
}

Ez pedig a production kód.

public class Greeter {

    public Stream<String> Greet(String whom) {
    if (whom == null) {
        throw new IllegalArgumentException();
    }

    if (whom.equals("John")) {
        return Stream.of("Hello", "my", "lord", "!");
    }

    if (whom.equals("Jane")) {
        return Stream.of("Hello", "my", "lady", "!");
    }

    return Stream.of("Are", "yeee", "new", "here", "?");
    }
}

Ennek a teszthalmaznak 100% sor lefedettsége van. Ez azonban nem jelenti azt, hogy a kódbázis mindent helyesen csinál. Elképzelhető, hogy például a “John Jane” esetben valami teljesen másnak kéne legyen a működésnek, viszont erre való teszt hiányában van egy teljesen lefedő halmaz, amiben minden teszt sikeres. Ebben az a tanulság, hogy a magas lefedettség önmagában nem garancia a helyes működésre, ha nincs elég teszt a működési szemantikára, viszont ha alacsony a lefedettség, akkor a kód egy jelentős részéről semmit sem tudunk. Az ipari standard rendszerint 80% körül mozog.

Tesztelhető kód

Amikor a kód egy kisebb részét teszteljük, hogy önmagában jól működik-e, esetleges függőségeivel megfelelően kommunikál-e, akkor unit tesztet írunk. Ha az illető unit (például egy metódus vagy osztály) független, akkor csak az argumentumaitól függ, hogyan működik, ilyenkor könnyű tesztet írni rá.

public int countWords(String line) {
    return line.split(" ").length;
}

Adott bementre várunk adott eredményt, mellékhatások nélkül.

Általában viszont igenis vannak függőségeik unitok-nak. Például nézzünk egy osztályt, ami valamilyen forrásból származó adatot módosít.

Példa bedrótozott függőségre
public class DataTransformer {

    DataSource dataSource = new DBDataSource("some config");

    public int getSourceCount() {
    return dataSource.get().size();
    }
}

A DBDataSource vélhetően egy adatbázisból olvassa az adatokat éles üzemmódban. A példa kedvéért csak egy egyszerű funkciója van, vissza tudja adni az adathalmaz méretét. Mivel ebben a példában explicit módon példányosítjuk, nincs módunk tesztelés céljából kicserélni egy mock objektumra. Ezt pedig szeretnénk, hiszen csak a DataTransformer osztály működését szeretnénk megerősíteni, nem szándékunk felállítani egy teljes környezetet, ahonnan a DBDataSource olvasni tud. Ezt a problémát a függőségek injektálásával megoldhatjuk. Ez egyszerűen annyit jelent, hogy az osztályunknak készen kell megkapnia a függőségét, nem az ő feladata annak a létrehozása. Az osztály csak annyit tud, hogy egy DataSource típussal kompatibilis objektumot várhat. Az injektálás történhet például konstruktoron keresztül.

Példa mock source
public class DataSourceMock implements DataSource {
    @Override
    public List<Data> get() {
    return Arrays.asList(
      new Data("resolution", "fullhd"), new Data("memory", "16GB"));
    }
}

Egy egyszerű mock data source osztályt írva kiváltjuk a tényleges adat forrást. Módosítjuk a transformer-t, hogy kívülről várja a forrást.

Függőség injektálása
public class DataTransformer {

    DataSource dataSource;

    public DataTransformer(DataSource dataSource) {
    this.dataSource = dataSource;
    }

    public int getSourceCount() {
    return dataSource.get().size();
    }
}

Így már a tesztünkben (és a production kódban is) paraméter lett, hogy pontosan milyen source objektummal jön létre egy transformer. Nincs szükség egy tényleges adatbázis indítására, ha nem az a célunk, csak egy típuskompatibilis objektumra van szükség, amit bedughatunk.

public class DataTransformerTest {

    DataTransformer dataTransformer;

    @Before
    public void setUp() {
    dataTransformer = new DataTransformer(new DBDataSourceMock());

    }

    @Test
    public void testSourceCount() {
    Assert.assertEquals(dataTransformer.getSourceCount(), 2);
    }
}

Mockito

Komponensekre mockokat írni nem mindig olyan könnyű mint a fenti példában. Előfordulhat például, hogy egy interfészen sok metódust kéne mockolni, holott csak párra lenne szükségünk, a többi maradhat definiálatlan. Ezen nehézségek feloldására léteznek framework-ök. Például a Mockito.

A Mockito futásidőben leszármaztat egy adott osztályból, és felülírhatjuk metódusainak visszatérési értékét, referenciát kaphatunk az argumentumokra, kivételt dobhatunk, akár tovább is irányíthatunk az eredeti metódusra, ha létre tud hozni az eredeti osztályból egy példányt. Ha ezenél bonyolultabbra van szükség, lehet hogy a Mockito nem tud megbírkózni vele, és mégis kézzel kell megírni a mockot.

A fenti teszt Mockito-ban
@RunWith(MockitoJUnitRunner.class)
public class DataTransformerTestMockito {
    @Mock
    DataSource dataSource;
    DataTransformer dataTransformer;

    @Before
    public void setUp() {
    Mockito.when(dataSource.get())
      .thenReturn(Arrays.asList(
        new Data("resolution", "fullhd"), new Data("memory", "16GB")));

    dataTransformer = new DataTransformer(dataSource);

    }

    @Test
    public void testSourceCount() {
    Assert.assertEquals(dataTransformer.getSourceCount(), 2);
    Mockito.verify(dataSource, Mockito.times(1)).get();
    }
}

Ami változott a fentihez képest, hogy van egy mock példányunk, (lásd @Mock annotáció), a MockitoJUnitRunner-el futtatjuk a tesztet a JUnit saját runnere helyett ((@RunWith annotáció), valamint a mockot konfiguráljuk a nekünk megfelelő működéssel. A teszt szemszögéből a mock olyan mintha az eredeti objektum lenne. A fenti tesztben példát látunk arra, hogyan lehet azt ellenőrizni, egy mockon egy metódus pontosan egyszer hívódott-e meg. Hasonló módon lehet alsó és felső korlátokat is ellenőrizni.

A Mockito megpróbál elég jó alapértelmezett értéket visszaadni olyan metódusokból, amiket nem konfigurálunk explicit módon. Primitív típusok esetében az alapértelmezett érték, kollekciók esetében üres példány, további referenciák esetében null, stb.

Átadott argumentumok elkérése

Argument Captors-ok használatával el lehet kérni, egy mock adott metódusa milyen értékekkel hívódott.

Példa argument captor használatára
//...
@Mock
DataSource dataSource;
ArgumentCaptor<String> stringCaptor;
//...
@Test
public void testSourceCount() {
    dataSource.setDataSourceName("New name");
    stringCaptor = ArgumentCaptor.forClass(String.class);
    Mockito.verify(dataSource).setDataSourceName(stringCaptor.capture());
    Assert.assertEquals("New name",stringCaptor.getValue());
}
Ugyanaz egyszerűbben, @Captor annotációval

Így nem kell kézzel létrehozni a captort.

//...
@Mock
DataSource dataSource;
@Captor
ArgumentCaptor<String> stringCaptor;
//...
@Test
public void testSourceCount() {
    dataSource.setDataSourceName("New name");
    Mockito.verify(dataSource).setDataSourceName(stringCaptor.capture());
    Assert.assertEquals("New name",stringCaptor.getValue());
}

Mock mellékhatásokkal

@Mock
DataSource dataSource;

@Test
public void testSourceCount() {
   Answer<Void> answer = invocation -> {
    Object argument = invocation.getArgument(0);
    System.out.println(argument);
    return null;
   };
   Mockito.doAnswer(answer).when(dataSource).mergeWith(any());
   dataSource.mergeWith(new Data("dummyname", "dummyvalue"));
}

Lehetséges mellékhatásokat is mockolni Answer definiálásával. Az megkapja az invocation környezetet, amiből kinyerhetjük az átadott argumentumokat. Ezzel már tudunk mellékhatásokat létrehozni. Void visszatérésű metódus esetében az Answer null-al kell visszatérjen.

Guice

Említettük a függőség injektálást (dependency injection). Amikor a komponenseink és a tranzitív függőségeik már elég bonyolultak hogy ne akarjuk kézzel létrehozni őket, hasznosnak bizonyul egy erre készített framework. A Guice egy ilyen népszerű megoldás.

A Guice alapvető működése, hogy egy injector, amelyet modulok segítségével hoztunk létre, példányokat tud nekünk készíteni kitériumok, mint típus, név, annotációk alapján. Ezek a modulok definiálják a kötéseket a kritériumok és a végén elkérhető példányok között. Így könnyű konfigurációval módosítani, hogy különböző környezetekben, mint például az éles üzem és a tesztüzem, mi kerül injektálásra. A függő komponensek számára teljesen átlátszó ez a típus kompatibilitás szintjén.

Példa guice modul
public class DataModule extends AbstractModule {
  @Override 
  protected void configure() {
    bind(DataSource.class).to(DatabaseDataSource.class);
  }
}

Ez egy nagyon egyszerű példa egy kötésre, ami szerint ha DataSource objektumot kérünk az injektortól, DatabaseDataSource típust próbál példányosítani. Tranzitív kötések is lehetségesek, így a DatabaseDataSource típus tovább lehet vezetve egy harmadik, természetesen kompatibilis típusra. Ha a Guice számára nem egyértelmű, valamilyen kritériumok mentén mit kéne példányosítania, kivételt dob és hasznos hibaüzenetekkel leáll.

@Provides annotáció
public class DataModule extends AbstractModule {
  @Override 
  protected void configure() {
    // üres
  }

  @Provides
  public DataSource provideDataSource() {
      // props előállítása
      return new DatabaseDataSource(props);
  }
}

A @Provides annotációval ellátott metódusokban is lehet injektálható példányokat készíteni, ez esetben a példányosítás a metódus feladata.

Név rendelése injektálható elemekhez
public class DataModule extends AbstractModule {
  @Override 
  protected void configure() {
    // üres
  }

  @Provides
  @Named("mydatasource")
  public DataSource provideDataSource() {
      // props előállítása
      return new DatabaseDataSource(props);
  }
}

A típuson kívül azonosítókkal is lehet tovább specializálni, mit szeretnénk az injektortól elkérni, vagy más komponensbe injektálni. Még egyéb módon is lehet kritériumokat szabni, pl. saját annotációval, a részleteket lásd a dokumentációban.

Példány elkérése típus alapján injektortól
public class SystemUsingGuice {

    Injector injector;

    @Before
    public void setUp() {
    injector = Guice.createInjector(new GuiceModule());
    }

    @Test
    public void getInjected() {
    DataSource dataSource = injector.getInstance(DataSource.class);
    System.out.println(dataSource.getClass().getName());
    }
}

Ebben a példában egy injektor-t készítünk egy modul alapján, és egy DataSource példányt kérünk tőle.

Példány elkérése típus és név alapján injektortól
public class SystemUsingGuice {

    Injector injector;

    @Before
    public void setUp() {
    injector = Guice.createInjector(new GuiceModule());
    }

    @Test
    public void getInjected() {
    DataSource dataSource =
      injector.getInstance(Key.get(DataSource.class, Names.named("mydatasource")));
    System.out.println(dataSource.getClass().getName());
    }
}
További függőségek injektálása osztályba
public class DatabaseDataSource implements DataSource {
  private final DatabaseConnector databaseConnector;

  @Inject
  public DatabaseDataSource(DatabaseConnector databaseConnector) {
    this.databaseConnector = databaseConnector;
}

Az @Inject annotációval megjelölt konstruktorokba (vagy egyes metódusokba, pl. setterekbe, vagy mezőkbe) a tartalmazó osztály példányosításakor (az injektor által) a megadott kritériumok alapján próbál rekurzívan példányt injektálni a Guice. Ezáltal ha van minden injekció esetében pontosan egy megfelelő objektum, amit a modulok definiálnak, akkor a teljes függőségi fa felépül.

Adott példányhoz kötés

Lehetséges adott kritériumokat egy konkrét példányhoz is kötni egy modul configure metódusában. Alább erre látunk példát.

bind(String.class)
    .annotatedWith(Names.named("VERSION"))
    .toInstance("3.1.2");
Modulok felülírása

Modulokban megadott definíciókat felül lehet írni további modulokban a Modules.override(Module…).with(Module…) metódusok használatával. Ez olyankor hasznos, amikor csak pár definíciót módosítanánk duplikált modul kód nélkül.

Összefoglalás

Érveket állítottunk fel amellett, hogy automata teszteket írjunk. Láttunk bevezető példájat, hogyan használhatjuk a Mockito frameworkot futásidőben való mockok létrehozására. Megnéztük, mi a dependency injection, hogyan használható erre a Guice modulokban való konfigurációval.

További érdekességek lehetnek a PowerMock(ito), amely erősebb eszközöket ad a kezünkbe, és a Guice további lehetőségei.