Tests d'intégration avec Springboot, Docker et Testcontainers

En début de semaine dernière, j'ai eu une réunion avec mes collègues sur JUnit 5 et Testcontainers. J'aurais dû écrire un article de blog à ce sujet depuis fort longtemps, mais pour diverses raisons, cela n'a pas été fait. Le moment est enfin venu et il s'agit de parler des Testcontainers. Dans cet article je parlerai de théorie mais aussi de la pratique et nous verrons ensemble comment mettre en place des tests d'intégrations avec JUnit 5 et les Testcontainers.

Prérequis

  • Être familier avec Java 8
  • Avoir les fondamentaux sur Maven
  • Avoir les fondamentaux sur Docker

Testcontainers ?

Testcontainers a été lancé en tant que projet open-source pour la plateforme Java (testcontainers-java) mais a progressivement révélé un support pour plus de langages tels que Go, Rust, dotnet (testcontainers-dotnet), node (testcontainers-node) et d'autres langages. Ce sont des projets de qualité variable mais beaucoup d'entre eux peuvent être utilisés sans problème dans vos applications.

Si nous nous référons à la documentation officielle de Testscontainers, nous pouvons également le définir comme suit :

Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

Quel problème résolvent les Testcontainers ?

Si nous examinons les différentes stratégies de test, généralement expliquées sous la forme d'une pyramide de test, nous avons tout en bas les tests unitaires (un test unitaire est une procédure de test d'une unité représentant le plus petit morceau de code qui peut être logiquement isolé dans un système). Il se concentre sur un seul composant et remplace toutes les dépendances par des mocks ou des doubles de test.

Maintenant, si nous regardons au-dessus de la couche de tests unitaires dans la pyramide de test, nous trouverons la couche de test d'intégration, ce qui signifie que nous aurons plus d'un composant à tester, et parfois nous avons besoin d'intégrer avec une ressource externe ou encore un backing service qui peut être une base de données, un message broker, un service SMTP ou encore un système de cache.

Ainsi, nous pouvons dire qu'un backing service est tout service que notre application consomme en dehors de notre système. C'est maintenant que les Testcontainers rentrent en action. Avant de passer à la pratique, voyons quel problème nous cherchons à résoudre.

"The Problem"

Plusieurs fois, lorsque je travaillais sur des projets, nous (mes collègues et moi) avons essayé de résoudre le problème de l'interaction avec les ressources externes de différentes manières, en fonction du type de backing service.

Nous avons eu plusieurs idées, comme par exemple, utiliser une machine virtuelle, un processus local ou un service intégré dans le système.

Chaque stratégie a ses avantages et ses inconvénients. Aujourd'hui, la base de donnée embarquée est probablement la méthode la plus utilisée. Elle résout de nombreux problèmes, mais malheureusement, elle en entraîne d'autres. Par exemple, la base de données H2 In-memory émule la ressource cible mais pas le comportement réel d'une base de données de production.

Testcontainers à la rescousse

La solution à ce problème qui marche plutôt bien est les Testcontainers.

Avec les Testcontainers, nous pouvons démarrer et arrêter un ou plusieurs conteneurs Docker avec la même configuration et le même comportement que dans notre environnement de production. Plus loin dans cet article, je vous montrerai, comment vous pouvez le configurer et l'utiliser pour un test d'intégration d'une application springboot.

Avantages

  • Des livraisons et des tests de logiciels plus prévisibles - Cela signifie que vous pouvez utiliser le même environnement (un conteneur) pour héberger votre logiciel, que vous construisiez, testiez ou déployiez un logiciel en production. De plus, la conteneurisation utilise moins de ressources que les machines virtuelles, ce qui en fait une solution de choix.
  • Ce que vous testez est ce que vous obtenez - Cela signifie que la conteneurisation fournit un environnement d'application cohérent pour les tests de logiciels et pour le déploiement. Vous pouvez être sûr que les tests refléteront exactement la façon dont l'application s'éxecutera en production (à quelques exceptions près) car les environnements de test et de production sont les mêmes.
  • Des branches de test plus simples - Les testeurs de logiciels testent souvent plusieurs versions d'une application. Ils peuvent avoir à tester des versions Windows et Linux par exemple. Les versions doivent descendre de la même base de code mais sont testées et déployées séparément.

Inconvénients

  • Les conteneurs ne sont pas agnostiques sur le plan matériel - L'utilisation de conteneurs pour effectuer des tests de logiciels présente quelques inconvénients. Par exemple, une application conteneurisée peut se comporter de manière différente selon le GPU que contient son serveur hôte et selon que l'application interagit ou non avec le GPU. Je me suis alors demandé si le rôle des équipes d'assurance qualité était de tester les applications conteneurisées sur tous les différents profils matériels utilisés en production.
  • Tester les microservices - Personnellement, je n'ai pas travaillé avec des microservices conteneurisés, mais certains développeurs m'ont dit qu'il est très difficile de tester les microservices avec des conteneurs de test parce qu'il faut configurer des tests automatisés pour couvrir plusieurs microservices.

Testcontainers avec Springboot

Pour cet article, nous allons utiliser une simple application springboot, qui exposera 2 urls, GET et POST, pour récupérer et sauvegarder des données dans notre base de données. Et nous appellerons ces urls avec des tests d'intégration réalisés par Testcontainers.

Mise en place de l'application

Commençons par notre entité JPA :

@Entity
@Getter
@Setter
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "id", nullable = false)
    private Long id;

    @Column(name = "author")
    private String author;

    @Column(name = "title")
    private String title;

    @Column(name= "publication_year")
    private int year;
}

Notre Repository :

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {}

Notre Service :

@Service
@RequiredArgsConstructor
public class BookService {

    public final BookRepository repository;

    public Book saveBook(Book book) {
        return this.repository.save(book);
    }

    public List<Book> getBooks() {
        return this.repository.findAll();
    }
}

Enfin le Controller :

@RestController
@RequiredArgsConstructor
public class BookController {

    private final BookService service;

    @ResponseStatus(HttpStatus.CREATED)
    @PostMapping("/create/book")
    public Book createTodo(@Valid @RequestBody Book book) {
        return this.service.saveBook(book);
    }

    @ResponseStatus(HttpStatus.OK)
    @GetMapping("/fetch/books")
    public List<Book> createTodo() {
        return this.service.getBooks();
    }
}

Mise en place d'une base de donnée réelle

Une fois que nous avons défini les éléments de base de notre application, nous allons maintenant définir un fichier docker-compose pour définir notre base de données :

version: '3.8'
services:
  postgres_db:
    container_name: 'postgresdb'
    image: postgres:14.2-alpine
    restart: always
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=book_db
    ports:
      - '5432:5432'

Maintenant, nous allons configurer notre fichier application.yml, pour initialiser notre conteneur de base de données postgres_db :

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/book_db
    username: postgres
    password: postgres

  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQL82Dialect

Une fois que nous avons tout en place, nous pouvons démarrer notre application pour vérifier que notre configuration est correcte.

Commençons par lancer notre service de conteneur de base de données. Dans le répertoire du fichier docker-compose exécutez cette commande :

docker-compose up -d

Une fois le conteneur postgres démarré, lancez l'application springboot, avec cette commande :

mvn spring-boot run

Pour tester que notre application fonctionne et enregistre les entités du livre dans la base de données du conteneur postgres, nous pouvons exécuter une commande curl pour effectuer une requête POST vers l'url exposée par notre contrôleur :

curl -d '{"author":"John Doe", "title":"Comic Code", "year":2008}' -H "Content-Type: application/json" -X POST http://localhost:8080/create/book

Bien, notre application insère des données dans la base de données du conteneur :

Mise en place des Testcontainers

Une fois que notre application a démarré, nous pouvons l'arrêter, nous n'avons pas vraiment besoin que l'application soit lancée pour faire des tests d'intégration.

Ainsi, la façon la plus pratique d'écrire des tests d'intégration avec Testcontainers est de créer une classe abstraite, dont le but est de mettre en place un conteneur de base de données pour toutes nos méthodes de tests, en utilisant le patron de conception singleton :

public abstract class AbstractIntegrationTest {

    private static final PostgreSQLContainer POSTGRES_SQL_CONTAINER;

    static {
        POSTGRES_SQL_CONTAINER = new PostgreSQLContainer<>(DockerImageName.parse("postgres:14.2-alpine"));
        POSTGRES_SQL_CONTAINER.start();
    }

    @DynamicPropertySource
    static void overrideTestProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", POSTGRES_SQL_CONTAINER::getJdbcUrl);
        registry.add("spring.datasource.username", POSTGRES_SQL_CONTAINER::getUsername);
        registry.add("spring.datasource.password", POSTGRES_SQL_CONTAINER::getPassword);
    }
}

Le DynamicPropertyRegistry permet de surcharger les valeurs des clés dans notre fichier de configuration des tests application-test.properties dans le dossier ressource :

# POSTGRESQL Connection Properties for Testcontainers overrided by DynamicPropertyRegistry
spring.datasource.url=
spring.datasource.username=
spring.datasource.password=

Notre classe de test d'intégration :

@Testcontainers
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
public class BookPostgresSQLTest extends AbstractIntegrationTest {

    @Autowired
    private BookRepository bookRepository;

    @Autowired
    private MockMvc mockMvc;

    @BeforeEach
    void setUp() {
        bookRepository.deleteAll();
    }

    @Test
    @Order(1)
    void should_be_able_to_save_one_book() throws Exception {
        // Given
        final var book = new Book();
        book.setAuthor("Alain de Botton");
        book.setTitle("The school of life");
        book.setYear(2012);

        // When & Then
        mockMvc.perform(post("/create/book")
                        .content(new ObjectMapper().writeValueAsString(book))
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.author").value("Alain de Botton"))
                .andExpect(jsonPath("$.title").value("The school of life"))
                .andExpect(jsonPath("$.year").value("2012"));
    }

    @Test
    @Order(2)
    void should_be_able_to_retrieve_all_book() throws Exception {
        // Given
        bookRepository.saveAll(List.of(new Book(), new Book(), new Book()));

        // When
        mockMvc.perform(get("/fetch/books")
                        .accept(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").isArray());

        // Then
        assertThat(bookRepository.findAll()).hasSize(3);
    }

}

Tout d'abord, nous nettoyons notre conteneur de test avant chaque méthode de test, puis nous testons que nous pouvons sauvegarder un livre via la méthode REST de notre contrôleur. Enfin, nous testons que nous pouvons récupérer tous les livres de notre base de données.

Lancement des tests d'intégrations

Pour réaliser notre test d'intégration, nous pouvons utiliser la commande suivante :

mvn -Dtest=BookPostgresSQLTest test

Hourra ! nous avons donc nos 2 tests d'intégration qui sont configurés et opérationnels !

Vue d'ensemble

Dans cet article, nous avons vu :

  • Le concept derrière les Testcontainers
  • Comment créer une application springboot avec une base de données conteneurisée.
  • Quels sont les avantages et les inconvénients des conteneurs de tests ?
  • Comment écrire des tests d'intégration avec Testcontainers

Kévin

Kévin aime le Java, mais il aime encore plus partager ses connaissances. Si vous lui demandez pourquoi, il vous parlera sûrement d'Austin Kleon et de 3 livres qui l'ont marqué pour toujours : "Show Your work," "Keep Going" et "Steal Like An Artist". Depuis ces lectures, il apprend, encore et toujours. Une tâche sans fin ? Même pas peur !

Retours aux publications