Testing

Mivel a kurzus nem feltételez semmilyen jellegű előfeltételt a teszteléssel kapcsolatban, így először az alapokra repülünk rá.

Első teszt

Feladat

Készítsünk egy könyvespolc alkalmazást, melyet TDD fejlesztéssel valósítunk meg!

Első lépésként hozzunk létre egy új Maven projektet, majd adjuk hozzá a következő dependency-t!

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

Készítsük el az első tesztünket, mely alapján, ha könyvespolchoz még nem adunk hozzá könyveket, akkor az még üresnek kell hogy legyen!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class BookShelfTest {

    @Test
    public void bookshelfEmptyWhenNoBookAdded() {
        BookShelf shelf = new BookShelf();
        List<String> books = shelf.books();
        assertTrue(books.isEmpty(), "Bookshelf should be empty");
    }

}

A fenti példa nyilván hibát dob, így hozzuk létre a BookShelf osztályt és annak books metódusát is!

1
2
3
4
5
6
7
public class BookShelf {

    public List<String> books() {
        return Collections.emptyList();
    }

}

Most már futtathatjuk a tesztet, melynek csont nélkül át kell mennie. Egy osztályhoz általában egy teszt osztályt hozunk létre, ahogy ez fent is látszik. A teszt osztályon belül a teszteseteket a @Test annotációval ellátott metódusok adják. Jelen esetben egy új könyvespolcot hozunk létre, melytől elkérjük a könyveket és ellenőrizzük az assertTrue segítségével, hogy valóban üres-e a visszakapott lista. Amennyiben igaz ezen állítás, akkor a teszteset átmegy, máskülönben elbukik.

JUnit5-ben az assert-ek a org.junit.jupiter.api.Assertions csomagban találhatóak. A komolyabb dolgokhoz 3rd-party libeket szokás használni, de az alap jupiter-es assertek is használhatóak. Az assertXXX alakú metódusok rendre 3 féle overload-al rendelkeznek:

  • assertNull(str);
  • assertNull(str, "str should be null");
  • assertNull(str, () -> "str should be null");

Az első esetben feltételezzük, hogy az str értéke null. Amennyiben ez nem igaz, akkor egy AssertionFailedError kivétel kelezkezik. A második esetben egy további szöveget adhatunk át paraméterként, mely szöveget a felhasználónak fogunk megmutatni, amennyiben a teszt elhasal. A 3 esetben egy Supplier callback-el állíthatjuk elő az üzenetet teljesen dinamikusan. Ez akkor tud nagyon jól jönni, ha komplexebb üzenetet állítunk elő.

@DisplayName

Mind a teszt osztályra és azok teszteseteire (metódusok @Test annotációval ellátva) rárakhatjuk a @DisplayName annotációt, mellyel egyedi neveket adhatunk a teszteknek. Pl.:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@DisplayName("Bookshelf")
class BookShelfTest {

    @Test
    @DisplayName("should be empty if no book was added.")
    public void bookshelfEmptyWhenNoBookAdded() {
        BookShelf shelf = new BookShelf();
        List<String> books = shelf.books();
        assertTrue(books.isEmpty(), "Bookshelf should be empty");
    }

}

A @DisplayName előnye, hogy használhatunk benne szóközöket, így javíthatják az olvashatóságot.

Feature request

Tudjunk könyvet hozzáadni a könyvespolchoz, melyet így később elolvashatunk!

Teszt eset a következő lehet:

1
2
3
4
5
6
7
8
9
@Test
@DisplayName("should have two books after adding two books")
public void bookshelfContainsTwoBooksWhenTwoBooksAdded() {
    BookShelf shelf = new BookShelf();
    shelf.add("Effective Java");
    shelf.add("Clean Code");
    List<String> books = shelf.books();
    assertEquals(2, books.size(), () -> "BookShelf should have two books.");
}

Az átalakítás után valami ilyesminek kell lennie az osztálynak:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class BookShelf {

    private List<String> books = new ArrayList<>();

    public List<String> books() {
        return books;
    }

    public void add(String title) {
        books.add(title);
    }
}

Van-e valami, amit refaktorálhatnánk? Például megcsinálhatjuk azt, hogy egyszerre több könyvet is hozzá lehessen adni a könyvespolchoz. Ehhez alakítsuk át az add metódust!

1
2
3
public void add(String... booksToAdd) {
    Arrays.stream(booksToAdd).forEach(book -> books.add(book));
}

Megjegyzés

A lambda kifejezés helyett használhatunk metódus referenciát is.

Ezután a tesztet is alakíthatjuk ennek megfelelően:

1
2
3
4
5
6
7
8
@Test
@DisplayName("should have two books after adding two books")
public void bookshelfContainsTwoBooksWhenTwoBooksAdded() {
    BookShelf shelf = new BookShelf();
    shelf.add("Effective Java", "Clean Code");
    List<String> books = shelf.books();
    assertEquals(2, books.size(), () -> "BookShelf should have two books.");
}

Szélsőséges esetek tesztelése

Mi történik akkor, amikor semmit sem adunk hozzá a könyvespolchoz (az add() üres paraméterlistával lett meghívva)?

Jelen esetben azt várjuk el, hogy üres maradjon a könyvespolc ilyen esetekben, hiszen nem adtunk hozzá semmit.

1
2
3
4
5
6
7
8
@Test
@DisplayName("should be empty after adding zero books")
public void emptyBookShelfWhenAddIsCalledWithoutBooks() {
    BookShelf shelf = new BookShelf();
    shelf.add();
    List<String> books = shelf.books();
    assertTrue(books.isEmpty(), () -> "BookShelf should be empty.");
}

A következőkben szeretnénk, ha a visszaadott könyvespolcot nem tudná módosítani a felhasználó.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Test
@DisplayName("should be immutable")
void booksReturnedFromBookShelfIsImmutableForClient() {
    BookShelf shelf = new BookShelf();
    shelf.add("Effective Java", "Clean Code");
    List<String> books = shelf.books();
    try {
        books.add("IT");
        fail("Should not be able to add book to books");
    } catch (Exception e) {
        assertTrue(e instanceof UnsupportedOperationException, "Should throw UnsupportedOperationException.");
    }
}

Ahhoz, hogy ez a teszt átmenjen, készítsünk unmodifiable listát:

1
2
3
public List<String> books() {
    return Collections.unmodifiableList(books);
}

Teszt inicializálás kiszervezése

Minden tesztesetnél azzal kell kezdenünk, hogy egy új BookShelf-et hozunk létre. Ezt felesleges mindenhova lemásolnunk. Helyette inkább használjuk a @BeforeEach-el ellátott metódusunkat!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class BookShelfTest {

    private BookShelf shelf;

    @BeforeEach
    public void init(){
        shelf = new BookShelf();
    }

    @Test
    @DisplayName("should be empty if no book was added.")
    public void bookshelfEmptyWhenNoBookAdded() {
        List<String> books = shelf.books();
        assertTrue(books.isEmpty(), "Bookshelf should be empty");
    }
    ...
}

Az összes metódusból töröljük az inicializálást, majd futtassuk a tesztjeinket!

Könyvek rendezése

Szeretnénk ha a könyvespolcon a könyveket bizonyos feltételek szerint rendezni tudnánk. Kezdjük a lexikografikus rendezéssel!

1
2
3
4
5
6
@Test
void bookshelfArrangedByBookTitle() {
    shelf.add("Effective Java", "Clean Code", "IT");
    List<String> books = shelf.arrange();
    assertEquals(Arrays.asList( "Clean Code", "Effective Java", "IT"), books, "Books in a bookshelf should be arranged lexicographically by book title");
}
1
2
3
4
public List<String> arrange() {
    books.sort(Comparator.naturalOrder());
    return books;
}

Ezután minden csodásan zöld... Viszont van egy kis probléma, mégpedig az, hogy ha meghívjuk a rendezést, majd elkérjük a könyveket, akkor megváltozik az eredeti elrendezés is. Ezt pedig nem akarjuk!

1
2
3
4
5
6
7
@Test
void booksInBookShelfAreInInsertionOrderAfterCallingArrange() {
    shelf.add("Effective Java", "Clean Code", "IT");
    shelf.arrange();
    List<String> books = shelf.books();
    assertEquals(Arrays.asList("Effective Java", "Clean Code", "IT"), books, "Books in bookshelf are in insertion order");
}

Az arrange kódját a következőképpen kell megváltoztatni!

1
2
3
public List<String> arrange() {
    return books.stream().sorted().collect(Collectors.toList());
}

Könyv osztály

Az egyszerű címek helyett szeretnénk komplexebb módon kezelni a könyveket. Minden könyvnek legyen címe, szerzője és publikálási ideje!

 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
public class Book {

    private final String title;
    private final String author;
    private final LocalDate publishedOn;

    public Book(String title, String author, LocalDate publishedOn) {
        this.title = title;
        this.author = author;
        this.publishedOn = publishedOn;
    }

    public String getTitle() {
        return title;
    }

    public String getAuthor() {
        return author;
    }

    public LocalDate getPublishedOn() {
        return publishedOn;
    }

    @Override
    public String toString() {
        return "Book{" +
                "title='" + title + '\'' +
                ", author='" + author + '\'' +
                ", publishedOn=" + publishedOn +
                '}';
    }

}

Ezután változtassuk meg a tesztjeinket, hogy könyv objektumokat használjanak az egyszerű string-ek helyett!

 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
@DisplayName("Bookshelf")
class BookShelfTest {

    private BookShelf shelf;
    private Book effectiveJava;
    private Book cleanCode;
    private Book it;

    @BeforeEach
    public void init(){
        shelf = new BookShelf();
        effectiveJava = new Book("Effective Java", "Joshua Bloch",
                LocalDate.of(2008, Month.MAY, 8));
        cleanCode = new Book("Clean Code", "Robert Martin",
                LocalDate.of(2008, Month.AUGUST, 1));
        it = new Book("IT", "Stephen King",
                LocalDate.of(1986, Month.SEPTEMBER, 15));
    }

    @Test
    @DisplayName("should be empty if no book was added.")
    public void bookshelfEmptyWhenNoBookAdded() {
        List<Book> books = shelf.books();
        assertTrue(books.isEmpty(), "Bookshelf should be empty");
    }

    @Test
    @DisplayName("should have two books after adding two books")
    public void bookshelfContainsTwoBooksWhenTwoBooksAdded() {
        shelf.add(effectiveJava, cleanCode);
        List<Book> books = shelf.books();
        assertEquals(2, books.size(), () -> "BookShelf should have two books.");
    }

    @Test
    @DisplayName("should be empty after adding zero books")
    public void emptyBookShelfWhenAddIsCalledWithoutBooks() {
        shelf.add();
        List<Book> books = shelf.books();
        assertTrue(books.isEmpty(), () -> "BookShelf should be empty.");
    }

    @Test
    @DisplayName("should be immutable")
    void booksReturnedFromBookShelfIsImmutableForClient() {
        shelf.add(effectiveJava, cleanCode);
        List<Book> books = shelf.books();
        try {
            books.add(it);
            fail("Should not be able to add book to books");
        } catch (Exception e) {
            assertTrue(e instanceof UnsupportedOperationException, "Should throw UnsupportedOperationException.");
        }
    }

    @Test
    void bookshelfArrangedByBookTitle() {
        shelf.add(effectiveJava, cleanCode, it);
        List<Book> books = shelf.arrange();
        assertEquals(Arrays.asList(cleanCode, effectiveJava, it), books, "Books in a bookshelf should be arranged lexicographically by book title");
    }

    @Test
    void booksInBookShelfAreInInsertionOrderAfterCallingArrange() {
        shelf.add(effectiveJava, cleanCode, it);
        shelf.arrange();
        List<Book> books = shelf.books();
        assertEquals(Arrays.asList(effectiveJava, cleanCode, it), books, "Books in bookshelf are in insertion order");
    }
}

Javítsuk ki a fordítási hibákat a BookShelf osztályban!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class BookShelf {

    private List<Book> books = new ArrayList<>();

    public List<Book> books() {
        return Collections.unmodifiableList(books);
    }

    public void add(Book... booksToAdd) {
        Arrays.stream(booksToAdd).forEach(books::add);
    }

    public List<Book> arrange() {
        return books.stream().sorted().collect(Collectors.toList());
    }
}

Ha ezek után futtatjuk a teszteket, akkor 2 el fog hasalni. Kapunk egy ClassCastException kivételt, hiszen a Book osztály nem implementálja a Comparable interface-t, így a books.stream().sorted().collect(Collectors.toList()); hívás hibát fog eredményezni. Végezzük el ezt az implementációt!

1
2
3
4
5
6
7
8
9
public class Book implements Comparable<Book> {
    ...

    @Override
    public int compareTo(Book o) {
        return this.title.compareTo(o.title);
    }

}

Ezek után a tesztjeinknek át kell mennie. Most adjunk támogatást a felhasználónak, hogy egy tetszőleges rendezési kritériumot megadhasson! Ehhez a teszt a következő:

1
2
3
4
5
6
7
8
9
@Test
void bookshelfArrangedByUserProvidedCriteria() {
    shelf.add(effectiveJava, cleanCode, it);
    List<Book> books = shelf.arrange(Comparator.<Book>naturalOrder().reversed());
    assertEquals(
            asList(it, effectiveJava, cleanCode),
            books,
            "Books in a bookshelf are arranged in descending order of book title");
}

Az eredeti lexikografikus rendezést meghagyjuk, mely a cím alapján dolgozott, de csináljunk egy olyan ovarload-ot, mely egy Comparator-t vár paraméterben.

1
2
3
public List<Book> arrange(Comparator<Book> criteria) {
    return books.stream().sorted(criteria).collect(Collectors.toList());
}

Ezután az eredeti arrange metódust is átalakíthatjuk:

1
2
3
public List<Book> arrange() {
    return arrange(Comparator.naturalOrder());
}

Megjegyzés

Vannak esetek, amikor tudomásunk van a bukó tesztekről, de le akarjuk szűkíteni a tesztesetek számát és csak arra figyelni ami az adott iterációban fontos, akkor használhatjuk a @Disabled annotációt, melynek megjegyzést is írhatunk attribútumként! Amennyiben tényleges szeretnénk egy tesztet kivenni a tesztesetek közül, akkor ezt használjuk, ne pedig a @Test annotációt töröljük, mert az így a Test Engine látóköréből is kikerül. Előbbi esetben azonban meg tud jelenni a statisztikában, mint átugrott tesztesett.

AssertJ

Az alap JUnit csak limitált assertXXX metódusokat ad. A komolyabb munkához 3rd party használata ajánlott. A korábbi verzióban a Hamcrest be volt építve a library-be, de ez az 5-ös verzióval megszűnt.

Az előző tesztünkben van egy kis gyengepont. Mégpedig az, hogy két különböző kollekciót hasonlítunk össze, de nem validáljuk, hogy az eredményben lévő elemek valóban a biztosított comparator által előirt sorrendben vannak-e rendezve. Ha megváltoztatjuk a "beégetett" fordított sorrendet, akkor hozzá kell igazítani az eredmény lista tartalmát.

Világosabb lesz máris, hogy pontosan mire gondolunk, ehhez azonban húzzuk be az AssertJ library-t!

1
2
3
4
5
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.18.0</version>
</dependency>

Módosítsuk a tesztet úgy, hogy abban az assertThat metódust használjuk!

1
2
3
4
5
6
7
@Test
void bookshelfArrangedByUserProvidedCriteria() {
    shelf.add(effectiveJava, cleanCode, it);
    Comparator<Book> reversed = Comparator.<Book>naturalOrder().reversed();
    List<Book> books = shelf.arrange(reversed);
    assertThat(books).isSortedAccordingTo(reversed);
}

Csoportosítás biztosítása

Szeretnénk, ha a könyveket csoportosítani lehetne évszám alapján.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Test
@DisplayName("books inside bookshelf are grouped by publication year")
void groupBooksInsideBookShelfByPublicationYear() {
    shelf.add(effectiveJava, cleanCode, it);
    Map<Year, List<Book>> booksByPublicationYear = shelf.groupByPublicationYear();
    assertThat(booksByPublicationYear)
            .containsKey(Year.of(2008))
            .containsValues(Arrays.asList(effectiveJava, cleanCode));
    assertThat(booksByPublicationYear)
            .containsKey(Year.of(1986))
            .containsValues(singletonList(it));
}

A bukó teszt javítása:

1
2
3
public Map<Year, List<Book>> groupByPublicationYear() {
    return books.stream().collect(Collectors.groupingBy(book -> Year.of(book.getPublishedOn().getYear())));
}

Refaktorálási lépés lehet, hogy megengedjük a felhasználónak, hogy saját csoportosítási feltételt fogalmazzon meg.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Test
@DisplayName("books inside bookshelf are grouped according to user provided criteria(group by author name)")
void groupBooksByUserProvidedCriteria() {
    shelf.add(effectiveJava, cleanCode, it);
    Map<String, List<Book>> booksByAuthor = shelf.groupBy(Book::getAuthor);
    assertThat(booksByAuthor)
            .containsKey("Joshua Bloch")
            .containsValues(singletonList(effectiveJava));
    assertThat(booksByAuthor)
            .containsKey("Robert Martin")
            .containsValues(singletonList(cleanCode));
    assertThat(booksByAuthor)
            .containsKey("Stephen King")
            .containsValues(singletonList(it));
}

Ezután alakítsuk át a default évszám alapján történő csoportosítást is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public Map<Year, List<Book>> groupByPublicationYear() {
    return groupBy(book -> Year.of(book.getPublishedOn().getYear()));
}


public <K> Map<K, List<Book>> groupBy(Function<Book, K> f) {
    return books
            .stream()
            .collect(groupingBy(f));
}

Beágyazott tesztek

Egy jó test suite-ban több teszt tartozik egy feature teszteléséhez. Mi is több tesztet írtunk eddig, mint amennyi feature van az alkalmazásban. A tesztek logikai csoportosításához használhatjuk a @Nested annotációt, melyet egy belső osztályra rakhatunk. Minden ilyen belső osztálynak lehetnek saját életciklus eseményei (@BeforeAll, @BeforeEach, stb). A beágyazás mélysége nincs megkötve és így még jobban tudjuk csoportosítani a tesztjeinket.

 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
@DisplayName("Bookshelf")
class BookShelfTest {

    private BookShelf shelf;
    private Book effectiveJava;
    private Book cleanCode;
    private Book it;

    @BeforeEach
    public void init(){
        ...
    }

    @Nested
    @DisplayName("is empty")
    class IsEmpty{

        @Test
        @DisplayName("should be empty if no book was added.")
        public void bookshelfEmptyWhenNoBookAdded() {
            List<Book> books = shelf.books();
            assertTrue(books.isEmpty(), "Bookshelf should be empty");
        }

        @Test
        @DisplayName("should be empty after adding zero books")
        public void emptyBookShelfWhenAddIsCalledWithoutBooks() {
            shelf.add();
            List<Book> books = shelf.books();
            assertTrue(books.isEmpty(), () -> "BookShelf should be empty.");
        }
    }

    ...


}

DI, Mocking

Nagyobb rendszerek tesztelésekor több komponens együttesen valósít meg egy-egy feature-t. Ilyenkor a komponensek közötti függőségek a tesztekbe is begyűrűznek, ami nem túl szerencsés, mivel a lehető legkisebb egységeket szeretnénk egyben tesztelni.

A JUnit 5 támogatást ad dependency injection-re, aminek a segítségével a teszt adatok beállítását nagyon elegánssal meg tudjuk oldani. JUnit 5-ben mind a teszt metódusokba, mind a konstruktorokba tudunk függőségeket injektálni (elődjénél nem lehetett paramétereket adni egyiknek se).

Az alkalmazásunkban a @BeforeEach-ben állítottuk be a teszt adatainkat. A következő problémák léphetnek fel:

  • A teszt kód szorosan csatolt a teszt adattal. Mi van akkor, ha más adatokat szeretnénk használni, mondjuk valamilyen feltételek teljesülése mellett?
  • A teszt adat újrafelhasználása a teszt osztályra korlátozódik

Módosítsuk a BeforeEach metódust ennek megfelelően!

1
2
3
4
5
6
7
8
@BeforeEach
public void init(Map<String, Book> books){
    shelf = new BookShelf();

    this.cleanCode = books.get("Clean Code");
    this.effectiveJava = books.get("Effective Java");
    this.it = books.get("IT");
}

Az init metódusnak már nem felelős a teszt adat létrehozásáért, azt majd valahol máshol fogjuk elvégezni. Na de hol? A JUnit erre a kérdésre az ún. ParameterResolver API-val válaszol. Használhatunk beépített ParameterResolver-t is (pl.: TestInfoParameterResolver) vagy készíthetünk sajátot is. Ahhoz, hogy a tesztjeink tudjanak a saját custom resolverről használnunk kell az @ExtendWith annotációt!

1
2
3
4
@ExtendWith(BooksParameterResolver.class)
public class BookShelfTest {
    ...
}

A BooksParameterResolver osztály a következőképpen néz ki:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class BookParameterResolver implements ParameterResolver {
    @Override
    public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        return false;
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        return null;
    }
}

Látjuk, hogy az osztálynak a ParameterResolver interface-t kell megvalósítania. A két metódus melyet meg kell valósítanunk:

  • supportsParameter: validálja, hogy az adott paramétert fel tudja-e oldani ez a resolver
  • resolveParameter: a feloldott értéket adja vissza. Esetünkben egy Map-et amiben könyvek találhatóak (kulcs a könyv neve).

Nézzük az elkészített implementációt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class BookParameterResolver implements ParameterResolver {
    @Override
    public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        Parameter parameter = parameterContext.getParameter();
        return Objects.equals(parameter.getParameterizedType().getTypeName(), "java.util.Map<java.lang.String, hu.suaf.testing.Book>");
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        Map<String, Book> books = new HashMap<>();
        books.put("Effective Java", new Book("Effective Java", "Joshua Bloch", LocalDate.of(2008, Month.MAY, 8)));
        books.put("Clean Code", new Book("Clean Code", "Robert Martin",
                LocalDate.of(2008, Month.AUGUST, 1)));
        books.put("IT", new Book("IT", "Stephen King",
                LocalDate.of(1986, Month.SEPTEMBER, 15)));

        return books;
    }
}

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