Spring Testing

The only constant is change.

Tesztelés típusai

A tesztelés a szoftverfejlesztés egyik legfontosabb lépése, amely biztosítja, hogy a szoftvertermékben meglévő hibákra, már a fejlesztési fázisban fény derüljön. A tesztelés növeli a szoftver minőségét és megbízhatóságát, de egyáltalán nem jelenthető ki egyetlen szoftverről sem, hogy hibátlan. A tesztelés egyik alapelve, hogy minél korábban kezdjük el a tesztelést, annál hamarabb találunk meg egy hibát és annál olcsóbb is javítani.

A tesztelés két alapvető típusát különböztetjük meg, az úgynevezett feketedobozos (black-box) és a fehérdobozos (white-box) tesztelést. Ezt nagyobb szoftverek esetén nem is feltétlen ugyanazon személyek végzik.

White-box tesztelés

Szokás strukturális tesztelésnek is nevezni, mert mindig egy meglévő (lefejlesztett) struktúrát tesztelünk vele. Egyes kisebb struktúrák tesztelése programozói feladat, míg egyes nagyobb struktúrák tesztelését sokszor külön tesztelői csapat végzi. A white-box tesztelés forráskód alapján történik, ezért ezen teszteket legtöbbször cégen belül végzik el. Struktúrának tekinthető, akár már egy kódsor is. Leggyakrabban tesztelt struktúrák:

  • Kódsorok
  • Elágazások
  • Metódusok
  • Osztályok
  • Funkciók
  • Modulok

Black-box tesztelés

Specifikáció alapú tesztelésnek is nevezhető, mert a tesztelők számára egyetlen elérhető információ a szoftverről a specifikációja, valamint természetesen maga a szoftver. Ilyen tesztelés esetén a tesztelők feladata azt ellenőrizni, hogy a szoftver a specifikációnak megfelel-e. A tesztelés azt vizsgálja, hogy a rendszer adott bemenetekre az elvárt választ adja-e. Sokszor ezt a fajta tesztelést külsős tesztelői csoportok végzik, ezzel növelve a tesztelés megbízhatóságát.

A tesztelés szintjei

  1. Komponens teszt
  2. Integrációs teszt
  3. Rendszerteszt
  4. Átvételi teszt

Komponens teszt

A komponens teszt a rendszer egy adott komponensét önmagában teszteli. Leggyakoribb formája a unit-teszt és a modulteszt. Jelen esetben mi majd a unit-tesztelésre nézünk majd példát.

Modulteszt

A modulteszt általában olyan tesztelést foglal magában, amely nem funkcionális tulajdonságokra terjed ki. Ilyen nem funkcionális tulajdonság például a sebesség, memóriaszivárgás stb.

Unit teszt

Unit-teszt a metódusokat teszteli. Segítségével megvizsgáljuk, hogy egy adott metódus visszatérési értéke vagy a metódus hatása megegyezik-e az elvárttal, ha igen a teszt sikeres, ha nem akkor sikertelen. Fontos, hogy magának a unit-tesztnek nem lehet mellékhatása a rendszeren.

Integrációs teszt

Az integrációs teszt során a komponensek közötti kölcsönhatásokat vizsgáljuk, vagy esetleg a rendszer és más rendszerek kölcsönhatásait vizsgáljuk (rendszer integrációs teszt). Az integrációs teszt azért fontos, mert nagyobb rendszerek esetén megeshet, hogy egy-egy komponenst más-más fejlesztői csapat fejleszt, így esetleg félreértésekből komoly hibák keletkezhetnek.

Rendszerteszt

A rendszerteszt legyakrabban fektetdobozos teszt. Feladata, hogy ellenőrizze, hogy a specifikációnak megfelel-e a termék.

Átvételi teszt

Hasonló a rendszerteszthez, mert ekkor is a teljes rendszer vizsgálata történik, de ezt már a végfelhasználók végzik. Több lépésből áll:

  1. Alfa teszt: Ilyenkor maga a tesztelés a fejlesztő cégnél zajlik, de nem a fejlesztői csapat által. Sokszor különböző segédprogramokkal egérkattintásokat szimulálnak és megvizsgálják, hogy össze-vissza kattintgatás vajon képes-e a rendszert kidönteni.
  2. Béta teszt: A végfelhasználók egy adott csoportja végzi.
  3. Felhasználó átvételi teszt: A végfelhasználók éles környezetben használatba veszik a rendszert, de nem alapoznak rá mindent. Általában ilyenkor használják aktívan a rendszert, de mellette a régi bevált módszereket is.
  4. Üzemeltetői átvételi teszt: A rendszer fenntartói ellenőrzik azon funkciókat, amely a rendszer üzemeltetését segítik esetleg (pl.: biztonsági mentések és helyreállítási funkciók), ha vannak ilyen funkciók.

A fenti egy rövid összefoglaló, arról, hogy milyen tesztelések léteznek egy szoftver esetén. Mi ezen a kurzuson a Unit tesztelést fogjuk megnézni (Spring-es környezetben), mivel a Unit teszteket általában maguk a fejlesztők írják. Akit a tesztelés mélyebben is érdekel felveheti a Szoftvertesztelés Alapjai kurzust, valamint mélyebben is utána olvashat itt.

TDD (Test-Driven Development)

Az agilis szoftverfejlesztés részeként alkalmazhatunk úgynevezett Test-Driven Development-et (TDD) is, mely célja, hogy először elhasaló teszteseteket írunk, majd a tesztesetek alapján írjuk meg a tényleges production kódot úgy, hogy a tesztesetek rendre mind "kizöldüljenek".

A TDD által rövid ciklusokban végezhetjük a fejlesztést, melynek lépései:

  1. Teszt hozzáadása az új funkcionalitáshoz/viselkedéshez
  2. Futtassuk, hogy lássuk, hogy elhasal
  3. Írjunk annyi kódot, hogy az előző teszt átmenjen
  4. Ellenőrizzük, hogy az új teszt mellett az összes eddigi tesz is átmegy
  5. Kód refaktorálás
  6. Előzőek iterálása, ameddig az új funkció el nem készül

A bukó tesztek írása segíti a rendszer tényleges megértését a fejlesztő számára, rá van kényszerítve, hogy megértse az új viselkedés mibenlétét. Emellett a gyors visszajelzések mentén jobban fókuszálhatjuk a figyelmünket és gyorsabban kaphatunk pozitív megerősítést, hogy amit csinálunk az jó irányba megy. A legfontosabb két cél, melyet segít elérni a TDD a következőek:

  • Regressziós hibák detektálása
  • Rendszer egyszerű marad, mivel minél kisebb egységekben szeretnénk teszteket írni (segíti a lazán csatolt megoldásokat)

Unit Testing With JUnit5

A Unit teszt esetén legtöbbször a következő szempontokra kell nagyon figyelni:

  • Egy osztály minden publikus metódusát tesztelni kell
  • A triviálisnak tűnő eseteket mindenképp tesztelni kell
  • Kell olyan teszteseteket írni, amely speciális eseteket fednek le
  • Kell pozitív és negatív teszteseteket is írni. Negatív teszteset, amikor egy függvényt hibás paraméterezéssel hívunk meg és ellenőrizzük, hogy megfelelő-e a hibakezelés.

A jó Unit tesztek a következő tulajdonságokkal rendelkeznek:

  • Olvasható: A teszt olvasója könnyen értelmezheti a tesztet (mit is csinál a teszt). Jó elnevezést kell választani!
  • Gyors: Egy tesztnek gyorsan kell futnia, melyet segít a függőségek mockolása (lásd később). A mockolással a függőségeket szimulálhatjuk kontrollált környezetben.
  • Független és izolált: A tesztek során a sorrendnek nem szabad számítania. Nem szabad másik teszttől függnie.
  • Korrekt: A teszt azt csinálja, amit a neve sugall. Egy teszt egy szimpla esetet tesztel.
  • Környezet agnosztikus: A tesztek nem függhetnek környezeti konfigurációktól. Pl.: környezeti változók, fájl adott helyen kell, hogy legyen, stb.
  • Megismételhető: A teszt újrafuttatáskor ugyanazt az eredményt kell, hogy adja minden alkalommal.

A legtöbb nyelvhez létezik Unitteszt keretrendszer. A Java nyelv esetén leggyakrabban a JUnit5 keretrendszert használjuk, így most ezzel ismerkedünk meg egy kicsit.

Miért is kellett a JUnit 5 az elődje helyett?

  • Modularitás: A korábbi verziókban ez hiányzott. Minden egy JAR-ba volt belegyümüszkélve, ami azt jelentette, hogy mindenki függött a JUnit JAR-tól (az IDE, a build tool-ok, az extension-ök, stb.).
  • Bővíthetőség: A JUnit 4-ben két módszer volt a bővítésre:
    • Runner API: Saját teszt futtatóhoz a komplett test életciklust implementálni kell (inicializálás, futtatás, setup, teardown, stb.). Egyszerre több futtatót nem lehet kombinálni.
    • Rule API: A tesztek előtt és után lefutó szabályok megadása. Nem lehet egyszerre osztály és metódus szintű callback-et is megadni egy rule-hoz
  • Java 8

A JUnit 5 keretrendszer az előző verzió teljes újraírása Java 8 használatával, szóval ez azt is jelenti, hogy legalább 8-as java kell a JUnit 5 használatához. A JUnit 5 tervezésekor backward compatibility-t is szem előtt tartották, így a 4-es, sőt még a 3-as JUnit-tal írt tesztjeinket is le tudjuk futtatni.

JUnit architektúra

A JUnit 3 fő alprojektből épül fel, melyek mindegyike további modulokat tartalmaz.

  1. JUnit Platform: JVM teszt keretrendszerek indításához szükséges alapok. Magában foglalja a TestEngine API-t, mely segítségével tesztfuttató rendszereket írhatunk. Szolgáltatja továbbá a ConsoleLauncher-t, melyet a build eszközök használnak, mint például a Gradle és a Maven.
  2. JUnit Jupiter: A tesztek írásához szükséges alprojekt. Implementálja a TestEngine API-t, szóval a JUnit 5-ös teszteket tudjuk is futtatni.
  3. JUnit Vintage: TestEngine implementáció JUnit 3-as és 4-es verziójú tesztekhez.

Az alábbi ábrán a JUnit architektúra felépítése jól látható:

junit_arch

A JUnit 5-ben található modulok:

  • junit-platform-commons: Közös részeket tartalmazó modul.
  • junit-platform-launcher: A Launcher API-t definiálja, melyet a külső tool-ok használhatnak. A Launcher-eket a tesztek felderítésére, szűrésére és futtatására használhatjuk.
  • junit-platform-engine: Amennyiben saját TestEngine-t szeretnénk írni, akkor ebben a modulban található API-t kell implementálnunk.
  • junit-platform-console: ConsoleLauncher implementáció, mely segítségével elindíthatjuk a junit platform-ot a konzolból (build eszközöknek kell).
  • junit-platform-gradle-plugin: JUnit 5 tesztek futtatására alkalmas Gradle plugin.
  • junit-platform-surefire-provider: Maven integráció támogatása JUnit 5-höz.
  • junit-jupiter-engine: A junit-platform-engine API implementációja JUnit 5 tesztekhez, azaz ez a JUnit 5 tesztek futtatója.
  • junit-jupiter-api: Az API, amit teszt íráshoz használhatunk.
  • junit-vintage-engine: Ugyanaz mint az előző, de 3-as és 4-es tesztek futtatásához.

Első tesztünk

Első körben hozzunk létre egy függvényt, amelyet később tesztelni fogunk! Készítsünk egy függvényt, amely egy másodfokú egyenlet gyökeit adja meg, ha ismerjük a-t, b-t és c-t a másodfokú egyenlet megoldóképletből. A függvény a gyököket egy 1 vagy 2 elemű tömbként adja vissza (0. index = 1. gyök; 1. index = 2. gyök). Ha nincs valós megoldása az egyenlenek, akkor egy ArithmeticException kivételt dob a következő hibaüzenettel "No real roots".

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class OwnMathClass {
    public static double[] quadraticEquationSolver(double a, double b, double c) throws ArithmeticException{

        double[] result;

        double discriminant = b * b - 4.0 * a * c;
        if (discriminant > 0.0){
            result = new double[2];
            result[0] = (-b + Math.sqrt(discriminant)) / (2.0 * a);
            result[1] = (-b - Math.sqrt(discriminant)) / (2.0 * a);
            return result;
        }else if (discriminant == 0.0){
            result = new double[1];
            result[0] = -b / (2.0 * a);
            return result;
        }

        throw new ArithmeticException("No real roots");
    }
}
A fenti kódból is jól látható, hogy 3 különböző válaszra kell összpontosítanunk a teszteseteink során. Ha a diszkrimináns nagyobb mint 0, ha egyenlő 0-val és mikor nincs megoldás. Ezen lehetőségeket kell lefednünk a teszteseteinkkel. Egy teszteset definiálható a tesztadattal és az elvárt válasszal. Ha az eredmény az elvárt válasszal megegyezik, akkor sikeresnek tekinthető a teszt.

Ahhoz, hogy egy Java projektben JUnit5 segítségével teszteket írjunk az alábbi dependency-re lesz szükségünk:

1
2
3
4
5
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.7.0</version>
</dependency>

Létre kell hoznunk egy vagy több tesztosztályt, amelyet általában az src/test/java modulban a projektünkkel azonos package struktúrával adunk meg. Egy tesztosztály az alábbi struktúrában adható meg:

1
2
3
4
5
6
7
8
9
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class OwnMathClassTest {
    @Test
    void testIfEquationHasTwoResults(){

    }
}
Az osztály neve megegyezik azon osztály nevével, amelyhez a teszteket írjuk és egy Test postfixummal jelezzük, hogy ez egy tesztosztály (ez csak egy konvenció, bárhogyan hívhatom a teszt osztályaimat). A tesztosztály @Test annotációval ellátott függvényei lesznek a tesztesetek. A függvényeket érdemes úgy elnevezni, hogy minél jobban leírja, hogy az adott teszteset mit vizsgál.

Az első tesztesetünk azt fogja vizsgálni, hogy jó eredményt ad-e vissza a quadraticEquationSolver() metódusunk, ha egy másodfokú egyenletnek 2 megoldása van.

  1. Lépés: Keresünk, olyan másodfokú egyenletet, amelynek 2 megoldása van: \(x^2-4x-5\)
  2. Határozzuk meg az elvárt eredményt: \(\{5,-1\}\)

A második tesztesetünk azt fogja vizsgálni, hogy jó eredményt ad-e vissza a quadraticEquationSolver() metódusunk, ha egy másodfokú egyenletnek 1 megoldása van.

  1. Lépés: Keresünk, olyan másodfokú egyenletet, amelynek 1 megoldása van: \(x^2+6x+9\)
  2. Határozzuk meg az elvárt eredményt: \(\{-3\}\)

A harmadik tesztesetünk azt fogja vizsgálni, hogy jó eredményt ad-e vissza a quadraticEquationSolver() metódusunk, ha egy másodfokú egyenletnek egytelen valós megoldása sincs.

  1. Lépés: Keresünk, olyan másodfokú egyenletet, amelynek nincs valós megoldása: \(x^2+2x+8\)
  2. Határozzuk meg az elvárt eredményt: ArithmeticException és "No real roots" üzenet

A negyedik tesztesetünk azt fogja vizsgálni, hogy jó eredményt ad-e vissza a quadraticEquationSolver() metódusunk, ha egy másodfokú egyenlet értékeit random határozzuk meg.

  1. Lépés: Adjuk hozzá a következő dependency-t a pom.xml-hez
1
2
3
4
5
6
<dependency>
    <groupId>io.github.glytching</groupId>
    <artifactId>junit-extensions</artifactId>
    <version>2.4.0</version>
    <scope>test</scope>
</dependency>

Megjegyzés

Ilyen extension-t saját magunk is írhatunk!

A következőben meg kell néznünk, hogy hogyan tudjuk ellenőrizni, hogy jó eredményt ad-e a függvényünk. Ennek ellenőrzéséhez szükségünk van néhány kisegítő függvényre, amelyeket a JUnit ad számunkra:

Method Működése
assertEquals(expected, result, message) Akkor sikeres a teszteset, ha az expected és a result egyenlő (object esetén meghívja az equals() metódust). A message egy nem kötelező paraméter, amely megadja, hogy ha a teszteset elszáll, akkor milyen üzentet adjon vissza. (ez mindenhol igaz)
assertArrayEquals(expected, result, message) Akkor sikeres a teszteset, ha az expected és a result tömb azonos. Fontos,hogy számít a sorrend és az elem szám is!
assertIterableEquals(expected, result, message) Akkor sikeres a teszteset, ha az expected és a result Iterable interface-t megvalósító objektum azonos. Fontos, hogy számít a sorrend és az elem szám is!
assertNotEquals(expected, result, message) Akkor sikeres a teszteset, ha az expected és a result nem egyenlő (Object esetén meghívja az equals() metódust).
assertTrue(result, message) Akkor sikeres a teszteset, ha a result igaz értékű
assertThrows(expected, executable, message) Akkor sikeres a teszteset, ha az executable (legtöbbször lambda expression) dobja az adott exceptiont. Úgy szoktuk vizsgálni, hogy adunk egy lambda kifejezést, amelyen belül meghívjuk a saját függvényünket.
assertDoesNotThrow(expected, executable, message) Akkor sikeres a teszteset, ha az executable (legtöbbször lambda expression) nem dobja az adott exceptiont. Úgy szoktuk vizsgálni, hogy adunk egy lambda kifejezést, amelyen belül meghívjuk a saját függvényünket.
assertNull(result, message) Akkor sikeres a teszteset, ha a result objektum null.
assertNotNull(result, message) Akkor sikeres a teszteset, ha a result objektum nem null.
assertSame(expected, result, message) Akkor sikeres a teszteset, ha a result objektum megegyezik az expected objektummal. Itt fontos megjegyezni, hogy akkor egyezik két objektum, ha referenciáik megegyeznek!
assertNotSame(expected, result, message) Akkor sikeres a teszteset, ha a result objektum nem egyezik az expected objektummal. Akkor nem egyezik meg 2 objektum, ha referciáik különbözőek!
assertTimeout(expected, executable, message) Akkor sikeres a teszteset, ha az executable (legtöbbször lambda expression) lefut az expected által meghatározott idő alatt.
assertAll(executable) Csoportosíthatunk vele ellenőrzéseket. Egymásba is ágyazhatók. (lásd későbbi példák)
fail(message) Sikertelenné tesz egy tesztesetet, ha meghívjuk

Megeshet, hogy adott teszteseteket csak bizonyos feltételekkel szeretnénk végrehajtani (például más-más teszteseteket szeretnénk futtatni prod és dev környezetben, vagy esetleg operációs rendszertől függően).

Egyik megoldás lehet, hogy használjuk a org.junit.jupiter.api.Assumptions osztályban található statikus függvényeket a következő módon:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Test
void testOnlyOnDevEnviroment() {
    Assumptions.assumeTrue("DEV".equals(System.getenv("ENV")), "ONLY IN DEV");
    // Tesztek, amelyek csak akkor futnak le ha DEV környeztben vagyunk
}

@Test
void testInAllEnvironments() {
    Assumptions.assumingThat("PROD".equals(System.getenv("ENV")),
        () -> {
            // Ebben a blockban lévő tesztek csak PROD környeztben futnak le
        });
    // Itt lévő tesztek lefutnka minden környeztben
}

TODO: Annotációk

Most már nagyjából látjuk mire képes a JUnit5. Jól látható, hogy ezek az ellenőrző függvények sokszor nagyon megkötik a kezünket, de alapvető teszteléshez elegendőek. Mielőtt továbblépnénk és megnéznénk, hogyan lehet még jobb teszteseteket írni, írjuk meg az osztályunkhoz tartózó alapvető teszteseteket! Az olvashatóság kedvéért használni fogjuk a @DisplayName("Név") annotációt, amellyel a teszteseteket könnyen elnevezhetjük (felülírható vele az automatikus elnevezés, amely a metódus nevével lenne ekvivalens).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import io.github.glytching.junit.extension.random.Random;
import io.github.glytching.junit.extension.random.RandomBeansExtension;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import java.util.logging.Logger;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.*;


public class OwnMathClassTest {

    private final Logger logger = Logger.getLogger("TestLogger");
    @Test
    @DisplayName("Equation with two results")
    void testIfEquationHasTwoResults(){
        final double[] RESULT = OwnMathClass.quadraticEquationSolver(1,-4,-5);
        final double[] EXPECTED = {5,-1};

        assertEquals(EXPECTED.length, RESULT.length,
                "a:1 b:-4 c:-5 egyenletre nem megfelelő elemszámú tömböt adott vissza");
        assertArrayEquals(EXPECTED,RESULT,
                "a:1 b:-4 c:-5 egyenletre adott gyökök nem megfelelőek!");
    }

    @Test
    @DisplayName("Equation with one results")
    void testIfEquationHasOneResult(){
        final double[] RESULT = OwnMathClass.quadraticEquationSolver(1,6,9);
        final double[] EXPECTED = {-3} ;

        assertEquals(EXPECTED.length, RESULT.length,
                "a:1 b:6 c:9 egyenletre nem megfelelő elemszámú tömböt adott vissza");
        assertArrayEquals(EXPECTED,RESULT,
                "a:1 b:6 c:9 egyenletre adott gyökök nem megfelelőek!");
    }

    @Test
    @DisplayName("Equation with no real roots")
    void testIfEquationHasNoRealRoots(){
        ArithmeticException ex = assertThrows(ArithmeticException.class, () -> {
            OwnMathClass.quadraticEquationSolver(1,2,8);
        }, "a:1 b:6 c:9 egyenletre nem dob ArithmeticException");

        assertEquals("No real roots", ex.getMessage(),
                "Az ArithmeticException szövege nem megfelelő");
    }

    @DisplayName("Equations with random coefficients")
    @RepeatedTest(20) //20 szer megismételt a teszt
    @ExtendWith(RandomBeansExtension.class)
    void testWithRandomNumbers(@Random(type = Double.class) Double a, @Random(type = Double.class) Double b, @Random(type = Double.class) Double c){

        double discriminant = b * b - 4.0 * a * c;

        logger.info(String.format("A teszt random értékei: a:%f b:%f c:%f -> Diszkrimináns: %f",a,b,c, discriminant));

        assumingThat(discriminant == 0, ()->{
            assertEquals(1, OwnMathClass.quadraticEquationSolver(a, b, c).length,
                    String.format("a:%f b:%f c:%f egyenletre nem megfelelő elemszámú tömböt adott vissza", a,b,c));
        });
        assumingThat(discriminant > 0, ()->{
            assertEquals(2, OwnMathClass.quadraticEquationSolver(a, b, c).length,
                    String.format("a:%f b:%f c:%f egyenletre nem megfelelő elemszámú tömböt adott vissza", a,b,c));
        });
        assumingThat(discriminant < 0, ()->{
            ArithmeticException ex = assertThrows(ArithmeticException.class, ()->{
                OwnMathClass.quadraticEquationSolver(a,b,c);
            }, String.format("a:%f b:%f c:%f egyenletre nem dob ArithmeticException", a,b,c));
            assertEquals("No real roots", ex.getMessage(),
                    "Az ArithmeticException szövege nem megfelelő");
        });
    }

}

AssertJ

Ahogy fentebb említettük a Junit5 által adott metódusok sokszor túl szigorúak (pl.: két tömb csak akkor ekvivalens, ha az elemek pozíciói is megegyeznek) vagy éppen nem elég kifinomultak komolyabb tesztesetek elkészítéséhez (pl.: objektumok összehasonlítása referencia szerint működik csak). Ezért több különböző library is létrejött, amelyek jól összehangolhatók a JUnit keretrendszerrel és assert függvényeikkel könnyebbé teszik a tesztesetek írását. Ebből a legismertebb az AssertJ. Ahhoz, hogy az AssertJ library-t használni tudjuk az alábbi dependency-re lesz szükségünk:

1
2
3
4
5
6
<dependency>
  <groupId>org.assertj</groupId>
  <artifactId>assertj-core</artifactId>
  <version>3.11.1</version>
  <scope>test</scope>
</dependency>

Valamint a következő statikus importot is el kell helyezzük a tesztosztályunk importjai között:

1
import static org.assertj.core.api.Assertions.*;

A következőekben megnézünk néhány alap metódust, amelyeket használhatunk. Későbbiekben, amikor a projektünkhöz írunk tezsteket ennél jóval szélesebb skáláját fogjuk látni az AsserJ által nyújtott függvényeknek.

Az AssertJ használata objektumokkal:

Hozzunk létre egy Point Java osztályt az alábbiak szerint:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Point {
    private int x = 0;
    private int y = 0;

    public int getX() {
        return x;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getY() {
        return y;
    }

    public void setY(int y) {
        this.y = y;
    }

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

}

Hozzuk létre a Point osztályunkhoz tartotó tesztosztályt PointTest névvel a következő módon:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import static org.assertj.core.api.Assertions.*;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.logging.Logger;

public class PointTest {
    private final Logger logger = Logger.getLogger("TestLogger");

    @Test
    void checkObject(){
        Point p1 = new Point(1,1);
        Point p2 = new Point(1,1);

        assertThat(p1).isEqualTo(p2);
    }
}
A fenti kódrészletben jól látható, hogy p1 és p2 objektumok "tartalma" megegyezik, de az isEqualTo() metódus mégis hamissal tér vissza. Az isEqualTo() metódus a referenciákat hasonlítja össze, ha nincs equals() metódus. Ha nincs equals() metódus, akkor használható az isEqualToComparingFieldByField(). Több hasonló függvény is található, amelyeket nem sorolunk most fel.

Az AssertJ használata boolean értékekkel:

1
2
3
4
5
6
@Test
void checkBoolean(){
    assertThat(false).isFalse();
    assertThat(true).isTrue();

}
Igaz/hamis értékek ellenőrzésére a fenti két függvény használható.

Az AssertJ használata tömbökkel és Iterable objektumokkal:

1
2
3
4
5
6
7
@Test
void checkIterable(){
    int[] a = {1,2,3};
    int[] b = {3,2,1};

    assertThat(a).contains(b);
}

Bármilyen iterálható objektum összehasonlítható a contains() metódussal. A JUnit5 alapvető függvénye nem működik, ha a sorrend nem azonos. A contains() metódus nem figyeli a sorrendet. Több hasonló függvény is található a AssertJ könyvtárban, ezekre később még kitérünk, ha szükségünk lesz rá.

Az AssertJ használata fájlokkal:

1
2
3
4
5
6
7
8
9
@Test
void checkFile(){
    File someFile = new File("test.txt");
    assertThat(someFile)
            .exists()
            .isFile()
            .canRead()
            .canWrite();
}

Az AssertJ biztosít számunkra olyan metódusokat is, amelyekkel fájlok meglétét, fájlok tulajdonságaira írhatunk vizsgálatokat.

Az AssertJ használata Map objektumokkal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Test
void checkMap(){
    Map<String, String> map = new HashMap<String, String>();
    assertThat(map)
            .isNotEmpty()
            .containsKey("Key")
            .doesNotContainKeys("NoKey")
            .contains(entry("Key", "a"));

}

A fenti kódrészlet megmutatja, hogy milyen alapvető metódusokat tartogat számunkra az AssertJ kulcs érték párokat reprezentáló Map objektumok vizsgálatára.

Az AssertJ használata kivételek ellenőrzésére:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Test
void checkException(){
    assertThatExceptionOfType(ArithmeticException.class).isThrownBy(()->{
        throw new ArithmeticException("Error");
    }).withMessage("Error");

    assertThatThrownBy(()->{
        throw new ArithmeticException("Error");
    }).isInstanceOf(ArithmeticException.class).hasMessage("Error");

}

Természetesen sok más metódust és egyéb lehetőséget is ad számunkra az AssertJ ezekről bővebben olvashattok az alábbi sorozatban: https://www.baeldung.com/introduction-to-assertj

Mockito

Az előző részekben láttuk, hogy nagyjából milyen lehetőségeink vannak Unit tesztelésre a Java környezetben. Mielőtt továbblépnénk megnézzük még egy keretrendszert. Az mondtuk, hogy a Unit teszt esetén mindig csak egy egységet tesztelünk. Felmerülhet a kérdés, hogy mi van a dependency-kkel. Vegyük azt, hogy van egy Repository és egy Service rétege az alkalmazásunknak. Ilyen esetben a service réteg használja a repository réteget. Ha a mi feladatunk a Service réteg adott osztályának tesztelése, akkor valahogy el kell érjük, hogy a rendszer ne az eredeti Repository-t használja, hanem egy általunk írt repository-t, amely utánozza az eredeti repository-t, de általunk meghatározott válaszokkal tér vissza meghatározott bemenetekkel.

Vegyük a következő Service osztályt, amely használ egy Repository osztályt! Most még nem használunk semmilyen Spring-es dependency-t, csak egyszerű Java osztályokat fogunk írni.

UserService, amelyet tesztelünk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class UserService {
    private final UserRepository userRepository;


    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public boolean login(String username, String password){
        return this.userRepository.checkUserCredentials(username, password);
    }
}

A UserService által használt UserRepository:

1
2
3
4
5
6
7
8
public class UserRepository {
    public boolean checkUserCredentials(String username, String password){
        //Itt jön valami adatbázis kezeléses rész.

        //valami feltétel mellett vagy igazat vagy hamisat adunk vissza
        return true;
    }
}

Írjunk olyan teszteseteket a UserService osztályunkhoz, amellyel tudjuk ellenőrizni, hogy a service által visszaadott érték valóban megegyezik a UserRepository által adott válasznak (tekintsünk el attól, hogy a jelenlegi repository megvalósítás mindig igazzal tér vissza)! Létre kell hoznunk valahogy egy olyan Repository osztályt, amely utánozza a UserRepository osztályunkat! Ennek megvalósításához nyújt segítséget a Mockito keretrendszer.

A következő dependency-ket kell hozzáadnunk a projektünkhöz:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>3.5.13</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>3.5.13</version>
    <scope>test</scope>
</dependency>

A következő teszt osztályt fogjuk megírni:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserRepository userRepository; // (1)

    @Test
    public void test(){
        when(userRepository.checkUserCredentials("admin", "admin123"))
        .thenReturn(true); // (2)

        UserService us = new UserService(userRepository);
        assertThat(us.login("admin", "admin123")).isTrue();

        verify(userRepository).checkUserCredentials("admin", "admin123");// (3)
    }
}
  1. Létrehozunk egy UserRepository Mock objektumot. Ez az objektum lesz, amellyel utánozni fogjuk a UserRepository osztályt.
  2. Meghatározzuk, hogy mi történjen, ha az osztály adott metódusát adott paraméterekkel hívjuk. Megadjuk, hogy mi legyen a visszatérési érték.
  3. Megnézzük, hogy tényleg meg lett e hívva a függvény és az adott paraméterekkel.

A következőkben egy kicsit ezt egyszerűsítjük egy @InjectMocks annotációval. Ezzel az annotációval elérjük, hogy a vizsgálandó objektumunkat is a Mockito keretrendszer hozza létre valamint a keretrendszer fel is oldja a dependency-ket is. Ezzel az annotációval így egyszerűsödik a kódunk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserRepository userRepository; // (1)

    @InjectMocks
    private UserService userService; // (2)

    @Test
    public void test(){
        when(userRepository.checkUserCredentials("admin", "admin123"))
        .thenReturn(true); // (3)

        assertThat(userService.login("admin", "admin123")).isTrue();

        verify(userRepository).checkUserCredentials("admin", "admin123"); // (3)
    }
}
  1. Létrehozunk egy UserRepository Mock objektumot. Ez az objektum lesz, amellyel utánozni fogjuk a UserRepository osztályt.
  2. Létrehozza a keretrendszer számunkra a UserService osztály egy példányát és feloldja a dependency-ket
  3. Meghatározzuk, hogy mi történjen, ha az osztály adott metódusát adott paraméterekkel hívjuk. Megadjuk, hogy mi legyen a visszatérési érték.
  4. Megnézzük, hogy tényleg meg lett e hívva a függvény és az adott paraméterekkel.

Egy kicsit vissza a JUnit5-hoz

A Junit5-ről szóló részben kihagytuk egy fontos elemét a könyvtárnak. Ennek egyszerűen annyi oka volt, hogy nem feltétlen tudtuk volna bizonyítani hasznosságát könnyedén. A fenti példában látható, hogy bár le tudtuk csökkenteni a ragacs kódokat, de mégis egy igazán jó Repository utánzathoz igen sok metódus tulajdonságot kellene megadnunk, ráadásul az összes tesztesetben külön külön. Felmerülhet a kérdés, hogy vajon van-e ennek Ctrl + C, Ctrl + V kímélő módja? Természetesen igen, mivel a JUnit ad nekünk 4 olyan annotációt, amellyel tudunk teszteseteket konfigurálni.

Vannak olyan konfigurációk, amelyeket minden egyes teszteset előtt/után külön-külön újra és újra el kell végeznünk. Ilyen műveletek megadásához a @BeforeEach/@AfterEach annotációkat használhatjuk. Ilyen metódusban elhejezhetjük a mock objektumaink tulajdonságait.

Megeshet, hogy szeretnénk valamilyen műveletet az összes teszteset előtt/után végrehajtani. Ehhez a @BeforeAll/@AfterALl annotációkat használhatjuk.

Egy példa a fenti 4 annotációra:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @BeforeEach
    public void beforeEach(){
        //Lefut minden teszetese előtt
        when(userRepository.checkUserCredentials("admin", "admin123")).thenReturn(true);
        //....
    }

    @AfterEach
    public void afterEach(){
        //Lefut minden teszetese után
    }

    @BeforeAll
    public static void beforeAll(){
        //Lefut mielőtt bármely teszteset végbemenne (csak egyszer fut le)
    }

    @AfterAll
    public static void afterALl(){
        //Lefut miután minden teszteset végbement (csak egyszer fut le)
    }

    @Test
    public void test(){
        assertThat(userService.login("admin", "admin123")).isTrue();
        verify(userRepository).checkUserCredentials("admin", "admin123");
    }
}

Unit Test Spring-ben

Service tesztelés

Első körben nézzük meg, hogy a fentiek alapján, hogyan tesztelhetjük a Service rétegünket Spring Boot környezetben! Lényegében teljesen hasonlóan járunk el mint fent. A tesztelt metódus legyen a ContactUserDetailsService registerUser() metódusa.

Adjuk a következő dependency-t a pom.xml fájlunkhoz (új projekt esetében automatikusan hozzáadja a Spring Initializr ezt a projektünkhöz):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
    </exclusions>
</dependency>

A teszttel most azt ellenőrizzük, hogy a ContactUserDetailsService valóban ugyan azt adja-e vissza, mint a ContactUserDetailsRepository:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@ExtendWith(MockitoExtension.class)
public class ContactUserDetailsServiceTest {

    @Mock
    ContactUserRepository contactUserRepository;

    @Mock
    RoleRepository roleRepository;

    @InjectMocks
    ContactUserDetailsService contactUserDetailsService;

    @BeforeEach
    public void init(){
        when(contactUserRepository.save(any(ContactUser.class)))
        .then(returnsFirstArg()); // (1)
    }

    @Test
    public void testRegisterUser(){

        ContactUser contactUser = new ContactUser();
        contactUser.setUsername("TESZT");

        ContactUser u = contactUserDetailsService.registerUser(contactUser);

        assertThat(u).isEqualToComparingFieldByField(contactUser);

        verify(contactUserRepository).save(contactUser);

    }
}
  1. Meghatározzuk, hogy ha a ContactUserRepository save() metódusa hívásra kerül bármilyen ContactUser típusú objektummal, akkor térjen vissza azzal az objektummal, amit átadtunk neki.

Controller tesztelés

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@SpringJUnitWebConfig(locations = "test-servlet-context.xml") // (1)
public class ControllerTest {
        MockMvc mockMvc; // (2)

        @BeforeEach
        void setup(/*WebApplicationContext wac*/) {
            //this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
            this.mockMvc = MockMvcBuilders.standaloneSetup(ContactRestController.class)
            .build(); // (3)
        }

        @Test
        void getAccount() throws Exception {
            MvcResult res this.mockMvc.perform(get("/URI") //get() post() put() delete() patch()
                    .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk())
                    .andExpect(content().contentType("application/json"))
                    .andExpect(jsonPath("$.path").value("value")).andReturn(); // (4)
        }

}

A fenti kódrészlet egy API végpont teszt példája. Nézzük meg mit is csinál az egyes kódrészek

  1. A @SpringJUnitWebConfig egy összetett annotáció, amely kombinálja a következő annotációkat:

    • @ExtendWith(SpringExtension.class): Egy kiterjesztést definiál a JUnit tesztünkhöz
    • @ContextConfiguration: A spring context konfigurációját adja meg
    • @WebAppConfiguration: Betölti a WebApplicationContextet
  2. Egy MockMvc példányon keresztül fogjuk a kéréseket létrehozni, valamint bizonyos elvárásokat is megfogalmazhatunk a válasszal kapcsolatban.

  3. Ha a kikommentezett verziót választjuk, akkor betöltődik a tényleges Spring MVC konfiguráció. A nem kikommentelt verzió talán kicsit közelebb áll a UnitTesztelés filozófiájához, hiszen ekkor csak egyetlen vezérlőt tudunk egyszerre tesztelni. Manuálisan létrehozhatjuk az álfüggőségeket és ez nem jár a Spring konfiguráció betöltésével.
  4. Megadjuk, hogy milyen kérést szimuláljon a rendszer, valamint a válaszra tehetünk megkötéseket (válasz media type-ja, státus kód, content type, és a JSON-ben is tudunk feltételeket adni).

Hasznos linkek

https://reflectoring.io/unit-testing-spring-boot/ https://spring.io/guides/gs/testing-web/ https://docs.spring.io/spring/docs/current/spring-framework-reference/testing.html


Utolsó frissítés: 2020-11-04 12:59:03