testy integracyjne w spring boot

Testy integracyjne Spring

Obecnie tworzone oprogramowanie testuje się na wiele różnych sposóbów. Wszystkim jednak testom przyświeca jeden cel – stworzyć aplikację odporną na błędy. Oczywiście nie żyjemy w świecie idealnym, testy nie pomogą nam wykluczyć wszystkich błędów w oprogramowaniu, jednak mogą pomóc zmniejszyć ich liczbę.

Aplikacje są testowane na różne sposoby, aby móc wychwytywać błędy na różnych poziomach. Od najprostszych błędów np. w algorytmie sortowania, aż do błędów wynikających z integracji wszystkich komponentów aplikacji lub nawet kilku osobnych serwisów.

W tym wpisie przedstawię Ci rolę testów integracyjnych – do czego służą i co pozwalają dokładnie osiągnąć. Wszystko przedstawię na przykładzie kilku testów, abyś dowiedział się jak możesz tworzyć testy integracyjne w Spring Boot.

Czym są testy integracyjne?

Testy integracyjne są rodzajem testów, które są odpowiedzialne za przetestowanie integracji między komponentami, modułami, a nawet zewnętrznymi serwisami.

Powyższa definicja może wydawać się zbyt ogólna i może nie za bardzo rozjaśnia Ci pojęcie testów integracyjnych. Nic dziwnego, w praktyce jest to jednak dużo prostsze.

O ile nasza aplikacja składa się z pewnych jednostek (ang. unit) np. klasa, to bardzo łatwo każdą z klas można przetestować testami jednostkowymi. Takie testy tzw. jednostkowe, pozwalają na wykrycie błędów wynikających jedynie z tej jednej jednostki, którą właśnie testujemy.

Testy integracyjne są poziom wyżej w hierarchii, są odpowiedzialne za przetestowanie wszystkich mniejszych jednostek na raz. Jednostek, które się ze sobą komunikują i muszą współgrać.

Zejdźmy na ziemie i podajmy przykład z życia realnej aplikacji webowej.

Przypuśćmy, że tworzysz aplikację webową w Spring Boot, niech będzie to biblioteka online. Masz jeden endpoint, który umożliwia dodanie nowej książki do biblioteki. Wszystkie książki są przechowywane w bazie danych SQL, a przed zapisaniem do bazy danych dokonywana jest prosta walidacja przesłanej książki.

Mając tak prostą aplikację, automatycznie mamy kilka klocków:

  • Kontroler – z naszym endpointem do tworzenia
  • Walidator – sprawdza poprawność książki np. czy tytuł nie jest pusty
  • Repository – obiekt zapisujący książki do bazy danych
  • Baza danych – miejsce do przechowywania książek.

Przykładowym testem integracyjnym, który mógłbyś stworzyć, jest sprawdzenie czy podczas wykonania zapytania pod konkretny endpoint książka zostanie zapisana w bazie danych. Kolejnym testem może być sprawdzenie jaki HttpStatus zwróci nam endpoint, jeśli walidacja książki nie przejdzie.

Jak widzisz na powyższych przykładach, w testach integracyjnych uruchamiamy wszystkie nasze jednostki na prawdziwym środowisku i sprawdzamy czy ze sobą współgrają. Dzięki takim prostym testom, mamy pewność, że uruchomiona aplikacja zadziała i podstawowe nasze biznesowe use-case (np. tworzenie książki) będzie działać.

Przykładowa aplikacja webowa

Do tego artykułu przygotowałem prostą aplikację w Spring Boot.

W skrócie, aplikacja umożliwia dodawanie książek, podając przy tym jej tytuł i autora oraz wylistować wszystkie istniejące książki. Dodatkowo podczas tworzenia książki są sprawdzane pola: tytuł oraz autor. Jeśli są one puste to jest zwracany błąd.

W dalszczej części artykułu, pokażę przykładowe testy integracyjne na podstawie logiki właśnie tej aplikacji. Na szczęście do zrozumienia tych testów, nie będziesz musiał znać dokładnej implementacji. Jeśli jednak chcesz ją poznać to zerknij na repozytorium, gdzie umieściłem cały kod.

Jedyne co będzie Ci potrzebne to znajomość warstwy API aplikacji. W tym przypadku jest to kontroler, a dokładniej dwa endpointy, przy użyciu których można korzystać z funkcjonalności aplikacji.

@RestController
@RequestMapping("/book")
@RequiredArgsConstructor
class BookController {
    private final BookService bookService;

    @PostMapping
    public ResponseEntity<?> addBook(@RequestBody CreateBookModel createBookModel) {
        try {
            Book book = bookService.addBook(createBookModel);
            return ResponseEntity.ok(book);
        } catch (ValidationException e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    @GetMapping
    public ResponseEntity<List<Book>> getAllBooks() {
        return ResponseEntity.ok(bookService.getBooks());
    }
}

W dalszej części zajmiemy się przetestowaniem dwóch powyższych endpointów. Logika ich jest bardzo prosta, jednak pozwoli na pokazanie Ci możliwości płynących z testów integracyjnych.

Konfiguracja środowiska

Konfiguracja środowiska testowego w Spring Boocie jest banalnie prosta. W większości przypadków, wystarczy dodać jedną zależność oraz adnotację i voilà testy działają.

spring-boot-starter-test

Spring-boot-starter-test jest zależnością, która zawiera wszystko, co potrzebujesz podczas testowania. Zawiera ona w sobie m.in. takie biblioteki jak:

  • Spring Boot Test,
  • Spring Test,
  • JUnit,
  • Mockito,
  • AssertJ,
  • Hamcrest.

Dzięki pierwszym dwóm zależnościom, w naszych testach jest uruchamiany cały kontekst Springa, mamy możliwość wstrzykiwać zależności oraz mamy dostęp do transakcji. W tym artykule właśnie na nich się skupimy. Jesli jesteś zainteresowany samym testowaniem to zachęcam do przeczytania wstępu do testów jednostkowych.

Dodaj poniższy wpis do pliku pom.xml.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Baza danych

Jak już wiesz z początku artykułu, testy integracyjne różnią się tym od jednostkowych, że testujemy w nich wszystkie moduły jednocześnie. W takim razie, aby miały one sens, potrzebna jest jeszcze nam baza danych.

Baza danych może być tak naprawdę jakakolwiek – wszystko zależy od Ciebie. Jednak najbardziej polecam bazę H2 i HSQLDB, ponieważ są to bazy, które można uruchomić w pamięci. Dzięki temu, mamy pewność, że gdziekolwiek testy nie zostaną uruchomione, testy będą miały dostęp do prawdziwej bazy danych.

Ja w swoich testach użyję bazy HSQLDB.

<dependency>
    <groupId>org.hsqldb</groupId>
    <artifactId>hsqldb</artifactId>
    <scope>test</scope>
</dependency>

Zauważ, że zależność ta będzie tylko ładowana w środowisku testowym (<scope>test</scope>). 

Przygotowanie do testowania

Do uruchomienia i napisania testów mamy już wszystko gotowe. Możemy przejść do stworzenia klasy, gdzie będziemy umieszczać testy dla kontrolera BookController.

Nazewnictwo

Podczas pisania testów warto trzymać się ustalonej na początku konwencji nazywania klas testowych. W swoim przypadku używam suffksu IT, jednak równie dobrze możesz użyć np. IntegrationTest.

public class BookControllerIT {
}

@SpringBootTest

Adnotacja @SpringBootTest jest dobrodziejstwem, które zesłał nam Spring. Jest to adnotacja, która jest odpowiedzialna za uruchomienie naszej całej aplikacji (tj. z całym kontekstem czyli wszystkimi beanami). Aplikacja domyślnie jest uruchamiana na zmockowanym web serwerze.

Dzięki temu, że uruchamiany jest cały Spring Context, mamy możliwość wstrzykiwać zależności w testach. Wystarczy użyć adnotacji @Autowired i możemy działać na wstrzykniętym obiekcie.

Wcześniej wspomniałem, że uruchamiany serwer jest jedynie mockiem. Da się to łatwo zmienić – wystarczy wykorzystać opcję adnotacji: webEnvironment.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BookControllerIT {
}

Użycie opcji RANDOM_PORT zapewni, że aplikacja zostanie uruchomiona na prawdziwym serwerze webowym.

@LocalServerPort

Ze względu, że Spring wybiera losowy, wolny port, to niestety port nie jest znany przed uruchomieniem testu. Aby go poznać można wykorzystać adnotację @LocalServerPort.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BookControllerIT {
    @LocalServerPort
    private int serverPort;
}

@Sql

Testy integracyjne opierają się o przetestowanie aplikacji od góry do dołu. Bez pomijania żadnego z elementów (no może za wyjątkiem serwisów zewnętrznych np. Twitter API). Na pewno bez pomijania bazy danych.

Skoro w testach mamy wziąć pod uwagę bazę danych, to z pewnością warto byłoby na sam początek wrzucić tam jakieś, poprawne dane.

W tym celu można wykorzystać adnotację @Sql, która umożliwia nam uruchomienie dowolnego skryptu SQL na bazie danych. Skrypt może zostać wykonany w dwóch fazach: przed lub po wykonaniu testu. Obie fazy nam się przydadzą do wypełniania bazy danych danymi oraz czyszczenia jej po skończonym teście. Tak, aby kolejny test miał do dyspozycji “świeżą” bazę danych.

Na początek, więc stworzyłem skrypt insert_data.sql, który znajduje się w /src/test/resources/.

insert into authors(id, name) values (1, 'Pablo');

insert into books(id, title, author_id) values
(1, 'Great book 1', 1),
(2, 'Great book 2', 1);

W tej samej lokalizacji utworzyłem skrypt clean_database.sql.

TRUNCATE SCHEMA PUBLIC AND COMMIT;

Uwaga! Powyższy skrypt może nie działać na każdej bazie danych.

Mając tak przygotowane skrypty, możemy je dodać do naszej klasy testowej.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Sql(value = "/insert_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/clean_database.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public class BookControllerIT {
    @LocalServerPort
    private int serverPort;
}

Jak pewnie zauważyłeś, skrypt inicjalizujący jest uruchamiany przed testem, zaś skrypt czyszczący zaraz po skończonym teście.

TestRestTemplate

Chcąc przetestować aplikację webową, potrzebujemy do tego jeszcze klienta, który będzie wykonywał zapytania HTTP.

Spring w tym celu przygotował obiekt TestRestTemplate. Jest on wrapperem dla obiektu RestTemplate, ogranicza lekko możliwości RestTemplate, ale również dodaje mechanizmy przydatne w testowaniu np. w przypadku błędu zwraca odpowiedź w postaci JSON, zamiast rzucać exception.

Aby wykorzystać obiekt w swoich testach wystarczy go wstrzyknąć.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Sql(value = "/insert_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/clean_database.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public class BookControllerIT {
    @LocalServerPort
    private int serverPort;

    @Autowired
    private TestRestTemplate restTemplate;
}

Server address

Do testów będzie potrzebna nam jeszcze znajomość dokładnej lokalizacji uruchomionego przez testy serwera.

Oczywiście wszystko jest uruchamiane lokalnie, a port już mamy umieszczony w polu serverPort.

private URI createServerAddress() throws URISyntaxException {
    return new URI("http://localhost:" + serverPort + "/book");
}

Tak stworzoną prywatną metodę wykorzystamy podczas użycia TestRestTemplate w testach.

Testowanie

Wszystko zostało już przygotowane i nareszczie można przystąpić do napisania testów. Testy integracyjne nie różnią się składnią od testów jednostkowych, nadal możemy dzielić je w bloki given-when-then, aby zachować czytelność testów.

shouldReturnAllExistingBooks

Na poczatek zaczniemy od najprostszego przypadku – sprawdzenia działania endpointu, który jest odpowiedzialny za zwracanie listy książek.

Zgodnie z kodem kontrolera, który umieściłem na początku, endpoint to GET /book.

@Test
void shouldReturnAllExistingBooks() throws Exception {
    // when:
    RequestEntity<Void> request = RequestEntity
            .get(createServerAddress())
            .build();

    ResponseEntity<List<Book>> response = restTemplate.exchange(request, new ParameterizedTypeReference<List<Book>>(){});

    // then:
    assertTrue(response.getStatusCode().is2xxSuccessful());
    assertEquals(2, response.getBody().size());
}

Na sam początek tworzymy RequestEntity, czyli wskazujemy metodę HTTP oraz adres, pod który zapytanie ma dotrzeć.

W dalszej części, przy użyciu TestRestTemplate oraz metody exchange wykonujemy zapytanie.

new ParameterizedTypeReference<List<Book>>(){} służy w celu zmapowania odpowiedzi zapytania na listę książek.

Na koniec sprawdzamy czy status HTTP jest poprawny (2xx) oraz czy zostały zwrócone dwie książki (zgodnie ze skryptem wypełniającym bazę danych).

shouldReturn2xxWhenAddBookSuccessfully

W tym teście sprawdzimy, czy podczas dodawania książki, jest zwracana odpowiednia odpowiedź oraz status.

@Test
void shouldReturn2xxWhenAddBookSuccessfully() throws Exception {
    // given:
    CreateBookModel createBookModel = new CreateBookModel("Great book", "Great author");

    // when:
    RequestEntity<CreateBookModel> request = RequestEntity
            .post(createServerAddress())
            .contentType(MediaType.APPLICATION_JSON)
            .body(createBookModel);

    ResponseEntity<Book> response = restTemplate.exchange(request, Book.class);

    // then:
    Book body = response.getBody();

    assertTrue(response.getStatusCode().is2xxSuccessful());
    assertEquals("Great author", body.getAuthorName());
    assertEquals("Great book", body.getTitle());
}

Test wygląda analogicznie do poprzedniego. W tym przypadku jednak tworzymy obiekt typu CreateBookModel, który zostanie wykorzystany jako payload zapytania HTTP. Zmienia się jeszcze metoda HTTP – z GET na POST oraz dodajemy header Content-Type: application/json.

Na koniec sprawdzamy status oraz zawartość odpowiedzi.

shouldReturn4xxWhenBookTitleIsEmpty

@Test
void shouldReturn4xxWhenBookTitleIsEmpty() throws Exception {
    // given:
    CreateBookModel createBookModel = new CreateBookModel("", "Great author");

    // when:
    RequestEntity<CreateBookModel> request = RequestEntity
            .post(createServerAddress())
            .contentType(MediaType.APPLICATION_JSON)
            .body(createBookModel);

    ResponseEntity<String> response = restTemplate.exchange(request, String.class);

    // then:
    assertTrue(response.getStatusCode().is4xxClientError());
    assertEquals("Book has to have a title.", response.getBody());
}

Kolejny test odpowiada za sprawdzenie, czy walidacja działa poprawnie podczas tworzenia książki.

W tym przypadku, zgodnie z implementacją endpointu, jest zwracany String podczas błędu walidacji, więc odpowiedź mapujemy na String (a nie na obiekt typu Book).

Mocki – czy są konieczne?

Jak pewnie zauważyłeś, w powyższych testach nie użyłem ani razu biblioteki Mockito. Podczas całego procesu pisania testów, ani razu w swoich rękach nie miałem mocka.

Jest to spora rzecz, jaka dzieli testy integracyjne oraz jednostkowe. W testach jednostkowych, chcemy przetestować jednostkę, całkowicie odizolowaną od reszty systemu – w tym celu właśnie wykorzystujemy mocki. Zapychamy nasz testowany obiekt wydmuszkami i sterujemy nimi zależnie od przypadku, który sprawdzamy.

Całkowicie inaczej jest w testach integracyjnych. Sprawdzamy, czy cały system potrafi ze sobą współpracować i się komunikować. Weryfikujemy, czy każdy biznesowy przypadek jest zaimplementowany i działa poprawnie np. czy walidacja działa i zwraca poprawne wiadomości.

W czasie testów integracyjnych nie pomijamy niczego – oprócz systemów zewnętrznych. Chcemy, aby wszystko co tylko się dało, było “prawdziwe”, a nie zmockowane.

Jednak czasami zdarza się tak, że nasza aplikacja integruje się z systemami zewnętrznymi. Zazwyczaj nie możemy ich uruchomić lokalnie, więc testowanie mogłoby powodować wysokie koszta np. z powodu zbyt dużej ilości zapytań. Mogłoby się zdarzyć, że podczas uruchamiania testów, system zewnętrzny zwyczajnie by nie działał, a testy zapaliłyby się na czerwono.

Są to przypadki, z którymi raczej nie chcemy się mierzyć i wolimy podejście, że w naszych testach system zewnętrzny zawsze działa poprawnie. W tym celu wykorzystuje się bibliotekę Mockito i mockuje się serwisy, które są odpowiedzialne za komunikację z zewnętrznym serwisem.

Tym prostym sposobem, testy integracyjne sprawdzają całkowicie działanie naszej aplikacji na prawdziwym serwerze (lub zmockowanym), a nie zależą od systemów trzecich.

Czy warto pisać testy integracyjne?

Ostatnią część artykułu chcę poświęcić na rozważania na temat: “Czy warto w ogóle pisać testy integracyjne?”.

Moim subiektywnym zdaniem, naprawdę warto. W swojej komercyjnej pracy, cenie testy integracyjne, nawet bardziej niż testy jednostkowe. Zapewniają mnie one, że przygotowane rozwiązanie działa w 100% w prawdziwym środowisku – oczywiście jeśli pokryłem wszystkie corner casy.

Dodatkowo, podczas rozwijania aplikacji, istniejący kod jest często modyfikowany, może powodować to konieczność edycji również testów jednostkowych.

W przypadku testów integracyjnych, rzadziej musimy je poprawić. Dlaczego? Testy integracyjne sprawdzają nam działanie całej aplikacji, czyli również przypadków biznesowych. Jeśli zmienia się jedynie np. implementacja rozwiązania, to testy integracyjne powinny nadal świecić się na zielono – o ile nic nie popsuliśmy. 😉

A ty jak podchodzisz do testowania w swoich projektach lub w swojej pracy? Które testy piszesz częściej? A które może w ogóle pomijasz podczas tworzenia oprogramowania? Zapraszam do dyskusji w komentarzach.

 

Kamil Klimek

Od 2016 jestem programistą Java. Przez pierwsze 4 lata pracowałem jako Full Stack Java Developer. Później postanowiłem postawić nacisk na Javę, żeby jeszcze lepiej ją poznać.

Subscribe
Powiadom o
guest
3 komentarzy
najstarszy
najnowszy oceniany
Inline Feedbacks
View all comments
Tomasz
Tomasz
3 lat temu

Świetny artykuł z dożą ilością przykładów w kodzie na pewno się przyda . Dziękuje

Tomasz
Tomasz
3 lat temu

Ja na razie jestem na początku swojej drogi z Java. Ciekawa wiedzą zawsze się kiedyś przydaje.

3
0
Would love your thoughts, please comment.x