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!
| <dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.8.1</version>
</dependency>
|
Készítsük el az első tesztünket, mely alapján, ha egy könyvespolchoz még nem adunk hozzá könyveket, akkor az még üres kell hogy legyen!
| 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!
| 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-dal 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 keletkezik.
A második esetben egy további szöveget adhatunk át paraméterként.
Ezt a szöveget fogja látni felhasználó, amennyiben a teszt elhasal.
A harmadik esetben egy Supplier callback-kel á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ályokra, mind az egyes tesztesetekre (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íthatjuk az olvashatóságot.
Feature request
Tudjunk könyvet hozzáadni a könyvespolchoz, melyet így később elolvashatunk!
Egy lehetséges teszteset a következő:
| @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.");
}
|
A új teszthez való hozzáigazí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!
| public void add(String... booksToAdd) {
books.addAll(Arrays.asList(booksToAdd));
}
|
Ezután a tesztet is alakíthatjuk ennek megfelelően:
| @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)?
Ilyen esetben azt várjuk el, hogy üres maradjon a könyvespolc, hiszen nem adtunk hozzá semmit.
| @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();
assertThrows(
UnsupportedOperationException.class,
() -> {
books.add("IT");
},
"Should throw UnsupportedOperationException."
);
|
Ahhoz, hogy ez a teszt átmenjen, készítsünk unmodifiable listát:
| 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!
| @Test
@DisplayName("should be arranged by book title")
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"
);
}
|
| 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
8
9
10
11
12 | @Test
@DisplayName("should reserve insertion order after calling arrange")
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!
| 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!
| @Data
public class Book {
private final String title;
private final String author;
private final LocalDate publishedOn;
}
|
Természetesen generálhatunk getter/setter metódusokat és konstruktort, de nyugodtan használhatjuk a már ismert @Data
annotációt a Lombok-ból az alábbi dependency hozzáadása után:
| <dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
</dependency>
|
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
71
72
73
74
75
76
77
78
79
80
81 | @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();
assertThrows(
UnsupportedOperationException.class,
() -> {
books.add(it);
},
"Should throw UnsupportedOperationException."
);
}
@Test
@DisplayName("should be arranged by book title")
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
@DisplayName("should reserve insertion order after calling arrange")
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!
| public class Book implements Comparable<Book> {
...
@Override
public int compareTo(Book o) {
return 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ő:
| @Test
@DisplayName("should be in descending order when using the appropriate comparator")
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 overload-ot, mely egy Comparator
-t vár paraméterben.
| 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:
| 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 JUnit-ba, 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.
Mindjárt világosabb lesz, hogy pontosan mire gondolunk, ehhez azonban húzzuk be az AssertJ library-t!
| <dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.18.0</version>
</dependency>
|
Majd módosítsuk a tesztet úgy, hogy abban az assertThat
metódust használjuk!
| @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:
| 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:
| 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ánsan 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 az init
metódust ennek megfelelően!
| @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ódus így 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 resolver-ről használnunk kell az @ExtendWith
annotációt!
| @ExtendWith(BooksParameterResolver.class)
public class BookShelfTest {
...
}
|
A BooksParameterResolver
osztály a következőképpen kell kinézzen:
| 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
interfészt kell megvalósítania.
Ebból adódik, hogy a következő metódusokat kell megvaló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 (a kulcs a könyv neve, az érték maga a könyv).
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
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42 | 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;
}
}
|
Természetesen a 7. sorban lévő típus nevet alakítsuk úgy, hogy stimmeljen a saját Book
osztályunkkal.
Videó
A gyakorlat anyagáról készült videó:
Utolsó frissítés: 2021-11-10 20:33:16