Hamcrest

Technológiák: JUnit 4.11, Hamcrest 1.3

Bevezetés

Ahogy egyre többet írok unit teszteket, így foglalkoztat a gondolat, hogyan lehet minél hatékonyabb asserteket írni. Elvárásaim ezekkel kapcsolatban a következők. Rövidek legyenek, azaz gyorsan, gond nélkül lehessen ezeket megírni. Más számára is könnyen olvashatóak legyenek. Amennyiben elbuknak, értelmes hibaüzeneteket adjanak. És össze lehessen fűzni akár őket.

Köszönet Lontay Gábornak, aki sokat segített a Hamcrest megértésében.

A poszthoz készült példaprogram elérhető a GitHub-on.

A Wikipedia szerint az asserteknek már a harmadik generációjánál járunk. Kezdetben az assert csak egy logikai kifejezést várt, ami ha a kiértékelés során hamisat adott vissza, a teszt elbukott. (Üzenetet ennek is lehetett paraméterül adni.) A második generációs assertek azonban külön várták az aktuális és az elvárt eredményt, így az üzenetben ki tudták írni, hogy pontosan mik nem stimmeltek.

A harmadik generációs assertek azonban támogatják az assertThat kifejezést, mely várja az aktuális értéket, valamint egy ún. Matcher objektumot.

assertThat([ellenőrizendő érték], [matcher]);

Egy példával talán érthetőbb:

assertThat(bank.getName(), either(containsString("CIB")).or(containsString("BANK")));

Ebből az látszik, hogy fluent interfésszel rendelkezik, és primitív matcherekből különböző konstrukciókkal, pl. logikai műveletekkel, bonyolultabbakat lehet összerakni. Gyakorlatilag ezzel egy DSL-t, azaz egy primitív saját nyelvet rakhatunk össze objektumok (egyezőségének) vizsgálatára, ami inkább deklaratív, mint procedurális. Sőt, a hibaüzenetek is olvashatóak maradnak.

Ehhez képzeljük el, hogy nem csak szöveges összehasonlítások vannak, hanem pl. collectionök kezelésére való műveletek is, pl. olyan feltételeket tudunk szabni, hogy egy vagy több elem benne van-e a collectionben, megfelelő sorrendben-e, stb. Ezen kívül még saját matchereket is lehet implementálni.

Hamcrest

Javaban erre a Hamcrest könyvtár használható (, ami a matchers anagrammája). Ezt az eszközt Joe Walnes fejlesztette ki a jMock mock keretrendszer részeként, majd kiszervezte és a Hamcrest nevet adta neki.

A Hamcrest két rétegből áll, egyrészt a core, mely stabil, osztályai nem változnak, valamint a library, mely collectionök, stringek, JavaBeanek, stb. kezelésére való matchereket tartalmaz, és megjelenhetnek újabb és újabb matcherek. Ennek megfelelően érdemes mindkettőt használni, és a org.hamcrest.CoreMatchers osztály helyett a org.hamcrest.Matchers osztály metódusait statikusan importálni.

Ha már az olvashatóság az egyik legnagyobb előny, nézzük, hogy mi történik klasszikus esetben, és mi az assertThat-tel. A JUnit még tartja magát.

assert(bank.getName().equals("CIB BANK"));
// ==> hibaüzenet:
// java.lang.AssertionError
// ... stacktrace

assertEquals("CIB BANK", bank.getName());
// org.junit.ComparisonFailure: expected:<C[IB BA]NK> but was:<C[B B]NK>
// ... stacktrace

assertThat(bank.getName(), is(equalTo("CIB BANK")));
// java.lang.AssertionError:
// Expected: is "CIB BANK"
//     but: was "CB BNK"
// ... stacktrace

És nézzünk egy bonyolultabb feltételt:

assertTrue(bank.getName().contains("CIB") || bank.getName().contains("BANK"));
// java.lang.AssertionError
// ... stacktrace

assertThat(bank.getName(), either(containsString("CIB")).or(containsString("BANK")));
// java.lang.AssertionError:
// Expected: (a string containing "CIB" or a string containing "BANK")
//     but: was "CB BNK"
// ... stacktrace

Sőt, az assertThat egy override-olt metódusa String-et is elfogad.

assertThat("name", bank.getName(), either(containsString("CIB")).or(containsString("BANK")));
// java.lang.AssertionError: name
// Expected: (a string containing "CIB" or a string containing "BANK")
//     but: was "CB BNK"
// ... stacktrace

Ahogy a tutorial is írja, rengeteg core matcher jön a Hamcresttel, erről egy jó kis egy oldalas referencia PDF is található.

Nyilván vannak általános célú matcherek, mint egyenlőség ellenőrzés, relációk ellenőrzése, null ellenőrzés, String-ek összehasonlítása, stb. Érdemes ezt a cikket is elolvasni. Ami talán érdekesebb az a JavaBeanek és a kollekciók kezelése.

HasProperty

A hasProperty metódussal azt nézhetjük meg, hogy egy adott objektumnak a megadott property-je nekünk megfelelő-e.

assertThat(bank, hasProperty("name", equalTo("CIB BANK")));

Collections

A collectionök összehasonlítására a leggyakrabban használt metódusok a contains, containsInAnyOrder, hasItem, hasItems és isIn.

A contains elnevezése nagyon megtévesztő, itt ugyanis a két collection pontos egyezőségét várja el, a sorrendet is beleértve.

assertThat(bank.getCards(), contains("CIB VISA Inspire", "CIB MasterCard Gold", "CIB Visa Internetkártya"));

Más sorrendben adva meg az értékeket, a teszt elbukik, akárcsak akkor, ha egyet kihagyunk. Ekkor szép hibaüzenetet is kapunk:

java.lang.AssertionError:
Expected: iterable containing ["CIB VISA Inspire", "CIB MasterCard Gold", "CIB Vis Internetkártya"]
     but: item 2: was "CIB Visa Internetkártya"
... stacktrace

A hasItem, hasItems már az elnevezésének megfelelően működik, itt ugyanis tényleg azt nézi, hogy ténylegesen szerepel-e az elem(ek) a collectionben.

assertThat(bank.getCards(), hasItem("CIB MasterCard Gold"));

assertThat(bank.getCards(), hasItems("CIB VISA Inspire", "CIB MasterCard Gold"));

Az isIn metódussal fordítva adhatjuk meg:

assertThat("CIB MasterCard Gold", isIn(bank.getCards()));

Ha a hasItem metódusnak matchert akarunk paraméterül átadni, hamar meglepetés érhet. A következő kód ugyanis nem működik:

assertThat(bank.getAddresses(), hasItem(hasProperty("zip", equalTo("5600"))));

Helyette a következő trükköt kell alkalmaznunk:

assertThat((List<Object>)(List)bank.getAddresses(), hasItem(hasProperty("zip", equalTo("5600"))));

Érdekessége, hogy míg a csak List-té castolást az IDEA/Eclipse elfogadja, parancssori fordításkor elszáll, így kell a List<Object>-té castolás is.

Saját Matcher implementálása

A matcherek a Matcher interfészt implementálják, de mi ne ezt implementáljuk, hanem induljunk ki valami speciális implementáló osztályból, melyből leszármaztathatunk. Amennyiben egy objektum egy értékét akarjuk hasonlítani, akkor használjuk a FeatureMatcher osztályt. ennek featureValueOf metódusa adja vissza a kívánt értéket.

public class HasNameMatcher extends FeatureMatcher<Bank, String> {

    @Factory
    public static HasNameMatcher hasName(Matcher<? super String> matcher) {
        return new HasNameMatcher(matcher, "name", "name");
    }

    public HasNameMatcher(Matcher<? super String> subMatcher, String featureDescription, String featureName) {
        super(subMatcher, featureDescription, featureName);
    }

    @Override
    protected String featureValueOf(Bank bank) {
        return bank.getName();
    }
}

És ezt a következőképpen használhatjuk fel:

assertThat(bank, hasName(equalTo("CIB BANK")));

Vagy hasonlóan egy másik FeatureMatcher implementációt, de már collectionre:

assertThat(bank.getAddresses(), hasItem(hasZip(equalTo("5600"))));

Ha még ennél is messzebb akarunk menni, akkor egy TypeSafeDiagnosingMatcher leszármazottat érdemes készítenünk (, ez amúgy mely a FeatureMatcher őse).

public class HasAddressWithZip extends TypeSafeDiagnosingMatcher<Bank> {

    @Factory
    public static HasAddressWithZip hasAddressWithZip(Matcher<? super String> valueMatcher) {
        return new HasAddressWithZip(valueMatcher);
    }

    private Matcher<? super String> valueMatcher;


    public HasAddressWithZip(Matcher<? super String> valueMatcher) {
        this.valueMatcher = valueMatcher;
    }

    @Override
    protected boolean matchesSafely(Bank bank, Description description) {
        List<String> zips = new ArrayList<>();
        for (Address address: bank.getAddresses()) {
            if (valueMatcher.matches(address.getZip())) {
                return true;
            }
            zips.add(address.getZip());
        }
        description.appendText(" zip codes found: ").appendValueList("[", ",", "]", zips);
        return false;
    }

    @Override
    public void describeTo(Description description) {
        description.appendText(" address with zip code ").appendDescriptionOf(valueMatcher);
    }
}

És a használata:

assertThat(bank, hasAddressWithZip(equalTo("5600")));

Ha ez sem elég, akkor használjuk a BaseMatcher osztályt, ennek leszármazottja a TypeSafeDiagnosingMatcher.

A példákban látható, hogy factory metódusokat alkalmaztunk, mely lehetővé teszi a könnyebb olvashatóságot az assertThat metódus paraméterként. Valamint elláttuk a @Factory metódussal. Ez arra való, hogy a Hamcrestben van egy generátor, mely képes kigyűjteni ezen metódusokat egy osztályba, így nem kell minden osztályt importálnunk. A org.hamcrest.Matchers oszály is így készül.

Jó tanácsok

Amennyiben matchereket használ vagy ír az ember, először mindenképp próbáljuk ki a negatív ágat, hogy használható hibaüzenetet ír-e ki.

Megoszlanak arról a vélemények, hogy egy teszt esetben egy assertet lehet-e csak használni. Én nem vagyok ennyire szigorú, szimpatikus a következő megfogalmazás: “test one logical CONCEPT per test”. Ez általában akkor teljesül, ha ugyanazon az objektumon végzel assertet, logikailag összetartozó feltételeket adsz meg. Gyakran egyszerűbb kifejezni és olvasni, ha több assert kifejezést használsz.

Történeti háttér

A JUnit fejlesztői úgy döntöttek, hogy a JUnit részévé teszik. A 4.4 verzióban jelent meg, gyakorlatilag a hamcrest-core projekt osztályait a JUnitba másolták. Ez volt az első alkalom, hogy egy third-party library bekerült a JUnitba, ami kifejezetten híres volt arról, hogy nem akart plusz függőségeket. Persze a régi assert metódusok, pl. assertEquals, assertTrue, stb. megmaradtak. Megjelent tehát a org.hamcrest csomag, benne a CoreMatchers osztály, valamint az org.junit.matchers csomagban a JUnitMatchers osztály. Mindez 2007-ben történt.

Sajnos azonban hoztak egy Mavennel szemben álló döntést, ezeket a class-okat elhelyezték a junit-4.4.jar artifact-ba. A JUnit csak a core osztályait tartalmazta. Ha viszont a library osztályaira is szükség volt, és az ember csak berakta a JUnit mellé, és nem volt megfelelő a jar fájlok sorrendje a classpath-on, elég nagy galibát tudott okozni. Ennek megoldására jelent meg a junit-dep artifact, mely nem tartalmazza a Hamcrestes osztályokat, viszont van dependency a hamcrest-core-ra. Ezt csak a JUnit 4.11-ben javították, ahol a junit artifact végre nem tartalmazza a Hamcrestes osztályokat, hanem egy tranzitív függőség van a hamcrest-core 1.3 (jelenleg legfrissebb) verziójára. Így mindenképpen ezt javaslom használni. A JUnitMatchers osztály metódusai deprecated-ek lettek, helyette a org.hamcrest.CoreMatchers metódusai használandók.

Azért is érdemes a legutolsó JUnit-ot használni, mert korábbi verzióban az org.junit.Assert.assertThat nem használta az ún. mismatch descriptiont, így a hibaüzenetek nem lettek olyan beszédesek, és figyelni kellett, hogy MatcherAssert.assertThat legyen használva. Szerencsére a JUnt 4.11 verziójában ezt már javították.

Úgy látszik, hogy ebben a körben nagyon népszerű a különböző library-k összecsomagolása. A Mockito esetén is van egy mockito-all artifact, mely a Hamcrest és az Objenesis osztályait csomagolja egybe. Ez akkor okoz kellemetlen meglepetést, mikor a Hamcrest egy újabb verzióját akarjuk használni, és nem tűnik fel, hogy a régebbi verzió osztályai már ott vannak a classpath-on.

Egyebek

Persze lehet rengeteg matchert találni a neten is, pl. szövegre, dátumra, Excel, JSON és XML formátumokra is, stb.

Megjegyzendő, hogy a Hamcrest nem csak JUnittal együtt hasznos, hanem más eszközök is használják. Több nyelvre is portolták, mint Python, Ruby, Objective C, PHP, Erlang, ActionScript.