Egységtesztelés Javában

Kategória: Java külső könyvtárak.

Az egységtesztelés elméletével a programozás bevezetőbe olvashatunk. Ebben a részben a Java egységteszteléssel ismerkedünk meg: először átnézzük a legnépszerűbb egységteszt könyvtárat, a junit-ot, majd megismerünk néhány ún. mockolási lehetőséget, végül elméleti szempontból járjuk körbe a témát.

És igen: bármily meglepő, de annak ellenére, hogy alapvető dologról van szó, és létezik de facto szabvány, nem része a Java szabvány könyvtárnak.

JUnit

A JUnit a legnépszerűbb Java egységteszt keretrendszer.

Egy példa

A bevezetőben már láttunk példát, de most ismételjük meg, és járjuk körbe kicsit részletesebben! Minthogy nem része a Java szabványkönyvtárnak, hozzá kell adnunk a projekthez ezt a függőséget:

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
</dependency>

A legtöbb esetben a 4.12-es verziót használjuk. Az 5-ös verzióról később lesz szó.

Megjegyzés: van egy (minden bizonnyal) szoftverhiba, ami miatt a maven ezt a hibát írja ki:

[ERROR] Source option 5 is no longer supported. Use 6 or later.
[ERROR] Target option 1.5 is no longer supported. Use 1.6 or later.

Remélhetőleg ezt hamarosan ki fogják javítani. Addig a következőképpen tudjuk elkerülni: írjuk be az alábbi pár sort a pom.xml-be, közvetlenül a legfelső szint alá (a megfeleéő verziót megadva):

<properties>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
</properties>

Tegyük fel, hogy van egy ilyen osztályunk, amit tesztelni szeretnénk:

package math;

public class Math {
    public int add(int a, int b) {
        return a + b;
    }
}

Egységteszteket elvileg bárhol létrehozhatunk, de célszerű betartanunk a következő konvenciót: különüljön el az üzleti kód és a tesz, az egységtesztek kerüljenek az src/test/java/ könyvtárba. A csomagszerkezet feleljen meg az eredetinek (amit már amiatt is érdemes megtennünk, mert így elérjük a tesztelendő osztály alapértelmezett láthatóságú részeit), és mindegyik tesztelendő osztályhoz hozzunk létre saját tesztosztályt, ami az eredeti osztály nevét + egy Test posztfixet tartalmazzon. A tesztesetek neve kezdődjön azzal, hogy test, majd következzen a tesztelendő függvény neve. Abban az esetben, ha ugyanazt a függvényt többféleképpen teszteljük (ez a tipikus), akkor az arra a tesztre vonatkozó egy-két szavas kiegészítést fűzzünk hozzá (tehát ne testFunc1(), testFunc2 stb. legyen, hanem pl. testFuncPositiveCase(), testFuncErrorCheck stb.). Jelen esetben hozzuk létre az src/test/java/math/MathTest.java forrásfájlt az alábbi tartalommal:

package math;

import static org.junit.Assert.assertEquals;
import org.junit.Test;

public class MathTest {
    @Test
    public void testAddPositive() {
        Math math = new Math();

        int result = math.add(2, 3);

        assertEquals(5, result);
    }
}

Látható, hogy a @org.junit.Test annotációval jeleztük, hogy junit tesztről van szó. Nem véletlenül "szellős" kicsit a teszteset. Egy jól felépített teszteset ugyanis pontosan 3 részből áll:
1. A teszteset előkészítése (jelen esetben a megfelelő osztály példányosítása).
2. A tesz végrehajtása (jelen esetben az összeadás).
3. Az eredmény ellenőrzése (assertEquals).

Az eredményt a különböző assert függvényekkel tudjuk ellenőrizni. A logikának kicsit ellentmondó módon először az elvárt értéket adjuk meg, és utána a ténylegeset, ugyanis hiba esetén ennek megfelelően formázza a hibaüzenetet.

Majd futtassuk a teszteket! Ezt megtehetjük a kedvenc IDE-nkből (pl. Eclipse-ben jobb kattintás a teszteseten, vagy magán az osztályon, és Run As → JUnit Test), vagy parancssorból, pl. így:

mvn test

Egyébként a legtöbb maven parancs (pl. mvn clean install) automatikusan lefuttatja a teszteket. Ha nem szeretnénk, ezt a -DskipTests paraméter megadásával tudjuk megadni.

A tesztek felépítése

A fent bemutatott @Test annotáció igazából a legtöbb esetben elegendő, ám vannak esetek, amikor ennél bonyolultabb dolgokat szeretnénk végrehajtani. Íme néhány további hasznos annotáció:

  • @Before: az ezzel annotált függvény végrehajtódik minden teszteset előtt. Például ha mindegyik teszt esetén ugyanazt a struktúrát szeretnénk felépíteni, és - ez fontos - mindig újra, akkor ezt az annotációt használhatjuk.
  • @After: a @Before párja, azaz ez minden teszteset végén hajtódik végre. Igazából ezt ritkán használjuk, mivel ide tipikusan adatkapcsolatok lezárását szokás tenni, de a jól felépített unit teszteknél ilyesmire nincs szükség.
  • @BeforeClass: az ezzel annotált függvény egyszer hajtódik végre az összes teszteset előtt. Ennek a függvénynek statikusnak kell lennie. A fenti példában létrehozhatunk egyetlen Math példányt a @BeforeClass annotációval ellátott függvényben, az lehet a teszt osztály egy member változója, és ezt az összes teszteset használja.
  • @AfterClass: a @BeforeClass párja, ami egyszer hajtódik végre, miután az összes teszteset lefutott.
  • @Ignore: figyelmen kívül lehet hagyni egy tesztesetet a fordítás során. Arra jó, hogy az ideiglenesen elhasaló teszteseteket rövid távon mellőzzük; a gyakorlat sajnos azt mutatja, hogy az ezzel ellátott tesztesetek "beragadnak". Ezzel az annotációval egyébként magát az osztályt is elláthatjuk; ez esetben az osztályban található egyik teszteset sem hajtódik végre.

Eredmény ellenőrzések

A fenti példában az assertEquals eljárással ellenőriztük, hogy az eredmény az elvárt-e. Ez a leggyakoribb ellenőrző függvény, de van még számos egyéb:

  • assertEquals(expected, actual): tehát előbb jön az elvárt eredmény, és utána az aktuális. Két lebegőpontos szám összehasonlításához használandó a harmadik paraméter, maely megmondja a maximális eltérést az elvárt és a tényleges között. Ennek az oka az, hogy a számábrázolás pontatlansága miatt esetenként két egyenlőnek gondolt értéke setén is lehet az összehasonlítás eredménye hamis,
  • assertTrue(boolean): a paraméterül kapott logikai kifejezés elvárt értéke igaz.
  • assertFalse(boolean): a paraméterül kapott logikai kifejezés elvárt értéke hamis.
  • assertNull(object): a paraméterül kapott objektum null kell, hogy legyen.
  • assertNotNull(object): a paraméterül kapott objektum nem lehet null.
  • fail(): a teszteset feltétel nélkül elhasal, pl. egy nem kívánt ágon.

Elvárt kivétel

A jól felépített egységtesztek minden lehetséges ágat leellenőriznek, így a kivételeket is. Ennek a legelegánsabb módja az, hogy a @Test annotációban expected parméterként megadjuk az elvárt kivételt. Lássunk egy példát! Először hozzunk létre a főprogramban egy kivételt, majd adjunk hozzá egy divide() függvényt:

package math;

class DivisionByZeroException extends Exception {}

public class Math {
    public int add(int a, int b) {
        return a + b;
    }

    public int divide(int a, int b) throws DivisionByZeroException {
        if (b == 0) {
            throw new DivisionByZeroException();
        } else return a / b;
    }
}

Szervezzük át az egységteszt kódot:

package math;

import static org.junit.Assert.assertEquals;
import org.junit.BeforeClass;
import org.junit.Test;

public class MathTest {
    static Math math;

    @BeforeClass
    public static void setUp() {
        math = new Math();
    }

    @Test
    public void testAddPositive() {
        int result = math.add(2, 3);
        assertEquals(5, result);
    }

    @Test
    public void testDivideSuccess() throws DivisionByZeroException {
        int result = math.divide(6, 3);
        assertEquals(2, result);
    }

    @Test(expected = DivisionByZeroException.class)
    public void testDivideException() throws DivisionByZeroException {
        math.divide(6, 0);
    }
}

A testDivideException() elvárt kivétele a DivisionByZeroException. Ha átírnánk a nevezőt 0-ról mondjuk 1-re, akkor ez az egységteszt elhasalna, mert nem érkezne meg az elvárt kivétel.

Időtúllépés tesztelése

A tesztesetnek opcionális paraméterként megadhatunk egy timeout értéket, ami azt jelzi, hogy legfeljebb hány ezredmásodperc ideig tarthat a teszteset. Ezzel nemcsak az eredményt, hanem az algoritmus hatékonyságát is ellenőrizhetjük. Lássunk erre is egy példát! A Fibonacci számok kiszámításának két módját adom meg: az egyik a rekurzív megvalósítás, a másik az iteratív. Az elsőnek exponenciális lesz a futási ideje, a másiknak lineáris. A tesztelendő osztály az alábbi:

package math;

public class Fibonacci {
    public static int fibonacciFast(int n) {
        if (n <= 1) {
            return n;
        }
        int a = 0, b = 1, temp;
        for (int i = 0; i < n - 1; i++) {
            temp = b;
            b = a + b;
            a = temp;
        }
        return b;
    }

    public static long fibonacciSlow(int n) {
        if (n <= 1) {
            return n;
        } else {
            return fibonacciSlow(n-1) + fibonacciSlow(n-2);
        }
    }
}

Amivel tesztelünk:

package math;
 
import static org.junit.Assert.assertEquals;
import org.junit.Test;
 
public class FibonacciTest {
    @Test(timeout = 1000)
    public void testFibonacciFast() {
        assertEquals(1134903170, Fibonacci.fibonacciFast(45));
    }
 
    @Test(timeout = 1000)
    public void testFibonacciSlow() {
        assertEquals(1134903170, Fibonacci.fibonacciSlow(45));
    }
}

Az első teszteset sikerül, a második elhasal.

A konzol kimenet ellenőrzése

A konzol kimenet ellenőrzése nem nyilvánvaló. Tekintsük az alábbi függvényt!

public class MyHello {
    public void sayHello(String name) {
        System.out.println("Hello " + name + "!");
    }
}

Ennek az egységtesztelése:

import static org.junit.Assert.assertEquals;
 
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
 
import org.junit.Test;
 
public class MyHelloTest {
    @Test
    public void testMyHello() {
        ByteArrayOutputStream outputStreamCaptor = new ByteArrayOutputStream();
        System.setOut(new PrintStream(outputStreamCaptor));
        MyHello myHello = new MyHello();
 
        myHello.sayHello("Csaba");
 
        assertEquals("Hello Csaba!", outputStreamCaptor.toString().trim());
    }
}

Parametrizált tesztesetek

Gyakori eset, hogy egy-egy függvényt nagyon sokféle paraméter kombinációval szeretnénk meghívni, valójában ugyanúgy. Ha ezt egyetlen tesztesetbe tennénk, akkor megszegjük azt a szabályt, hogy egy egységteszt pontosan egy valamit ellenőrizzen (a szabályokról később még lesz szó). Ha mindegyiket külön tesztbe tesszük, akkor túl ugyanolyan sok függvény lesz, ami növeli a kód duplikátumok arányát. A megoldás a parametrizált tesztelés, ahol paraméterként adjuk meg a bemeneteket és az elvárt kimeneteket, és magát a tesztesetet csak egyszer írjuk meg.

Lássuk ezt egy példán keresztül!

package math;

import static org.junit.Assert.assertEquals;
import java.util.Arrays;
import java.util.Collection;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;

@RunWith(Parameterized.class)
public class MathTest {
    static Math math;

    @BeforeClass
    public static void setUp() {
        math = new Math();
    }

    @Parameter(0)
    public int a;

    @Parameter(1)
    public int b;

    @Parameter(2)
    public int result;

    @Parameterized.Parameters
    public static Collection<Object[]> primeNumbers() {
       return Arrays.asList(new Object[][] {
          {2, 3, 5},
          {-2, -3, -5},
          {0, 0, 0},
          {100, 100, 200},
          {-10, 10, 0}
       });
    }

    @Test
    public void testAdd() {
        assertEquals(result, math.add(a, b));
    }
}

Test suite

A @Suite annotációval ún. teszt készleteket tudunk létrehozni. ENnek illusztrálására kicsit szervezzük át a kódot! Az üzleti logika így néz ki:

// Math.java
package math;
public class Math {}

// Add.java
package math;
public class Add extends Math {
    public int add(int a, int b) {
        return a + b;
    }
}

// Divide.java
package math;
public class Divide extends Math {
    public int divide(int a, int b) throws DivisionByZeroException {
        if (b == 0) {
            throw new DivisionByZeroException();
        } else return a / b;
    }
}

// DivisionByZeroException.java
package math;
public class DivisionByZeroException extends Exception {
    private static final long serialVersionUID = 785018278084864807L;
}

Ennek megfelelően az átszervezett egységteszt:

// AddTest.java
package math;

import static org.junit.Assert.assertEquals;
import java.util.Arrays;
import java.util.Collection;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;

@RunWith(Parameterized.class)
public class AddTest {
    static Add add;

    @BeforeClass
    public static void setUp() {
        add = new Add();
    }

    @Parameter(0)
    public int a;

    @Parameter(1)
    public int b;

    @Parameter(2)
    public int result;

    @Parameterized.Parameters
    public static Collection<Object[]> primeNumbers() {
       return Arrays.asList(new Object[][] {
          {2, 3, 5},
          {-2, -3, -5},
          {0, 0, 0},
          {100, 100, 200},
          {-10, 10, 0}
       });
    }

    @Test
    public void testAdd() {
        assertEquals(result, add.add(a, b));
    }
}

Ill.:

package math;

import static org.junit.Assert.assertEquals;

import org.junit.BeforeClass;
import org.junit.Test;

public class DivideTest {
    static Divide divide;

    @BeforeClass
    public static void setUp() {
        divide = new Divide();
    }

    @Test
    public void testDivideSuccess() throws DivisionByZeroException {
        int result = divide.divide(6, 3);
        assertEquals(2, result);
    }

    @Test(expected = DivisionByZeroException.class)
    public void testDivideException() throws DivisionByZeroException {
        divide.divide(6, 0);
    }
}

Tegyük fel, hogy az összeadásokat és az osztásokat egy logikai egységbe szeretnénk szervezni. Ezt a következőképen tudjuk megtenni:

package math;

import org.junit.runner.RunWith;
import org.junit.runners.Suite;

@RunWith(Suite.class)
@Suite.SuiteClasses({AddTest.class, DivideTest.class})
public class MathTest {}

A MathTest-et unit tesztként lefuttatva mind az összeadás, mind az osztás tesztek végrehajtódnak.

Test runner

Az alapértelmezett teszt futtató véletlen sorrendben lefuttatja a teszteseteket. Előfordulhat, hogy ez nem megfelelő; ez esetben saját teszt futtatót lehet készíteni. Mi magunk nagyon ritkán készítünk teszt futtatót; erről egy jó leírás itt található: https://www.baeldung.com/junit-4-custom-runners. Ami fontosabb: bizonyos keretrendszerek használatakor ha azt szeretnénk ellenőrizni, hogy a függvényünk hogyan működik abban a környezetben, szükség lehet annak valamilyen szintű szimulált futtatására, azaz a tesztesetek előtti elindítására, majd leállítására. Így a keretrendszer gyártók többnyire elkészítik az ő teszt futtatójukat is.

Ha nem az alapértelmezett futtatóval szeretnénk futtatni a teszteket, akkor az osztályt a @RunWith annotációval kell ellátni, pl. @RunWith(CustomRunner.class).

JUnit 5

A JUnit 5-ös verziója átnevezésre került, az új név jupiter. Az írás pillanatában legfrissebb verziót a következőképpen tudjuk használni:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>5.5.2</version>
    <scope>test</scope>
</dependency>

Az első, lényeges dolog, ami nekem feltűnt, az a logikusabb @Before és @After annotációt: @BeforeEach és @AfterEach, ill. @BeforeAll és @AfterAll. Egyik sem szorul magyarázatra. Bevezették a tesztek ismétlését: @RepeatedTest(number). A kivételkezelést is kódból oldja meg, annotáció paraméter helyett: expectThrows.

Bevezették a lambda kifejezések támogatását. Ezt egy példán keresztül mutatom be:

public class Math {
    public static int add(int a, int b) {
        return a + b;
    }
}

Ennek egy lehetséges egységtesztelése a következő:

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

public class MathTest {
    @Test
    public void testAdd() {
        assertAll(
            () -> assertEquals(5, Math.add(2, 3)),
            () -> assertEquals(8, Math.add(4, 4))
        );
    }
}

A parametrizált tesztelést is tovább gondolták; itt dinamikus teszteknek hívják:

import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;

public class MathTestFactory {
    @TestFactory
    public Stream<DynamicTest> testAddDynamic() {
        List<Object[]> testCases = Arrays.asList(new Object[][] {
            {2, 3, 5},
            {-2, -3, -5},
            {0, 0, 0},
            {100, 100, 200},
            {-10, 10, 0}
         });
        return DynamicTest.stream(
            testCases.iterator(),
            testCase -> "Testing " + (int)testCase[0] + " + " + (int)testCase[1] + " = " + (int)testCase[2],
            testCase -> assertEquals(testCase[2], Math.add((int)testCase[0], (int)testCase[1]))
        );
    }
}

Megjegyzés: ez rendesen működött Eclipse-ből, parancssorból viszont nem akart sehogyan sem működni. A neten található leírások (pl. https://dzone.com/articles/why-your-junit-5-tests-are-not-running-under-maven, https://github.com/junit-team/junit5/issues/1778, https://blog.codefx.org/libraries/junit-5-dynamic-tests/) nem segítettek.

Mockolás

A fent bemutatott egységtesztek egyszerűek, talán túl egyszerűek voltak. A valóságban előfordulnak esetek, amikor fel kell építeni a teszteset környezetét. A mock keretrendszerek ebben segítik a fejlesztőt.

Egy egyszerű Mockito példa

Tekintsük a következő példát: adatbázisból azonosító alapján kiolvasunk egy adatot, ami részvénnyel kapcsolatos információkat tartalmaz: a részvény neve, az ára, valamint az, hogy mennyit mozdult el az ára. Egy függvény kiszámolja az új árat. Ezt szeretnénk egységtesztelni! A problémát nyilván az adatbázis olvasás okozza, a példában ezt fogjuk mockolni. Lássuk először az üzleti logikát! A részvény kódja az alábbi:

package stock;

public class Stock {
    int id;
    String name;
    double actualPrice;
    double increase;

    public Stock(int id, String name, double actualPrice, double increase) {
        super();
        this.id = id;
        this.name = name;
        this.actualPrice = actualPrice;
        this.increase = increase;
    }
}

A valóságban természetesen ennél jobban ki kell dolgozni, pl. priváttá célszerű állítani az attribútumokat, kellenek getterek és setterek stb., most a tömörebb kód érdekében ettől eltekintünk. Az adatbázisból kiolvasó kód ez:

package stock;

public class StockDao {
    public int[] getAllIds() {
        int[] stockIds = null;
        //read stock IDs from database
        return stockIds;
    }

    public Stock readStockInfo(int id) {
        Stock stock = null;
        // read Stock from database
        return stock;
    }
}

Az üzleti logika, amit tesztelni szeretnénk:

package stock;

public class StockLogic {
    private StockDao stockDao;

    public StockLogic(StockDao stockDao) {
        this.stockDao = stockDao;
    }

    public double calculateNextPrice(int id) {
        Stock stock = stockDao.readStockInfo(id);
        return stock.actualPrice * (1 + stock.increase);
    }
}

Az egységtesztet Mockito-val valósítjuk meg. Ehhez az alábbi függőséget kell a pom.xml fájlhoz adni:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-all</artifactId>
    <version>1.10.19</version>
    <scope>test</scope>
</dependency>

A junitra (ld. fent) továbbra is szükség van.

package stock;

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

public class StockLogicTest {
    @Test
    public void testCalculateNextPrice() {
        StockDao stockDao = mock(StockDao.class);
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.07));
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.calculateNextPrice(3);

        assertEquals(result, 133.964, 0.001);
    }
}

A mock, valamint a when-thenReturn kódot tartalmazó sorok a lényegesek jelen esetben, amelyek "beégetik" azt, hogy melyik azonosítóra mit kell visszaadni. Megjegyzések:

  • A futtatás során nálam kiírja ezt a figyelmeztetést pár sorban: An illegal reflective access operation has occurred; nem sikerült megfejtenem,hogy mi okozza.
  • A fenti példa eléggé "élére vasalt": a legtöbb esetben sajnos nem ennyire egyszerű a tesztelésre való előkészítés.
  • A fenti példát természetesen Mockito nélkül is meg tudjuk valósítani: leszármaztatunk a StockDao osztályból, felüldefiniáljuk a readStock(id) eljárást egy feltétellel: ha a paraméterül átadott érték 3, akkor a fenti példányt adja vissza. Mockito-val mindez rövidebb, és egy helyen van a teszthez szükséges minden lényeges dolog.

A hívások számának ellenőrzése

A verify() metódussal tudjuk leellenőrizni azt, hogy meghívódott-e egy függvény vagy sem. Külön be tudjuk állítani azt, hogy a hívás megfelelő számszor, ill. megfelelő paraméterekkel történt-e.

Ennek illusztrálásához bővítsük ki a fenti StockLogic osztályt az alábbi függvénnyel, amely megkeresi a legnagyobb arányú növekedést:

    public double findBiggestIncrease() {
        double biggestIncrease = Double.NEGATIVE_INFINITY;
        for (int stockId : stockDao.getAllIds()) {
            Stock stock = stockDao.readStockInfo(stockId);
            if (stock.increase > biggestIncrease) {
                biggestIncrease = stock.increase;
            }
        }
        return biggestIncrease;
    }

Felépítünk egy olyan példát, melyben 5 elem van, és megnézzük, hogy a readStockInfo() függvény ötször lett-e meghívva, bármilyen paraméterrel:

    @Test
    public void testFindBiggestIncrease() {
        StockDao stockDao = mock(StockDao.class);
        int[] stockIds = {1, 2, 3, 4, 8};
        when(stockDao.getAllIds()).thenReturn(stockIds);
        when(stockDao.readStockInfo(1)).thenReturn(new Stock(1, "MyProduct1", 125.2, 0.07));
        when(stockDao.readStockInfo(2)).thenReturn(new Stock(2, "MyProduct2", 100.0, -0.07));
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct3", 89.1, 0.12));
        when(stockDao.readStockInfo(4)).thenReturn(new Stock(4, "MyProduct4", 17.4, 0.2));
        when(stockDao.readStockInfo(8)).thenReturn(new Stock(8, "MyProduct8", 389.0, 0.09));
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.findBiggestIncrease();

        verify(stockDao, times(5)).readStockInfo(anyInt());
        assertEquals(result, 0.2, 0.001);
    }

A times(5) elhagyható; ez esetben azt nézzük, hogy egyáltalán meg lett-e hívva. Az anyInt() helyére beírhatunk számot, így azt is le tudnánk ellenőrizni, hogy a megfelelő paraméterekkel történt-e a hívás:

    @Test
    public void testFindBiggestNotOrder() {
        StockDao stockDao = mock(StockDao.class);
        int[] stockIds = {1, 2, 3, 4, 8};
        when(stockDao.getAllIds()).thenReturn(stockIds);
        when(stockDao.readStockInfo(1)).thenReturn(new Stock(1, "MyProduct1", 125.2, 0.07));
        when(stockDao.readStockInfo(2)).thenReturn(new Stock(2, "MyProduct2", 100.0, -0.07));
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct3", 89.1, 0.12));
        when(stockDao.readStockInfo(4)).thenReturn(new Stock(4, "MyProduct4", 17.4, 0.2));
        when(stockDao.readStockInfo(8)).thenReturn(new Stock(8, "MyProduct8", 389.0, 0.09));
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.findBiggestIncrease();

        verify(stockDao).readStockInfo(4);
        verify(stockDao).readStockInfo(3);
        verify(stockDao).readStockInfo(2);
        verify(stockDao).readStockInfo(1);
        verify(stockDao).readStockInfo(8);
        assertEquals(result, 0.2, 0.001);
    }

A hívások sorrendjének ellenőrzése

A fenti példában szándékosan el lett rontva a hívás sorrendje az ellenőrzésnél; ennek ellenére a teszteset sikeresen lefut. Ezt a módszert kell alkalmaznunk akkor, ha a sorrend nem determinisztikus, pl. halmazok esetén. Ha számít a sorrend, akkor az org.mockito.InOrder osztályt használhatjuk:

import org.mockito.InOrder;

...

    @Test
    public void testFindBiggestIncreaseOrder() {
        StockDao stockDao = mock(StockDao.class);
        InOrder inOrder = inOrder(stockDao);
        int[] stockIds = {1, 2, 3, 4, 8};
        when(stockDao.getAllIds()).thenReturn(stockIds);
        when(stockDao.readStockInfo(1)).thenReturn(new Stock(1, "MyProduct1", 125.2, 0.07));
        when(stockDao.readStockInfo(2)).thenReturn(new Stock(2, "MyProduct2", 100.0, -0.07));
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct3", 89.1, 0.12));
        when(stockDao.readStockInfo(4)).thenReturn(new Stock(4, "MyProduct4", 17.4, 0.2));
        when(stockDao.readStockInfo(8)).thenReturn(new Stock(8, "MyProduct8", 389.0, 0.09));
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.findBiggestIncrease();

        inOrder.verify(stockDao).readStockInfo(1);
        inOrder.verify(stockDao).readStockInfo(2);
        inOrder.verify(stockDao).readStockInfo(3);
        inOrder.verify(stockDao).readStockInfo(4);
        inOrder.verify(stockDao).readStockInfo(8);
        assertEquals(result, 0.2, 0.001);
    }
...

Kivételkezelés

Ha egy függvény kivételt dobhat, azt adott inputra meg tudjuk adni. A fenti példát úgy módosítjuk, hogy a StockDao.readStockInfo() függvény StockNotFoundException kivételt dobjon olyan esetben, ha az adott azonosítójú részvény nem létezik. Ehhez először létrehozzuk magát a kivételt:

// StockNotFoundException.java
package stock;

public class StockNotFoundException extends RuntimeException {}

Az egyszerűség érdekében hoztuk létre nem ellenőrzött kivételként (a RuntimeException osztályból örököltetve), hogy nem kelljen mindenhova odaírni a throws StockNotFoundException-t; a valóságban ezt ellenőrzött kivételként célszerű létrehozni. Mindenesetre a StockDao.java-ban jelezzük, hogy a readStockInfo() ezt a kivételt dobhatja:

...
    public Stock readStockInfo(int id) throws StockNotFoundException {
        Stock stock = null;
        // read Stock from database
        return stock;
    }
...

Most pedig megírjuk a StockLogicTest.java forrásban a vonatkozó egységtesztet:

    @Test(expected = StockNotFoundException.class)
    public void testInvalidStock() {
        StockDao stockDao = mock(StockDao.class);
        doThrow(StockNotFoundException.class).when(stockDao).readStockInfo(5);
        StockLogic stockLogic = new StockLogic(stockDao);

        stockLogic.calculateNextPrice(5);
    }

A teszt a korábban bemutatott calculateNextPrice() függvényt hívja meg, de most az azonosító 5, ami a tesztünkben nem létezik. Itt az elvárt kivétel a StockNotFoundException, amit a mockolt StockDao adott inputra dob.

Nagyobb kontroll

Az első példát általánosíthatjuk az Answer interfész segítségével. Lássuk először a megoldást:

...
import org.mockito.stubbing.*;
...
    @Test
    public void testCalculateNextPriceWithAnswer() {
        StockDao stockDao = mock(StockDao.class);
        when(stockDao.readStockInfo(anyInt())).thenAnswer(new Answer<Stock>() {
            @Override
            public Stock answer(InvocationOnMock invocation) {
                Object[] args = invocation.getArguments();
                StockDao mockStockDao = (StockDao)invocation.getMock();
                // here we can manipulate the mock object
                if ((int)args[0] == 3) {
                    return new Stock(3, "MyProduct", 125.2, 0.07);
                }
                return null;
            }
        });
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.calculateNextPrice(3);

        assertEquals(result, 133.964, 0.001);
    }

A kód olyan szempontból nem jó, hogy ugyanazt csinálja mint az előző, de hosszabb lett, az Answer megoldás erejét viszont jól illusztrálja: belül, ahol létrehozzuk a választ, egyéb műveleteket is végre tudunk hajtani a mockolt objektumon.

Az időtúllépés ellenőrzése

A verify() metódus második, opcionális paramétere az időtúllépésre vonatkozik, pl. a verify(myMock, timeout(100)).myFunc(myParam) azt jelenti, hogy az adott eljárás befejeződik-e egytized másodperc alatt. Jó példát erre viszont nem tudok adni, mert ezzel azt ellenőrizzük, hogy a mockolt változat fejeződik-e be adott időn belül, aminek szerintem semmi értelme nincs.

"Kémkedés"

Kémkedés (angolul spying) alatt azt értjük, hogy egy osztályt részben szeretnénk mockolni, részben az eredeti függvényeket használni. Egészen pontosan egy létez példányt egy az egyben lemásolunk, majd bizonyos részeit módosítjuk. A nem módosított részek az eredeti módon működnek.

Példaként vegyünk egy (egyébként rosszul megtervezett) osztályt, ami két dolgot csinál: az egyik függvénye kiolvas egy egész számot az adatbázisból, a másik pedig a kilolvasott szám négyzetét adja vissza értékül (a tervezés amiatt rossz, mert ezt a két dolgot a gyakorlatban külön kell választani).

package math;

public class MathDb {
    public int readNextValue() {
        int result = 0;
        // read value from database
        return result;
    }

    public int squareOfNextValue() {
        int value = readNextValue();
        return value * value;
    }
}

A példában az adatbázisból olvasós részt, tehát a readNextValue() függvényt mockolni szeretnénk, a négyzet számolós részt viszont eredeti formájában felhasználni. Valódi tesztesetek esetén jó eséllyel egy másiko osztályt tesztelünk, amely a mockoltat használja; most a jól áttekinthető példa érdekében az eredeti formájában hagyott eljárást teszteljük:

package math;

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

public class MathDbTest {
    @Test
    public void testSquareOfNextValue() {
        MathDb spyMathDb = Mockito.spy(new MathDb());
        when(spyMathDb.readNextValue()).thenReturn(5);

        int result = spyMathDb.squareOfNextValue();

        assertEquals(25, result);
    }
}

A fenti másolást annotációval is meg lehet oldani. Ehhez a MockitoJUnitRunner osztállyal kell futtatni a tesztet:

package math;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.*;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Spy;
import org.mockito.runners.MockitoJUnitRunner;

@RunWith(MockitoJUnitRunner.class)
public class MathDbTest {
    @Spy
    MathDb spyMathDb = new MathDb();

    @Test
    public void testSquareOfNextValue() {
        when(spyMathDb.readNextValue()).thenReturn(5);

        int result = spyMathDb.squareOfNextValue();

        assertEquals(25, result);
    }
}

Ha ez nem lehetséges, akkor programkódból is elérhetővé tudjuk tenni az annotációkat:

package math;

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

import org.junit.Before;
import org.junit.Test;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;

public class MathDbTest {
    @Before
    public void init() {
        MockitoAnnotations.initMocks(this);
    }

    @Spy
    MathDb spyMathDb = new MathDb();

    @Test
    public void testSquareOfNextValue() {
        when(spyMathDb.readNextValue()).thenReturn(5);

        int result = spyMathDb.squareOfNextValue();

        assertEquals(25, result);
    }
}

A mockolt objektum visszaállítása

A reset() függvénnyel lehet a mockolt objektumot alapértelmezett állapotba állítani, például így:

    @Test
    public void testCalculateNextPriceWithReset() {
        StockDao stockDao = mock(StockDao.class);
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.07));
        StockLogic stockLogic = new StockLogic(stockDao);

        assertEquals(stockLogic.calculateNextPrice(3), 133.964, 0.001);

        reset(stockDao);

        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.0));
        assertEquals(stockLogic.calculateNextPrice(3), 125.2, 0.001);
    }

Viselkedés vezérelt fejlesztés Mockito segítségével

Angolul behavior-driven development, szokásos rövidítéssel BDD. A BDD egy sokkal általánosabb fogalom, melyre most nem értünk ki; az egységtesztelés szempontjából ez a tesztek felépítésére van hatással: a given-when-then jól felépített mintát követi. Lássunk erre egy példát!

...
import static org.mockito.BDDMockito.*;
import static org.hamcrest.CoreMatchers.*;
...
    @Test
    public void testCalculateNextPriceBDD() {
        // given
        StockDao stockDao = mock(StockDao.class);
        given(stockDao.readStockInfo(3)).willReturn(new Stock(3, "MyProduct", 125.2, 0.07));
        StockLogic stockLogic = new StockLogic(stockDao);

        // when
        double result = stockLogic.calculateNextPrice(3);

        // then
        then(stockDao).should().readStockInfo(3);
        assertThat(result, is(133.964));
    }
....

Láthatóak a jól elkülönülő szakaszok, valamint azok a kulcsszavak, amellyel szinte mondatokként tudjuk a kódot olvasni.

A PowerMock

A Mockito igen hasznos keretrendszer egységteszteléshez, viszont vannak kötöttségei: nem lehet vele statikus függvényeket, privát függvényeket, final kulcsszóval ellátott függvényeket és konstruktorokat sem mockolni. A PowerMock ezeket is tudja. A Mockito mellett az EasyMock keretrendszerrel is együttműködik. Most a Mockito-t nézzük meg.

Mielőtt rátérnénk a lehetőségekre, néhány bevezető gondolat a PowerMock-ról. Az írás pillanatában aktuális verzió nem működött jól együtt a 9-es vagy annál újabb Java-val. Két külső könyvtárat is importálni kell: az egyik a JUnit komponens (így magát a junit-ot nem kell külön importálni), a másik pedig arra vonatkozik, hogy melyik mock keretrendszert használjuk. A pom.xml kb. így néz ki:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>hu.faragocsaba</groupId>
    <artifactId>junit</artifactId>
    <version>1.0</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.powermock</groupId>
            <artifactId>powermock-module-junit4</artifactId>
            <version>1.7.4</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.powermock</groupId>
            <artifactId>powermock-api-mockito</artifactId>
            <version>1.7.4</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

(Megjegyzés: nem elég a verziószámot a tulajdonságok között átállítani, ténylegesen 8-as Java-t kell használni.)

Két annotációval el kell látni a teszt osztályt:

  • @RunWith(PowerMockRunner.class) - ellentétben a Mockito-val itt már kötelező.
  • @PrepareForTest(fullyQualifiedNames = "package.*") - az osztályokat úgymond elő kell készíteni a teszteléshez, egyébként nem működik a teszt.

A PoerMock egyébként felülről kompatibilis a Mockitot-val, például a fenti első példa - más importokkal és a két említett annotációval - egy az egyben működik:

package stock;

import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import static org.powermock.api.mockito.PowerMockito.*;

@RunWith(PowerMockRunner.class)
@PrepareForTest(fullyQualifiedNames = "stock.*")
public class StockLogicTest {
    @Test
    public void testCalculateNextPrice() {
        StockDao stockDao = mock(StockDao.class);
        when(stockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.07));
        StockLogic stockLogic = new StockLogic(stockDao);

        double result = stockLogic.calculateNextPrice(3);

        assertEquals(result, 133.964, 0.001);
    }
}

Konstruktorok mockolása

A fenti példa talán túlzottan is vegytisztán elő volt készítve az egységteszteléshez. A valóságban a kód általában ennél problémásabb. A külső függőségeket tipikusan nem paraméterként kapják meg az osztályok, hanem maguk hozzák létre. Például a valóságban a StockLogic inkább a következőképpen néz ki:

package stock;

public class StockLogic {
    public double calculateNextPrice(int id) {
        Stock stock = (new StockDao()).readStockInfo(id);
        return stock.actualPrice * (1 + stock.increase);
    }
}

Ezt ebben a formában még Mocikoto-val sem tudnánk tesztelni, PowerMock-kal viszont igen!

package stock;

import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import static org.powermock.api.mockito.PowerMockito.*;

@RunWith(PowerMockRunner.class)
@PrepareForTest(fullyQualifiedNames = "stock.*")
public class StockLogicTest {
    @Test
    public void testCalculateNextPrice() throws Exception {
        StockDao stockDaoMock = mock(StockDao.class);
        whenNew(StockDao.class).withNoArguments().thenReturn(stockDaoMock);
        when(stockDaoMock.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.07));
        StockLogic stockLogic = new StockLogic();

        double result = stockLogic.calculateNextPrice(3);

        assertEquals(result, 133.964, 0.001);
    }
}

Statikus és final függvények mockolása

Írjuk át a kódot úgy, hogy az összes függvény statikus legyen!

package stock;

public class StockDao {
    public final static int[] getAllIds() {
        int[] stockIds = null;
        //read stock IDs from database
        return stockIds;
    }

    public final static Stock readStockInfo(int id) throws StockNotFoundException {
        Stock stock = null;
        // read Stock from database
        return stock;
    }
}

Ill.:

package stock;

public class StockLogic {
    public static double calculateNextPrice(int id) {
        Stock stock = StockDao.readStockInfo(id);
        return stock.actualPrice * (1 + stock.increase);
    }

    public static double findBiggestIncrease() {
        double biggestIncrease = Double.NEGATIVE_INFINITY;
        for (int stockId : StockDao.getAllIds()) {
            Stock stock = StockDao.readStockInfo(stockId);
            if (stock.increase > biggestIncrease) {
                biggestIncrease = stock.increase;
            }
        }
        return biggestIncrease;
    }
}

A kód többi része (Stock.java, StockNotFoundException.java) maradjon úgy, ahogy van. Ezt Mocikoto-val nem tudnánk mockolni, PowerMock-kal viszont igen!

package stock;

import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import static org.powermock.api.mockito.PowerMockito.*;

@RunWith(PowerMockRunner.class)
@PrepareForTest(fullyQualifiedNames = "stock.*")
public class StockLogicTest {
    @Test
    public void testCalculateNextPrice() {
        mockStatic(StockDao.class);
        when(StockDao.readStockInfo(3)).thenReturn(new Stock(3, "MyProduct", 125.2, 0.07));

        double result = StockLogic.calculateNextPrice(3);

        assertEquals(result, 133.964, 0.001);
    }
}

Ez a példa tehát elsősorban a statikus függvények mockolását illusztrálja, de azt is bemutatja, hogy ugyanazzal a módszerrel final függvényeket is tudunk mockolni.

Privát függvények mockolása

A Mockito privát függvények mockolására sem alkalmas, a PowerMock igen. Valójában a látszat ellenére igen ritka az, hogy erre legyen szükség, mivel a tesztelendő osztályok a mockolandó osztályok publik függvényeit használják. Ekkor lehet erre szükség, ha a mockolandó osztály publikus metódusait eredeti funkciójukban szeretnénk megtartani, vagy akár pont azokat szeretnénk tesztelni. Az egyszerűség érdekében most egy ilyen példát nézünk meg. Tehát mivel mindenképp szükség van az osztály bizonyos eredeti funkcióira, a privát függvények mockolása és a "kémkedés" kéz a kézben jár.

Példaként tekintsük a már bemutatott osztályt, amely egy elképzelt adatbázis kiolvasást emel négyzetre, annyi különbséggel, hogy az adatbázis olvasás legyen privát függvény:

package math;

public class MathDb {
    private int readNextValue() {
        int result = 0;
        // read value from database
        return result;
    }

    public int squareOfNextValue() {
        int value = readNextValue();
        return value * value;
    }
}

Az erre vonatkozó egységteszt:

package math;

import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import static org.powermock.api.mockito.PowerMockito.*;

@RunWith(PowerMockRunner.class)
@PrepareForTest(fullyQualifiedNames = "math.*")
public class MathDbTest {
    @Test
    public void testSquareOfNextValue() throws Exception {
        MathDb mathDbMock = spy(new MathDb());
        doReturn(5).when(mathDbMock, "readNextValue");

        int result = mathDbMock.squareOfNextValue();

        assertEquals(25, result);
    }
}

Látható, hogy az eljárás nevét idézőjelbe kell tenni, ennélfogva természetesen csak futási időben derül ki, ha elgépeltünk valamit. Nem tökéletes tehát a megoldás, de működik!

Konvenciók

Az egységteszt keretrendszerek sajnos túl nagy szabadságot adnak a fejlesztőknek. Célszerű betartani néhány konvenciót, hogy a kód olvashatóbb, karbantarthatóbb maradjon.

  • A teszt kód csomagneve ugyanaz legyen, mint amiben a tesztelendő kód van. Ennek egy kellemes mellékhatása az, hogy látjuk a package private adatmezőket és metódusokat is.
  • Az teszt osztály neve egyezzen meg a tesztelendő osztály nevével, de adjuk hozzá a Test postfixet a végére.
  • Egy osztály lehetőleg csak a rá vonatkozó osztályt, ill. a benne található függvényeket ellenőrizze. A többit bízzuk a neki megfelelő osztályokra, és mockoljuk.
  • A függvénynévnek egy kialakult konvenciója a következő: álljon 3 részből, aláhúzással elválasztva. Az első legyen a tesztelendő függvény, a második a teszteset, a harmadik pedig az elvárt eredmény. Ha pl. van egy add() metódus két paraméterrel, akkor pl. a teszt függvény neve lehet add_3and2_5().
  • Egy függvény csak egy dolgot teszteljen.
  • A teszt függvényt osszuk 3 jól elkülönülő részre: előkészítése, a teszt végrehajtása, eredmény ellenőrzése.

Források

A téma fontosságát támasztja alá a hatalmas irodalom, ami az interneten fellelhető. Ebből szemezgetünk most. Jellemzően mindegyik jelentősebb Java programozással foglalkozó oldalnak van egységteszteléssel foglalkozó része, beleértve a mockolást is, sőt egyes oldalak (pl. a Baeldung) a tőlük nem megszokott módon külön oldalakra szedte a témát. A fent bemutatott mock keretrendszerek mellett egyéb könyvtárak is vannak, melyekről említést teszünk.

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License