Table des matières

3 - Tests et CI/CD

Objectifs pédagogiques

Partie 0 : Point de départ (15min)

Point avancement TD2

  • Qui a terminé les associations Order/OrderItem/User ?
  • Qui a résolu des problèmes N+1 ?
  • Ceux qui ont fini peuvent commencer les tests, les autres finalisent le TD2

Partie 1 : Configuration multi-environnements (30min)

1.1 Stratégie de profils Spring

Objectif : Séparer les configurations selon l'environnement (dev, test, prod)

Profiles

La création de profiles permet de gérer des configurations différentes, et des fichiers de configuration spécifiques à chaque profile.

pom.xml - Configuration des profils Maven

<profiles>
    <profile>
        <id>dev</id>
        <activation>
            <activeByDefault>true</activeByDefault>
        </activation>
        <properties>
            <activeProfile>dev</activeProfile>
        </properties>
    </profile>
    
    <profile>
        <id>test</id>
        <properties>
            <activeProfile>test</activeProfile>
        </properties>
    </profile>
    
    <profile>
        <id>prod</id>
        <properties>
            <activeProfile>prod</activeProfile>
        </properties>
    </profile>
</profiles>

application.properties (commun)

# Récupération du profile Maven pour def du profile Spring
spring.profiles.active=@activeProfile@

# JPA commun
spring.jpa.open-in-view=false
spring.jpa.properties.hibernate.jdbc.time_zone=UTC

# Validation
spring.jackson.deserialization.fail-on-unknown-properties=true

application-dev.properties

# Base H2 fichier pour le dev
spring.datasource.url=jdbc:h2:file:./data/ecommerce-dev
spring.datasource.username=sa
spring.datasource.password=

# Console H2 activée
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# DDL auto pour en dev
spring.jpa.hibernate.ddl-auto=update

# Logs verbeux
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.com.ecommerce=DEBUG
logging.level.io.hypersistence.utils=DEBUG

application-test.properties

# Base H2 en mémoire pour les tests
spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=

# Recréation du schéma à chaque test
spring.jpa.hibernate.ddl-auto=create-drop

# Logs minimaux (sauf erreurs)
spring.jpa.show-sql=false
logging.level.com.ecommerce=INFO
logging.level.org.hibernate=WARN

# Performance tests
spring.jpa.properties.hibernate.generate_statistics=true

# Désactivation fonctionnalités non nécessaires en test
spring.h2.console.enabled=false

application-prod.properties

# Base PostgreSQL (exemple)
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}

# JAMAIS de DDL auto en production
spring.jpa.hibernate.ddl-auto=validate

# Logs minimaux
spring.jpa.show-sql=false
logging.level.com.ecommerce=INFO

# Sécurité
spring.h2.console.enabled=false

1.2 Activation des profils

# Dans IntelliJ : Run Configuration > Active profiles: dev
# Ou via variable d'environnement
export SPRING_PROFILES_ACTIVE=dev

# Via ligne de commande
mvn spring-boot:run -Dspring-boot.run.profiles=dev

# Avec profil maven
mvn spring-boot:run -P dev

Exercice 1 (15min) :

  1. Créer les 4 fichiers de configuration
  2. Tester le lancement avec le profil dev
  3. Vérifier que la console H2 est accessible sur /h2-console
  4. Relancer avec le profil test et constater les différences de logs

Partie 2 : Tests Unitaires (1h15)

Principe clé : Un test unitaire teste UNE classe isolée, sans base de données, très rapidement

2.1 Dépendances nécessaires (pom.xml)

<dependencies>
    <!-- Spring Boot Test (inclut JUnit 5, Mockito, AssertJ) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    
    <!-- MockK pour Kotlin (optionnel, alternative à Mockito) -->
    <dependency>
        <groupId>com.ninja-squad</groupId>
        <artifactId>springmockk</artifactId>
        <version>4.0.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

2.2 Premier test simple : ProductService

package com.ecommerce.service

import com.ecommerce.domain.Product
import com.ecommerce.domain.Category
import com.ecommerce.repository.ProductRepository
import com.ecommerce.exception.ProductNotFoundException
import com.ecommerce.exception.InsufficientStockException
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.mockk.slot
import org.assertj.core.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.BeforeEach
import java.math.BigDecimal
import java.util.*

@DisplayName("ProductService - Unit Tests")
class ProductServiceTest {

    private lateinit var productRepository: ProductRepository
    private lateinit var productService: ProductService
    private lateinit var category: Category

    @BeforeEach
    fun setUp() {
        productRepository = mockk()
        productService = ProductService(productRepository)
        category = Category(name = "Electronics", description = "Electronic products")
        category.id = UUID.randomUUID()
    }

    @Test
    @DisplayName("Should create product with valid data")
    fun `createProduct with valid data should return product`() {
        // Given
        val productSlot = slot<Product>()
        val savedProduct = Product(
            name = "iPhone 15",
            price = BigDecimal("999.99"),
            stock = 10,
            category = category
        ).apply { id = UUID.randomUUID() }

        every { productRepository.save(capture(productSlot)) } returns savedProduct

        // When
        val result = productService.createProduct(
            name = "iPhone 15",
            price = BigDecimal("999.99"),
            stock = 10,
            categoryId = category.id!!
        )

        // Then
        assertThat(result).isNotNull
        assertThat(result.name).isEqualTo("iPhone 15")
        assertThat(result.price).isEqualByComparingTo("999.99")
        assertThat(result.stock).isEqualTo(10)
        verify(exactly = 1) { productRepository.save(any()) }
    }

    @Test
    @DisplayName("Should throw exception when product not found")
    fun `getProduct when not exists should throw exception`() {
        // Given
        val productId = UUID.randomUUID()
        every { productRepository.findById(productId) } returns Optional.empty()

        // When & Then
        assertThatThrownBy { productService.getProduct(productId) }
            .isInstanceOf(ProductNotFoundException::class.java)
            .hasMessageContaining(productId.toString())
    }

    @Test
    @DisplayName("Should decrease stock when sufficient")
    fun `decreaseStock with sufficient stock should update product`() {
        // Given
        val productId = UUID.randomUUID()
        val product = Product(
            name = "Test Product",
            price = BigDecimal("10.00"),
            stock = 10,
            category = category
        ).apply { id = productId }

        every { productRepository.findById(productId) } returns Optional.of(product)
        every { productRepository.save(any()) } returns product

        // When
        productService.decreaseStock(productId, 3)

        // Then
        assertThat(product.stock).isEqualTo(7)
        verify(exactly = 1) { productRepository.save(product) }
    }

    @Test
    @DisplayName("Should throw exception when insufficient stock")
    fun `decreaseStock with insufficient stock should throw exception`() {
        // Given
        val productId = UUID.randomUUID()
        val product = Product(
            name = "Test Product",
            price = BigDecimal("10.00"),
            stock = 2,
            category = category
        ).apply { id = productId }

        every { productRepository.findById(productId) } returns Optional.of(product)

        // When & Then
        assertThatThrownBy { productService.decreaseStock(productId, 5) }
            .isInstanceOf(InsufficientStockException::class.java)
            .hasMessageContaining("Insufficient stock")
    }

    @Test
    @DisplayName("Should update stock correctly")
    fun `updateStock should change product stock and save`() {
        // Given
        val productId = UUID.randomUUID()
        val product = Product(
            name = "Test Product",
            price = BigDecimal("10.00"),
            stock = 10,
            category = category
        ).apply { id = productId }

        every { productRepository.findById(productId) } returns Optional.of(product)
        every { productRepository.save(any()) } returns product

        // When
        productService.updateStock(productId, 5)

        // Then
        assertThat(product.stock).isEqualTo(15)
        verify(exactly = 1) { productRepository.save(product) }
    }
}

2.3 Concepts clés

// MockK pour Kotlin (alternative à Mockito)
import io.mockk.*

// Créer un mock
val repository = mockk<ProductRepository>()

// Définir le comportement du mock
every { repository.findById(id) } returns Optional.of(product)
every { repository.save(any()) } returns product

// Capturer un argument
val productSlot = slot<Product>()
every { repository.save(capture(productSlot)) } returns product

// Vérifier qu'une méthode a été appelée
verify(exactly = 1) { repository.save(any()) }
verify(atLeast = 1) { repository.findById(any()) }
verify(exactly = 0) { repository.delete(any()) }

// AssertJ - Assertions plus lisibles
assertThat(result.stock).isEqualTo(7)
assertThat(result).isNotNull()
assertThat(list).hasSize(3)
assertThat(price).isEqualByComparingTo("999.99")

// Vérifier qu'une exception est levée
assertThatThrownBy { service.doSomething() }
    .isInstanceOf(MyException::class.java)
    .hasMessage("Expected message")
    .hasMessageContaining("partial")

2.4 Tests paramétrés (en plus)

import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource

class ProductValidationTest {

    @ParameterizedTest
    @DisplayName("Should validate price is positive")
    @ValueSource(strings = ["-10.00", "-0.01", "0.00"])
    fun `createProduct with invalid price should throw exception`(price: String) {
        // Given
        val productService = ProductService(mockk())

        // When & Then
        assertThatThrownBy {
            productService.createProduct(
                name = "Test",
                price = BigDecimal(price),
                stock = 10,
                categoryId = UUID.randomUUID()
            )
        }.isInstanceOf(InvalidPriceException::class.java)
    }

    @ParameterizedTest
    @DisplayName("Should calculate correct total for different quantities")
    @CsvSource(
        "1, 10.00, 10.00",
        "2, 10.00, 20.00",
        "5, 9.99, 49.95"
    )
    fun `calculateTotal with different quantities should return correct amount`(
        quantity: Int,
        unitPrice: String,
        expectedTotal: String
    ) {
        // Given
        val product = Product(
            name = "Test",
            price = BigDecimal(unitPrice),
            stock = 100,
            category = mockk()
        )
        val productService = ProductService(mockk())

        // When
        val total = productService.calculateTotal(product, quantity)

        // Then
        assertThat(total).isEqualByComparingTo(expectedTotal)
    }
}

Exercice 2 (45min) :

Créer UserServiceTest avec au moins 5 tests :

  1. createUser_WithValidData_ShouldReturnUser
  2. createUser_WithDuplicateEmail_ShouldThrowException
  3. getUser_WhenExists_ShouldReturnUser
  4. getUser_WhenNotExists_ShouldThrowException
  5. getUserOrders_ShouldReturnOrderHistory

Template fourni :

@DisplayName("UserService - Unit Tests")
class UserServiceTest {
    
    private lateinit var userRepository: UserRepository
    private lateinit var orderRepository: OrderRepository
    private lateinit var userService: UserService
    
    @BeforeEach
    fun setUp() {
        userRepository = mockk()
        orderRepository = mockk()
        userService = UserService(userRepository, orderRepository)
    }
    
    @Test
    @DisplayName("Should create user with valid data")
    fun `createUser with valid data should return user`() {
        // Given
        val email = "john@example.com"
        val user = User(name = "John Doe", email = email).apply {
            id = UUID.randomUUID()
        }
        
        every { userRepository.existsByEmail(email) } returns false
        every { userRepository.save(any()) } returns user

        // When
        val result = userService.createUser(name = "John Doe", email = email)

        // Then
        assertThat(result).isNotNull()
        assertThat(result.email).isEqualTo(email)
        verify(exactly = 1) { userRepository.save(any()) }
    }
    
    // TODO: Implémenter les 4 autres tests
}

Critères de validation :

  • ✅ Tous les tests passent (mvn test)
  • ✅ Utilisation correcte des mocks
  • ✅ Pattern AAA (Arrange, Act, Assert) respecté
  • ✅ Messages d'erreur explicites avec @DisplayName

Partie 3 : Tests d'Intégration (1h15)

Test d'intégration : Teste le fonctionnement complet de l'API (Controller → Service → Repository → DB)

3.1 Configuration de base

package com.ecommerce.controller

import com.ecommerce.domain.Product
import com.ecommerce.domain.Category
import com.ecommerce.repository.ProductRepository
import com.ecommerce.repository.CategoryRepository
import org.hamcrest.Matchers.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.DisplayName
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.*
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
@DisplayName("ProductController - Integration Tests")
class ProductControllerIntegrationTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var productRepository: ProductRepository

    @Autowired
    private lateinit var categoryRepository: CategoryRepository

    private lateinit var electronics: Category

    @BeforeEach
    fun setUp() {
        productRepository.deleteAll()
        categoryRepository.deleteAll()
        
        electronics = categoryRepository.save(
            Category(
                name = "Electronics",
                description = "Electronic products"
            )
        )
    }

    @Test
    @DisplayName("POST /products should create a new product")
    fun `createProduct should return 201 with created product`() {
        // Given
        val requestBody = """
            {
                "name": "iPhone 15",
                "price": 999.99,
                "stock": 10,
                "categoryId": "${electronics.id}"
            }
        """.trimIndent()

        // When & Then
        mockMvc.post("/products") {
            contentType = MediaType.APPLICATION_JSON
            content = requestBody
        }.andExpect {
            status { isCreated() }
            jsonPath("$.name") { value("iPhone 15") }
            jsonPath("$.price") { value(999.99) }
            jsonPath("$.stock") { value(10) }
            jsonPath("$.category.name") { value("Electronics") }
        }
    }

    @Test
    @DisplayName("GET /products/{id} should return product when exists")
    fun `getProduct when exists should return 200 with product`() {
        // Given
        val product = productRepository.save(
            Product(
                name = "MacBook Pro",
                price = BigDecimal("1999.99"),
                stock = 5,
                category = electronics
            )
        )

        // When & Then
        mockMvc.get("/products/${product.id}")
            .andExpect {
                status { isOk() }
                jsonPath("$.id") { value(product.id.toString()) }
                jsonPath("$.name") { value("MacBook Pro") }
                jsonPath("$.price") { value(1999.99) }
            }
    }

    @Test
    @DisplayName("GET /products/{id} should return 404 when not found")
    fun `getProduct when not exists should return 404`() {
        // Given
        val nonExistentId = java.util.UUID.randomUUID()

        // When & Then
        mockMvc.get("/products/$nonExistentId")
            .andExpect {
                status { isNotFound() }
            }
    }

    @Test
    @DisplayName("PUT /products/{id} should update product")
    fun `updateProduct should return 200 with updated product`() {
        // Given
        val product = productRepository.save(
            Product(
                name = "iPad",
                price = BigDecimal("699.99"),
                stock = 15,
                category = electronics
            )
        )

        val updateRequest = """
            {
                "name": "iPad Pro",
                "price": 899.99,
                "stock": 20
            }
        """.trimIndent()

        // When & Then
        mockMvc.put("/products/${product.id}") {
            contentType = MediaType.APPLICATION_JSON
            content = updateRequest
        }.andExpect {
            status { isOk() }
            jsonPath("$.name") { value("iPad Pro") }
            jsonPath("$.price") { value(899.99) }
            jsonPath("$.stock") { value(20) }
        }
    }

    @Test
    @DisplayName("DELETE /products/{id} should delete product")
    fun `deleteProduct should return 204`() {
        // Given
        val product = productRepository.save(
            Product(
                name = "AirPods",
                price = BigDecimal("199.99"),
                stock = 50,
                category = electronics
            )
        )

        // When & Then
        mockMvc.delete("/products/${product.id}")
            .andExpect {
                status { isNoContent() }
            }

        // Verify deletion
        mockMvc.get("/products/${product.id}")
            .andExpect {
                status { isNotFound() }
            }
    }

    @Test
    @DisplayName("GET /products should return paginated list")
    fun `getProducts should return paginated results`() {
        // Given
        productRepository.save(
            Product(
                name = "iPhone",
                price = BigDecimal("999"),
                stock = 10,
                category = electronics
            )
        )
        productRepository.save(
            Product(
                name = "MacBook",
                price = BigDecimal("1999"),
                stock = 5,
                category = electronics
            )
        )

        // When & Then
        mockMvc.get("/products") {
            param("page", "0")
            param("size", "10")
        }.andExpect {
            status { isOk() }
            jsonPath("$.content") { isArray() }
            jsonPath("$.content.length()") { value(2) }
            jsonPath("$.totalElements") { value(2) }
        }
    }

    @Test
    @DisplayName("GET /products should filter by category")
    fun `getProducts with category filter should return filtered list`() {
        // Given
        val books = categoryRepository.save(
            Category("Books", "Books category")
        )
        
        productRepository.save(
            Product(
                name = "iPhone",
                price = BigDecimal("999"),
                stock = 10,
                category = electronics
            )
        )
        
        productRepository.save(
            Product(
                name = "Java Book",
                price = BigDecimal("50"),
                stock = 20,
                category = books
            )
        )

        // When & Then
        mockMvc.get("/products") {
            param("categoryId", electronics.id.toString())
        }.andExpect {
            status { isOk() }
            jsonPath("$.content.length()") { value(1) }
            jsonPath("$.content[0].name") { value("iPhone") }
        }
    }
}

3.2 Concepts clés

// @SpringBootTest : Lance toute l'application Spring
@SpringBootTest

// @AutoConfigureMockMvc : Configure MockMvc pour simuler les requêtes HTTP
@AutoConfigureMockMvc

// @ActiveProfiles("test") : Utilise application-test.properties
@ActiveProfiles("test")

// @Transactional : Rollback automatique après chaque test
@Transactional

// MockMvc DSL Kotlin : Simule des requêtes HTTP sans démarrer le serveur
mockMvc.get("/products/123")
    .andExpect {
        status { isOk() }
        jsonPath("$.name") { value("iPhone") }
    }

mockMvc.post("/products") {
    contentType = MediaType.APPLICATION_JSON
    content = jsonBody
}.andExpect {
    status { isCreated() }
}

// jsonPath : Parcourt la réponse JSON avec des expressions
jsonPath("$.name")              // Champ direct
jsonPath("$.category.name")     // Objet imbriqué
jsonPath("$.items[0].name")     // Premier élément d'un tableau
jsonPath("$.items.length()")    // Taille du tableau

3.3 Test avec détection N+1

import io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator
import io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator.*

@Test
@DisplayName("GET /users/{id}/orders should not trigger N+1 queries")
fun `getUserOrders should not trigger NPlusOne`() {
    // Given
    val user = userRepository.save(User("John", "john@example.com"))
    
    repeat(5) {
        val order = Order(user = user)
        order.addItem(OrderItem(product1, 1, product1.price))
        order.addItem(OrderItem(product2, 2, product2.price))
        orderRepository.save(order)
    }

    // When
    SQLStatementCountValidator.reset()
    
    mockMvc.get("/users/${user.id}/orders")
        .andExpect {
            status { isOk() }
            jsonPath("$.length()") { value(5) }
            jsonPath("$[0].items.length()") { value(2) }
        }

    // Then - Vérifier le nombre de requêtes SQL
    assertSelectCount(2) // 1 pour User + 1 pour Orders avec items (JOIN FETCH)
}

Exercice 3 (1h) :

Créer UserControllerIntegrationTest et OrderControllerIntegrationTest avec :

UserController (30min) :

  1. POST /users - Création valide → 201
  2. POST /users - Email déjà utilisé → 409 Conflict
  3. GET /users/{id} - Utilisateur existant → 200
  4. GET /users/{id} - Utilisateur inexistant → 404
  5. GET /users/{id}/recommendations - Retourne des produits → 200

OrderController (30min) :

  1. POST /orders - Création valide → 201
  2. POST /orders - Stock insuffisant → 400
  3. GET /orders/{id} - Commande existante → 200
  4. GET /users/{userId}/orders - Historique → 200
  5. Bonus : Test N+1 sur l'historique des commandes

Critères de validation :

  • ✅ Tous les tests passent (mvn verify)
  • @BeforeEach pour préparer les données
  • ✅ Vérification des codes HTTP corrects
  • ✅ Vérification du contenu JSON retourné
  • ✅ Au moins 1 test de performance (N+1)

Partie 4 : Couvert