Kihagyás

Testing folytatás

Kiindulási alapnak készítsünk egy Utils osztályt, melynek segítségével CSV-ből importálhatunk könyveket! Ezt az osztályt fogjuk mock-olni és tesztelni.

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

    List<Book> importBooksFromCSV(Path path){
        List<Book> books = new ArrayList<>();

        try(BufferedReader br = Files.newBufferedReader(path)){
            String line;

            while((line = br.readLine()) != null){
                books.add(parseBook(line));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        return books;
    }

    public Book parseBook(String line) {
        String[] tokens = line.split(";");

        return new Book(
            tokens[0],
            tokens[1],
            LocalDate.of(
                Integer.parseInt(tokens[2]),
                Integer.parseInt(tokens[3]),
                Integer.parseInt(tokens[4])
            )
        );
    }
}

mock()

Ezután nézzük meg, hogy hogyan tudjuk mock-olni ezt az osztályt! Ehhez szükségünk lesz a Mockito-ra, melyhez a függőséget a pom.xml-ben így adhatjuk meg:

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

Lássuk először az egyszerű mock-ot:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class UtilsTest {

    @Test
    public void simpleMockUtils(){
        Utils utils = mock(Utils.class);

        List<Book> expected = List.of(
            new Book(
                "Effective Java",
                "Joshua Bloch",
                LocalDate.of(2008, 5, 8)
            )
        );

        when(utils.importBooksFromCSV(any(Path.class))).thenReturn(expected);

        System.out.println(utils.importBooksFromCSV(Path.of("resources", "books.csv")));
    }

}

Elsőként elkészítjük a Utils osztály egy mock objektumát a mock() metódus meghívásával. Ezután a when()-el specifikáljuk az elvárásokat, azaz egy stub-ot készítünk, vagyis azt mondjuk, hogy bármi történik is, ha meghívják az importBooksFromCSV metódust akármilyen Path típusú paraméterrel, akkor minden esetben a fent megadott egy könyvből álló listát adja vissza. Végül pedig teszteljük is a működését, azaz meghívjuk ezt a metódust egy tetszőleges Path-al, majd kiírjuk az eredményt a konzolra.

Az egyszerű kiíratás helyett használjunk assert-et!

1
assertThat(utils.importBooksFromCSV(Path.of("resources", "books.csv"))).isEqualTo(expected);

Most teszteljünk kivételkezelést!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Test
public void shouldThrowExceptionForParseBook(){
    Utils utils = mock(Utils.class);

    when(utils.parseBook(any(String.class))).thenThrow(RuntimeException.class);

    assertThrows(RuntimeException.class, () -> {
        utils.parseBook("");
    });
}

A mockolással megadjuk, hogy amikor a parseBook-ot meghívjuk, akkor dobjunk RuntimeException-t, majd ezt teszteljük is egy assertThrows-al.

Egy tipikus mockolási példa kialakításához hozzunk létre egy service réteget, mely belül egy BookShelf-re tárol referenciát!

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

    private BookShelf bookShelf;

    public BookshelfService(BookShelf bookShelf) {
        this.bookShelf = bookShelf;
    }

    public boolean containsBookWithTitle(String title){
        return bookShelf.findBookByTitle(title) != null;
    }
}

A BookShelf osztályban szükségünk lesz a következő metódusra:

1
2
3
4
5
6
public Book findBookByTitle(String title) {
    return books.stream()
        .filter(book -> book.getTitle().contains(title))
        .findFirst()
        .orElse(null);
}

Nézzük a tesztet!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class BookshelfServiceTest {

    @Test
    public void testFindingBookByTitle(){
        BookShelf bookShelf = mock(BookShelf.class);
        BookshelfService service = new BookshelfService(bookShelf);

        service.containsBookWithTitle("Something");

        verify(bookShelf).findBookByTitle(anyString());
    }

}

A fentiekkel azt teszteljük, hogy a findBookByTitle() metódus meg lett-e hívva (tetszőleges sztringgel). Ahhoz, hogy ez a teszt ne bukjon, ahhoz természetesen meg is kell hívnunk a findBookByTitle() metódust. Habár ez közvetlen nem történik meg, de a service.containsBookWithTitle("Something"); hívással közvetetten viszont igen, így a teszt át fog menni.

@Mock

Amikor egy teszt osztályon belül többször használjuk a mock()-ot ugyanarra az osztályra, akkor érdemes lehet a metódus helyett a @Mock annotációt használni.

1
2
@Mock
private BookShelf bookShelf;

Ezután futtatva a tesztet, az elszáll NullPointerException-nel. Ez azért történik, mert a mock objektumokat a használatuk előtt inicializálnunk kell! Az inicializálást kirakhatjuk pl. a @BeforeEach-be:

1
2
3
4
@BeforeEach
public void setup(){
    MockitoAnnotations.openMocks(this);
}

@InjectMocks

Automatikus dependency injection során igen hasznos lehet ez az annotáció. A fenti példában a következőt írtuk:

1
BookshelfService service = new BookshelfService(bookShelf);

Ez általában felesleges boilerplate kód. Az ismétlődések elkerülése érdekében alakítsuk át a teszt osztály ide vonatkozó részeit a következőképpen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class BookshelfServiceTest {

    @Mock
    private BookShelf bookShelf;

    @InjectMocks
    private BookshelfService service;

    @BeforeEach
    public void setup(){
        MockitoAnnotations.openMocks(this);
    }

    @Test
    public void testFindingBookByTitle(){
        service.containsBookWithTitle("Something");

        verify(bookShelf).findBookByTitle(anyString());
    }

}

Amennyiben több ugyanolyan típusú mock objektumunk is van, akkor a @Mock-nak adhatunk egy name attribútumot is, mely alapján az injection végbemegy.

Spring Test

Tekintsünk vissza a Contacts alkalmazásunk legutóbbi verziójára. Ebben találnunk kell egy ContactsApplicationTests teszt osztály, amely még akkor jött létre, amikor réges-régen (egy messzi-messzi galaxisban) létrehoztuk az alkalmazást.

1
2
3
4
5
6
7
8
@SpringBootTest
class ContactsApplicationTests {

    @Test
    void contextLoads() {
    }

}

Itt a @SpringBootTest annotációval megadtuk, hogy a teljes (teszt) Spring Boot kontextus töltödjön be. Emiatt a contextLoads teszt - ahogyan a nevéből ki is derül - most mindösszesen azt ellenőrzi, hogy ez a betöltés sikeresen végbemegy-e.

Unit Tests

Készítsünk egy tesztet a ContactService-hez. A teszt osztály váza a következőképpen fog kinézni:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package hu.suaf.contacts.service;

import hu.suaf.contacts.repository.ContactRepository;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

class ContactServiceTest {

    @InjectMocks
    private ContactService contactService;

    @Mock
    private ContactRepository contactRepository;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.initMocks(this);
    }

}

Ezzel egyelőre azt mondjuk meg, hogy

  • a ContactService bizonyos részeit injektálással mock-olni fogjuk,
  • a ContactRepository lesz az egyik dolog, amit mock-olunk és injektálunk contactRepository néven, valamint
  • a setUp metódusban inicializáljuk is mock-olt objektumokat.

Megjegyzés

A setUp-ban most az initMocks metódust használjuk. Ez azért van, mert a Spring Initializr régebbi függőségeket húz be, mint amiket fentebb a Bookshelf-nél használtunk. A régebbi Mockito verziókban az initMocks, az újabbakban az openMocks metódussal találkozhatunk.

Írjunk egy tesztet, ami a getContactById metódust teszteli!

1
2
3
4
5
6
@Test
public void shouldReturnContactById(){
    Contact contact = contactService.getContactById(1L);

    assertThat(contact.getId()).isEqualTo(1L);
}

Ahhoz, hogy ez működjön, a setUp-ot a következőképpen kell átalakítsuk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@BeforeEach
public void setup(){
    MockitoAnnotations.initMocks(this);

    Contact contact = new Contact();
    contact.setId(1L);
    contact.setAddress("6725 Szeged, Egyenes utca 2.");
    contact.setBirthDate(Date.valueOf("1984-04-10"));
    contact.setEmail("kiss@bela.com");
    contact.setName("Kiss Béla");
    contact.setPhone("+36 20 111 2222");

    when(contactRepository.findById(anyLong())).thenReturn(Optional.of(contact));
}

Azaz meg kell adjuk, hogy amennyiben a findById metódust bármilyen Long típusú paraméterrel meghívják, akkor az általunk kreált Contact objektum legyen a visszatérési érték.

Természetesen további funkciókkal bővíthetjük még a teszt osztályunkat. Azonban, vegyük észre, hogy ebben az esetben most egy unit tesztet készítettünk, mégpedig a ContactService-t teszteltük. A mock-olást is pont azért csináltuk, hogy ezt az osztályt a függőségeitől függetlenül egy különálló egységként tudjuk tesztelni.

REST API Tests

Készítsünk tesztet a ContactRestController-hez.

A controller-ben a contacts és a contactById metódusokat állítsuk vissza a legegyszerűbb működőképes verziójukra, ha esetleg az aktuális verzióban ki lennének kommentezve, vagy akár a HATEOAS átalakítások miatt nem működnének.

A teszt osztály váza az alábbi lesz:

1
2
3
4
5
6
7
8
@SpringBootTest
@AutoConfigureMockMvc
class ContactRestControllerTest {

    @Autowired
    private MockMvc mockMvc;

}

Itt a @SpringBootTest a context inicializálásához kell. A MockMvc-vel már találkoztunk korábban - ő a különböző műveletek végrehajtásához és az eredmények ellenőrzéséhez szükséges. Az @AutoConfigureMockMvc pedig azt biztosítja, hogy a mockMvc-t tudjuk @Autowired-ölni.

Valósítsuk meg a contacts metódus tesztelését!

Ezt az alábbi kódrészlettel fogjuk elérni:

1
2
3
4
5
6
7
8
9
@Test
public void shouldReturnAllContacts() throws Exception {
    MvcResult result = mockMvc.perform(get("/api/contact").accept(MediaType.APPLICATION_JSON_VALUE))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
            .andReturn();

    System.out.println(result.getResponse().getContentAsString());
}

Info

Az egzotikusabb statikus metódusokat ezekkel az import-okkal érhetjük el:

1
2
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

Security Test

A fenti példában a /api/contact végpont tesztelésekor egy kicsit csaltunk, ugyanis a WebSecurityConfig-ban a REST API-t egy az egyben kiengedtük a nagyvilába: .antMatchers("/api/**", "/api-v2/**").permitAll() Vagyis épp fordítva, a nagyvilágnak minden szűrés és megszorítás nélkül hozzáférést adtunk az API-hoz.

Mi a helyzet, akkor, ha olyan végpontokat szeretnénk tesztelni, amelyek eléréséhez authentikációra van szükség? Szimuláljuk ezt úgy, hogy az API-ra vonatkozó sort kikommentezzük a security config-ban. Próbáljuk ki, hogy mi történik ekkor!

A teszt el kell hasaljon. Az elvárt 200 OK helyett 302-es státusz kódot fog dobni és a login oldalra szeretne átirányítani minket.

A megoldáshoz szükségünk lesz először is a következő dependency-re:

1
2
3
4
5
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

Valamint a tesztet a következőképpen kell módosítsuk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Test
@WithMockUser(roles = "ADMIN", username = "test")
public void shouldReturnAllContacts() throws Exception {
    MvcResult result = mockMvc.perform(get("/api/contact").accept(MediaType.APPLICATION_JSON_VALUE))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
            .andReturn();

    System.out.println(result.getResponse().getContentAsString());
}

A megfelelő role-t és user-t a WebSecurityConfig és a UserDataSetup osztályokból tudjuk kinyerni.

Videó

A gyakorlat anyagáról készült videó:

SUAF_10_gyak


Utolsó frissítés: 2021-11-24 18:54:32
Back to top