Différences
Ci-dessous, les différences entre deux révisions de la page.
| Les deux révisions précédentes Révision précédente Prochaine révision | Révision précédente | ||
| eadl:bloc3:dev_av:td3 [2025/10/29 00:38] – jcheron | eadl:bloc3:dev_av:td3 [2025/11/09 16:36] (Version actuelle) – jcheron | ||
|---|---|---|---|
| Ligne 1: | Ligne 1: | ||
| - | ====== | + | ====== 3 - Tests et CI/CD ====== |
| ===== Objectifs pédagogiques ===== | ===== Objectifs pédagogiques ===== | ||
| Ligne 15: | Ligne 15: | ||
| * Qui a terminé les associations Order/ | * Qui a terminé les associations Order/ | ||
| * Qui a résolu des problèmes N+1 ? | * Qui a résolu des problèmes N+1 ? | ||
| - | | + | * Ceux qui ont fini peuvent commencer les tests, les autres finalisent le TD2 |
| </ | </ | ||
| Ligne 26: | Ligne 26: | ||
| </ | </ | ||
| - | === Structure des fichiers | + | === Profiles |
| - | <sxh; | + | La création de profiles permet de gérer des configurations différentes, |
| - | src/main/resources/ | + | |
| - | ├── application.properties | + | === pom.xml - Configuration des profils Maven === |
| - | ├── application-dev.properties | + | |
| - | ├── application-test.properties | + | < |
| - | └── application-prod.properties | + | < |
| + | < | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | </activation> | ||
| + | <properties> | ||
| + | < | ||
| + | </properties> | ||
| + | </ | ||
| + | |||
| + | < | ||
| + | <id>test</ | ||
| + | <properties> | ||
| + | < | ||
| + | </ | ||
| + | </ | ||
| + | |||
| + | < | ||
| + | <id>prod</ | ||
| + | <properties> | ||
| + | < | ||
| + | </ | ||
| + | </ | ||
| + | </ | ||
| </ | </ | ||
| === application.properties (commun) === | === application.properties (commun) === | ||
| - | <sxh properties; | + | <sxh properties> |
| - | # Configuration commune à tous les profils | + | # Récupération du profile Maven pour def du profile Spring |
| - | spring.application.name=ecommerce-api | + | spring.profiles.active=@activeProfile@ |
| - | server.port=8080 | + | |
| # JPA commun | # JPA commun | ||
| Ligne 53: | Ligne 76: | ||
| === application-dev.properties === | === application-dev.properties === | ||
| - | <sxh properties; | + | <sxh properties> |
| # Base H2 fichier pour le dev | # Base H2 fichier pour le dev | ||
| spring.datasource.url=jdbc: | spring.datasource.url=jdbc: | ||
| Ligne 75: | Ligne 98: | ||
| === application-test.properties === | === application-test.properties === | ||
| - | <sxh properties; | + | <sxh properties> |
| # Base H2 en mémoire pour les tests | # Base H2 en mémoire pour les tests | ||
| spring.datasource.url=jdbc: | spring.datasource.url=jdbc: | ||
| Ligne 98: | Ligne 121: | ||
| === application-prod.properties === | === application-prod.properties === | ||
| - | <sxh properties; | + | <sxh properties> |
| # Base PostgreSQL (exemple) | # Base PostgreSQL (exemple) | ||
| spring.datasource.url=${DATABASE_URL} | spring.datasource.url=${DATABASE_URL} | ||
| Ligne 117: | Ligne 140: | ||
| ==== 1.2 Activation des profils ==== | ==== 1.2 Activation des profils ==== | ||
| - | <sxh bash; | + | <sxh bash> |
| # Dans IntelliJ : Run Configuration > Active profiles: dev | # Dans IntelliJ : Run Configuration > Active profiles: dev | ||
| # Ou via variable d' | # Ou via variable d' | ||
| Ligne 151: | Ligne 174: | ||
| < | < | ||
| < | < | ||
| + | < | ||
| + | </ | ||
| + | | ||
| + | <!-- MockK pour Kotlin (optionnel, alternative à Mockito) --> | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | < | ||
| < | < | ||
| </ | </ | ||
| Ligne 158: | Ligne 189: | ||
| ==== 2.2 Premier test simple : ProductService ==== | ==== 2.2 Premier test simple : ProductService ==== | ||
| - | < | + | < |
| - | package com.ecommerce.service; | + | 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 org.junit.jupiter.api.Test; | + | |
| - | import org.junit.jupiter.api.DisplayName; | + | |
| - | import org.junit.jupiter.api.extension.ExtendWith; | + | |
| - | import org.mockito.InjectMocks; | + | |
| - | import org.mockito.Mock; | + | |
| - | import org.mockito.junit.jupiter.MockitoExtension; | + | |
| - | + | ||
| - | import java.math.BigDecimal; | + | |
| - | import java.util.Optional; | + | |
| - | import java.util.UUID; | + | |
| - | import | + | import |
| - | import | + | 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 | ||
| + | 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.* | ||
| - | @ExtendWith(MockitoExtension.class) | ||
| @DisplayName(" | @DisplayName(" | ||
| class ProductServiceTest { | class ProductServiceTest { | ||
| - | | + | |
| - | private | + | private |
| + | private lateinit var category: Category | ||
| - | @InjectMocks | + | @BeforeEach |
| - | | + | |
| + | productRepository = mockk() | ||
| + | | ||
| + | category = Category(name = " | ||
| + | category.id = UUID.randomUUID() | ||
| + | } | ||
| @Test | @Test | ||
| - | @DisplayName(" | + | @DisplayName(" |
| - | | + | |
| - | // Given (Arrange) | + | // Given |
| - | | + | |
| - | | + | |
| - | | + | name = " |
| - | | + | price = BigDecimal(" |
| - | .name(" | + | stock = 10, |
| - | | + | category |
| - | | + | ).apply { id = UUID.randomUUID() } |
| - | | + | |
| - | .build(); | + | |
| - | | + | |
| - | when(productRepository.findById(productId)) | + | |
| - | | + | |
| - | | + | |
| - | Product result = productService.getProduct(productId); | + | |
| - | // Then (Assert) | + | // When |
| - | assertThat(result).isNotNull(); | + | val result = productService.createProduct( |
| - | assertThat(result.getName()).isEqualTo(" | + | name = " |
| - | assertThat(result.getPrice()).isEqualByComparingTo(" | + | price = BigDecimal(" |
| - | assertThat(result.getStock()).isEqualTo(10); | + | stock = 10, |
| - | | + | categoryId = category.id!! |
| - | verify(productRepository, | + | ) |
| - | verifyNoMoreInteractions(productRepository); | + | |
| + | // Then | ||
| + | assertThat(result).isNotNull | ||
| + | assertThat(result.name).isEqualTo(" | ||
| + | assertThat(result.price).isEqualByComparingTo(" | ||
| + | assertThat(result.stock).isEqualTo(10) | ||
| + | verify(exactly = 1) { productRepository.save(any()) } | ||
| } | } | ||
| @Test | @Test | ||
| @DisplayName(" | @DisplayName(" | ||
| - | | + | |
| // Given | // Given | ||
| - | | + | |
| - | | + | |
| - | .thenReturn(Optional.empty()); | + | |
| // When & Then | // When & Then | ||
| - | assertThatThrownBy(() -> productService.getProduct(productId)) | + | assertThatThrownBy |
| - | .isInstanceOf(ProductNotFoundException.class) | + | .isInstanceOf(ProductNotFoundException::class.java) |
| - | .hasMessageContaining(productId.toString()); | + | .hasMessageContaining(productId.toString()) |
| - | + | ||
| - | verify(productRepository).findById(productId); | + | |
| } | } | ||
| @Test | @Test | ||
| - | @DisplayName(" | + | @DisplayName(" |
| - | | + | |
| // Given | // Given | ||
| - | | + | |
| - | | + | |
| - | | + | name = "Test Product" |
| - | .name("Test Product" | + | price = BigDecimal("10.00"), |
| - | | + | stock = 10, |
| - | | + | |
| - | | + | |
| - | + | ||
| - | | + | |
| - | .thenReturn(Optional.of(product)); | + | |
| - | | + | |
| - | .thenReturn(product); | + | |
| // When | // When | ||
| - | productService.updateStock(productId, | + | productService.decreaseStock(productId, 3) |
| // Then | // Then | ||
| - | assertThat(product.getStock()).isEqualTo(7); | + | assertThat(product.stock).isEqualTo(7) |
| - | verify(productRepository).save(product); | + | verify(exactly = 1) { productRepository.save(product) |
| } | } | ||
| @Test | @Test | ||
| @DisplayName(" | @DisplayName(" | ||
| - | | + | |
| // Given | // Given | ||
| - | | + | |
| - | | + | |
| - | | + | name = "Test Product" |
| - | .name("Test Product" | + | price = BigDecimal("10.00"), |
| - | | + | stock = 2, |
| - | | + | |
| - | | + | |
| - | + | ||
| - | | + | |
| - | .thenReturn(Optional.of(product)); | + | |
| // When & Then | // When & Then | ||
| - | assertThatThrownBy(() -> productService.updateStock(productId, | + | assertThatThrownBy |
| - | .isInstanceOf(InsufficientStockException.class); | + | .isInstanceOf(InsufficientStockException::class.java) |
| - | + | .hasMessageContaining(" | |
| - | verify(productRepository, | + | |
| } | } | ||
| @Test | @Test | ||
| - | @DisplayName(" | + | @DisplayName(" |
| - | | + | |
| // Given | // Given | ||
| - | | + | |
| - | | + | |
| - | | + | name = "Test Product" |
| - | .name("Test Product" | + | price = BigDecimal("10.00"), |
| - | | + | stock = 10, |
| - | | + | |
| - | | + | |
| - | + | ||
| - | | + | |
| - | .thenReturn(Optional.of(product)); | + | |
| - | | + | |
| - | .thenReturn(product); | + | |
| // When | // When | ||
| - | productService.updateStock(productId, | + | productService.updateStock(productId, |
| // Then | // Then | ||
| - | assertThat(product.getStock()).isEqualTo(15); | + | assertThat(product.stock).isEqualTo(15) |
| - | verify(productRepository).save(product); | + | verify(exactly = 1) { productRepository.save(product) |
| } | } | ||
| } | } | ||
| Ligne 312: | Ligne 336: | ||
| ==== 2.3 Concepts clés ==== | ==== 2.3 Concepts clés ==== | ||
| - | < | + | < |
| - | // @Mock : Crée un faux objet (ne fait rien par défaut) | + | // MockK pour Kotlin |
| - | @Mock | + | import io.mockk.* |
| - | private ProductRepository productRepository; | + | |
| - | // @InjectMocks : Injecte automatiquement les mocks dans la classe testée | + | // Créer un mock |
| - | @InjectMocks | + | val repository = mockk< |
| - | private ProductService productService; | + | |
| - | // when(...).thenReturn(...) : Définit | + | // Définir |
| - | when(productRepository.findById(id)).thenReturn(Optional.of(product)); | + | every { repository.findById(id) |
| + | every { repository.save(any()) } returns product | ||
| - | // verify(...) : Vérifie qu'une méthode a été appelée (et combien de fois) | + | // Capturer un argument |
| - | verify(productRepository, | + | val productSlot = slot< |
| - | verify(productRepository, | + | every { repository.save(capture(productSlot)) } returns product |
| - | // assertThat(...) : Vérifie le résultat | + | // Vérifier qu'une méthode a été appelée |
| - | assertThat(result.getStock()).isEqualTo(7); | + | verify(exactly = 1) { repository.save(any()) } |
| - | assertThat(result).isNotNull(); | + | verify(atLeast = 1) { repository.findById(any()) } |
| - | assertThat(list).hasSize(3); | + | verify(exactly = 0) { repository.delete(any()) } |
| - | // assertThatThrownBy : Vérifie | + | // AssertJ - Assertions plus lisibles |
| - | assertThatThrownBy(() -> service.doSomething()) | + | assertThat(result.stock).isEqualTo(7) |
| - | .isInstanceOf(MyException.class) | + | assertThat(result).isNotNull() |
| - | .hasMessage(" | + | assertThat(list).hasSize(3) |
| + | assertThat(price).isEqualByComparingTo(" | ||
| + | |||
| + | // Vérifier | ||
| + | assertThatThrownBy | ||
| + | .isInstanceOf(MyException::class.java) | ||
| + | .hasMessage(" | ||
| + | .hasMessageContaining(" | ||
| </ | </ | ||
| ==== 2.4 Tests paramétrés (en plus) ==== | ==== 2.4 Tests paramétrés (en plus) ==== | ||
| - | < | + | < |
| - | import org.junit.jupiter.params.ParameterizedTest; | + | import org.junit.jupiter.params.ParameterizedTest |
| - | import org.junit.jupiter.params.provider.CsvSource; | + | import org.junit.jupiter.params.provider.CsvSource |
| - | import org.junit.jupiter.params.provider.ValueSource; | + | import org.junit.jupiter.params.provider.ValueSource |
| - | @ParameterizedTest | + | class ProductValidationTest |
| - | @DisplayName(" | + | |
| - | @ValueSource(strings = {" | + | |
| - | void createProduct_WithInvalidPrice_ShouldThrowException(String price) { | + | |
| - | // Given | + | |
| - | CreateProductDto dto = new CreateProductDto(); | + | |
| - | dto.setName(" | + | |
| - | dto.setPrice(new BigDecimal(price)); | + | |
| - | dto.setStock(10); | + | |
| - | | + | |
| - | | + | |
| - | | + | @ValueSource(strings = ["-10.00", " |
| - | } | + | fun `createProduct |
| + | | ||
| + | val productService = ProductService(mockk()) | ||
| - | @ParameterizedTest | + | // When & Then |
| - | @DisplayName(" | + | |
| - | @CsvSource({ | + | |
| - | " | + | name = "Test", |
| - | "2, 10.00, 20.00", | + | price = BigDecimal(price), |
| - | " | + | stock = 10, |
| - | }) | + | |
| - | void calculateTotal_WithDifferentQuantities_ShouldReturnCorrectAmount( | + | ) |
| - | int quantity, | + | |
| - | | + | } |
| - | | + | |
| - | ) { | + | |
| - | // Given | + | |
| - | Product product | + | |
| - | | + | |
| - | .build(); | + | |
| - | | + | |
| - | | + | |
| + | @CsvSource( | ||
| + | "1, 10.00, 10.00", | ||
| + | "2, 10.00, 20.00", | ||
| + | "5, 9.99, 49.95" | ||
| + | ) | ||
| + | fun `calculateTotal | ||
| + | quantity: Int, | ||
| + | unitPrice: String, | ||
| + | expectedTotal: | ||
| + | ) { | ||
| + | // Given | ||
| + | val product | ||
| + | name = " | ||
| + | price = BigDecimal(unitPrice), | ||
| + | stock = 100, | ||
| + | category = mockk() | ||
| + | ) | ||
| + | val productService = ProductService(mockk()) | ||
| - | | + | // When |
| - | assertThat(total).isEqualByComparingTo(expectedTotal); | + | val total = productService.calculateTotal(product, |
| + | |||
| + | | ||
| + | assertThat(total).isEqualByComparingTo(expectedTotal) | ||
| + | } | ||
| } | } | ||
| </ | </ | ||
| Ligne 397: | Ligne 437: | ||
| **Template fourni :** | **Template fourni :** | ||
| - | <code java> | + | <sxh kotlin> |
| - | @ExtendWith(MockitoExtension.class) | + | |
| @DisplayName(" | @DisplayName(" | ||
| class UserServiceTest { | class UserServiceTest { | ||
| | | ||
| - | | + | |
| - | private | + | private |
| + | private lateinit var userService: | ||
| | | ||
| - | @Mock | + | @BeforeEach |
| - | | + | |
| - | + | | |
| - | | + | |
| - | | + | userService |
| + | } | ||
| | | ||
| @Test | @Test | ||
| @DisplayName(" | @DisplayName(" | ||
| - | | + | |
| // Given | // Given | ||
| - | | + | |
| - | | + | |
| - | | + | id = UUID.randomUUID() |
| - | | + | } |
| - | .email(dto.getEmail()) | + | |
| - | .build(); | + | |
| | | ||
| - | | + | |
| - | | + | |
| // When | // When | ||
| - | | + | |
| // Then | // Then | ||
| - | assertThat(result).isNotNull(); | + | assertThat(result).isNotNull() |
| - | assertThat(result.getEmail()).isEqualTo(" | + | assertThat(result.email).isEqualTo(email) |
| - | verify(userRepository).save(any(User.class)); | + | verify(exactly = 1) { userRepository.save(any()) |
| } | } | ||
| | | ||
| // TODO: Implémenter les 4 autres tests | // TODO: Implémenter les 4 autres tests | ||
| } | } | ||
| - | </code> | + | </sxh> |
| **Critères de validation :** | **Critères de validation :** | ||
| Ligne 453: | Ligne 492: | ||
| ==== 3.1 Configuration de base ==== | ==== 3.1 Configuration de base ==== | ||
| - | < | + | < |
| - | package com.ecommerce.controller; | + | package com.ecommerce.controller |
| - | import com.ecommerce.domain.Product; | + | import com.ecommerce.domain.Product |
| - | import com.ecommerce.domain.Category; | + | import com.ecommerce.domain.Category |
| - | import com.ecommerce.repository.ProductRepository; | + | import com.ecommerce.repository.ProductRepository |
| - | import com.ecommerce.repository.CategoryRepository; | + | import com.ecommerce.repository.CategoryRepository |
| - | import org.junit.jupiter.api.BeforeEach; | + | import org.hamcrest.Matchers.* |
| - | import org.junit.jupiter.api.Test; | + | import org.junit.jupiter.api.BeforeEach |
| - | import org.junit.jupiter.api.DisplayName; | + | import org.junit.jupiter.api.Test |
| - | import org.springframework.beans.factory.annotation.Autowired; | + | import org.junit.jupiter.api.DisplayName |
| - | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | + | import org.springframework.beans.factory.annotation.Autowired |
| - | import org.springframework.boot.test.context.SpringBootTest; | + | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc |
| - | import org.springframework.http.MediaType; | + | import org.springframework.boot.test.context.SpringBootTest |
| - | import org.springframework.test.context.ActiveProfiles; | + | import org.springframework.http.MediaType |
| - | import org.springframework.test.web.servlet.MockMvc; | + | import org.springframework.test.context.ActiveProfiles |
| - | import org.springframework.transaction.annotation.Transactional; | + | import org.springframework.test.web.servlet.* |
| - | + | import org.springframework.transaction.annotation.Transactional | |
| - | import java.math.BigDecimal; | + | import java.math.BigDecimal |
| - | + | ||
| - | import static org.hamcrest.Matchers.*; | + | |
| - | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; | + | |
| - | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; | + | |
| - | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; | + | |
| @SpringBootTest | @SpringBootTest | ||
| @AutoConfigureMockMvc | @AutoConfigureMockMvc | ||
| @ActiveProfiles(" | @ActiveProfiles(" | ||
| - | @Transactional | + | @Transactional |
| @DisplayName(" | @DisplayName(" | ||
| class ProductControllerIntegrationTest { | class ProductControllerIntegrationTest { | ||
| @Autowired | @Autowired | ||
| - | private | + | private |
| @Autowired | @Autowired | ||
| - | private | + | private |
| @Autowired | @Autowired | ||
| - | private | + | private |
| - | private | + | private |
| @BeforeEach | @BeforeEach | ||
| - | | + | |
| - | // Nettoyage (si @Transactional ne suffit pas) | + | productRepository.deleteAll() |
| - | productRepository.deleteAll(); | + | categoryRepository.deleteAll() |
| - | categoryRepository.deleteAll(); | + | |
| | | ||
| - | // Données de test | ||
| electronics = categoryRepository.save( | electronics = categoryRepository.save( | ||
| - | | + | Category( |
| - | ); | + | name = " |
| + | description = " | ||
| + | | ||
| + | ) | ||
| } | } | ||
| @Test | @Test | ||
| - | @DisplayName(" | + | @DisplayName(" |
| - | | + | |
| // Given | // Given | ||
| - | | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | .category(electronics) | + | |
| - | .build() | + | } |
| - | ); | + | """ |
| // When & Then | // When & Then | ||
| - | mockMvc.perform(get("/ | + | mockMvc.post("/ |
| - | .andDo(print()) // Affiche la requête/ | + | |
| - | | + | |
| - | .andExpect(content().contentType(MediaType.APPLICATION_JSON)) | + | }.andExpect |
| - | | + | |
| - | | + | jsonPath(" |
| - | | + | jsonPath(" |
| - | | + | jsonPath(" |
| - | | + | jsonPath(" |
| + | } | ||
| } | } | ||
| @Test | @Test | ||
| - | @DisplayName(" | + | @DisplayName(" |
| - | | + | |
| + | // Given | ||
| + | val product = productRepository.save( | ||
| + | Product( | ||
| + | name = " | ||
| + | price = BigDecimal(" | ||
| + | stock = 5, | ||
| + | category = electronics | ||
| + | ) | ||
| + | ) | ||
| // When & Then | // When & Then | ||
| - | mockMvc.perform(get("/ | + | mockMvc.get("/ |
| - | .andExpect(status().isNotFound()) | + | .andExpect |
| - | .andExpect(jsonPath(" | + | |
| + | jsonPath(" | ||
| + | | ||
| + | | ||
| + | } | ||
| } | } | ||
| @Test | @Test | ||
| - | @DisplayName(" | + | @DisplayName(" |
| - | | + | |
| // Given | // Given | ||
| - | | + | |
| - | { | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | } | + | |
| - | """ | + | |
| // When & Then | // When & Then | ||
| - | mockMvc.perform(post("/ | + | mockMvc.get("/ |
| - | .contentType(MediaType.APPLICATION_JSON) | + | .andExpect |
| - | .content(requestBody)) | + | |
| - | .andDo(print()) | + | |
| - | .andExpect(status().isCreated()) | + | |
| - | .andExpect(header().exists(" | + | |
| - | .andExpect(jsonPath(" | + | |
| - | .andExpect(jsonPath(" | + | |
| - | | + | |
| - | | + | |
| } | } | ||
| @Test | @Test | ||
| - | @DisplayName(" | + | @DisplayName(" |
| - | | + | |
| - | // Given - prix négatif | + | // Given |
| - | | + | |
| + | Product( | ||
| + | name = " | ||
| + | price = BigDecimal(" | ||
| + | stock = 15, | ||
| + | category = electronics | ||
| + | ) | ||
| + | ) | ||
| + | |||
| + | val updateRequest | ||
| { | { | ||
| - | " | + | " |
| - | " | + | " |
| - | " | + | " |
| - | " | + | |
| } | } | ||
| - | | + | |
| // When & Then | // When & Then | ||
| - | mockMvc.perform(post("/ | + | mockMvc.put("/ |
| - | .contentType(MediaType.APPLICATION_JSON) | + | contentType |
| - | .content(requestBody)) | + | content |
| - | .andExpect(status().isBadRequest()) | + | }.andExpect |
| - | .andExpect(jsonPath(" | + | |
| + | jsonPath(" | ||
| + | | ||
| + | | ||
| + | } | ||
| } | } | ||
| @Test | @Test | ||
| - | @DisplayName(" | + | @DisplayName(" |
| - | | + | |
| // Given | // Given | ||
| - | | + | |
| - | Product.builder() | + | Product( |
| - | | + | name = "AirPods", |
| - | | + | price = BigDecimal("199.99"), |
| - | | + | stock = 50, |
| - | | + | category |
| - | | + | ) |
| - | ); | + | ) |
| - | | + | |
| - | { | + | mockMvc.delete("/ |
| - | | + | |
| + | | ||
| } | } | ||
| - | """; | ||
| - | // When & Then | + | // Verify deletion |
| - | mockMvc.perform(put("/ | + | mockMvc.get("/ |
| - | .contentType(MediaType.APPLICATION_JSON) | + | .andExpect { |
| - | | + | status |
| - | .andExpect(status().isOk()) | + | |
| - | | + | |
| } | } | ||
| @Test | @Test | ||
| @DisplayName(" | @DisplayName(" | ||
| - | | + | |
| // Given | // Given | ||
| - | productRepository.save(Product.builder() | + | productRepository.save( |
| - | .name("Product 1") | + | |
| - | .price(BigDecimal.TEN) | + | name = "iPhone", |
| - | .stock(10) | + | price = BigDecimal(" |
| - | .category(electronics) | + | stock = 10, |
| - | | + | category |
| - | + | ) | |
| - | productRepository.save(Product.builder() | + | |
| - | .name("Product 2") | + | productRepository.save( |
| - | .price(BigDecimal.valueOf(20)) | + | |
| - | .stock(20) | + | name = "MacBook", |
| - | .category(electronics) | + | price = BigDecimal(" |
| - | | + | stock = 5, |
| + | category | ||
| + | ) | ||
| + | | ||
| // When & Then | // When & Then | ||
| - | mockMvc.perform(get("/ | + | mockMvc.get("/ |
| - | .param(" | + | param(" |
| - | .param(" | + | param(" |
| - | .andExpect(status().isOk()) | + | }.andExpect |
| - | | + | |
| - | | + | jsonPath(" |
| - | | + | jsonPath(" |
| + | jsonPath(" | ||
| + | } | ||
| } | } | ||
| @Test | @Test | ||
| @DisplayName(" | @DisplayName(" | ||
| - | | + | |
| // Given | // Given | ||
| - | | + | |
| + | | ||
| + | | ||
| | | ||
| - | productRepository.save(Product.builder() | + | productRepository.save( |
| - | .name(" | + | |
| - | .price(BigDecimal.valueOf(999)) | + | name = " |
| - | .stock(10) | + | price = BigDecimal("999"), |
| - | .category(electronics) | + | stock = 10, |
| - | | + | category |
| + | ) | ||
| + | | ||
| | | ||
| - | productRepository.save(Product.builder() | + | productRepository.save( |
| - | .name("Java Book") | + | |
| - | .price(BigDecimal.valueOf(50)) | + | name = "Java Book", |
| - | .stock(20) | + | price = BigDecimal("50"), |
| - | .category(books) | + | stock = 20, |
| - | | + | category |
| + | ) | ||
| + | | ||
| // When & Then | // When & Then | ||
| - | mockMvc.perform(get("/ | + | mockMvc.get("/ |
| - | .param(" | + | param(" |
| - | .andExpect(status().isOk()) | + | }.andExpect |
| - | | + | |
| - | | + | jsonPath(" |
| + | jsonPath(" | ||
| + | } | ||
| } | } | ||
| } | } | ||
| Ligne 676: | Ligne 737: | ||
| ==== 3.2 Concepts clés ==== | ==== 3.2 Concepts clés ==== | ||
| - | < | + | < |
| // @SpringBootTest : Lance toute l' | // @SpringBootTest : Lance toute l' | ||
| @SpringBootTest | @SpringBootTest | ||
| Ligne 689: | Ligne 750: | ||
| @Transactional | @Transactional | ||
| - | // MockMvc : Simule des requêtes HTTP sans démarrer le serveur | + | // MockMvc |
| - | mockMvc.perform(get("/ | + | mockMvc.get("/ |
| - | .andExpect(status().isOk()) | + | .andExpect |
| - | | + | |
| + | jsonPath(" | ||
| + | } | ||
| + | |||
| + | mockMvc.post("/ | ||
| + | contentType = MediaType.APPLICATION_JSON | ||
| + | content = jsonBody | ||
| + | }.andExpect { | ||
| + | status { isCreated() } | ||
| + | } | ||
| // jsonPath : Parcourt la réponse JSON avec des expressions | // jsonPath : Parcourt la réponse JSON avec des expressions | ||
| Ligne 698: | Ligne 768: | ||
| jsonPath(" | jsonPath(" | ||
| jsonPath(" | jsonPath(" | ||
| - | jsonPath(" | + | jsonPath(" |
| </ | </ | ||
| ==== 3.3 Test avec détection N+1 ==== | ==== 3.3 Test avec détection N+1 ==== | ||
| - | < | + | < |
| - | import io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator; | + | import io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator |
| - | import | + | import io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator.* |
| @Test | @Test | ||
| @DisplayName(" | @DisplayName(" | ||
| - | void getUserOrders_ShouldNotTriggerNPlusOne() throws Exception | + | fun `getUserOrders should not trigger NPlusOne`() { |
| // Given | // Given | ||
| - | | + | |
| | | ||
| - | | + | |
| - | | + | |
| - | order.addItem(new OrderItem(product1, | + | order.addItem(OrderItem(product1, |
| - | order.addItem(new OrderItem(product2, | + | order.addItem(OrderItem(product2, |
| - | orderRepository.save(order); | + | orderRepository.save(order) |
| } | } | ||
| // When | // When | ||
| - | SQLStatementCountValidator.reset(); | + | SQLStatementCountValidator.reset() |
| | | ||
| - | mockMvc.perform(get("/ | + | mockMvc.get("/ |
| - | .andExpect(status().isOk()) | + | .andExpect |
| - | | + | |
| - | | + | jsonPath(" |
| + | jsonPath(" | ||
| + | } | ||
| // Then - Vérifier le nombre de requêtes SQL | // Then - Vérifier le nombre de requêtes SQL | ||
| - | assertSelectCount(2); // 1 pour User + 1 pour Orders avec items (JOIN FETCH) | + | assertSelectCount(2) // 1 pour User + 1 pour Orders avec items (JOIN FETCH) |
| } | } | ||
| </ | </ | ||
| Ligne 765: | Ligne 837: | ||
| <sxh xml> | <sxh xml> | ||
| - | <!-- pom.xml --> | ||
| < | < | ||
| < | < | ||
| - | <!-- JaCoCo | + | <!-- JaCoCo |
| < | < | ||
| < | < | ||
| Ligne 774: | Ligne 845: | ||
| < | < | ||
| < | < | ||
| + | <!-- Préparation de l' | ||
| < | < | ||
| + | < | ||
| < | < | ||
| < | < | ||
| </ | </ | ||
| </ | </ | ||
| + | | ||
| + | <!-- Génération du rapport après les tests --> | ||
| < | < | ||
| < | < | ||
| Ligne 786: | Ligne 861: | ||
| </ | </ | ||
| </ | </ | ||
| + | | ||
| + | <!-- Vérification du seuil minimum de couverture --> | ||
| < | < | ||
| - | <id>jacoco-check</ | + | < |
| < | < | ||
| < | < | ||
| Ligne 807: | Ligne 884: | ||
| </ | </ | ||
| </ | </ | ||
| - | </ | ||
| - | |||
| - | <!-- Surefire pour les tests unitaires --> | ||
| - | < | ||
| - | < | ||
| - | < | ||
| - | < | ||
| < | < | ||
| - | < | ||
| - | < | ||
| - | </ | ||
| < | < | ||
| - | < | + | |
| + | | ||
| + | <!-- Exclure les DTOs --> | ||
| + | < | ||
| + | <!-- Exclure la classe main --> | ||
| + | < | ||
| + | <!-- Exclure les configurations --> | ||
| + | < | ||
| </ | </ | ||
| </ | </ | ||
| - | </ | ||
| - | |||
| - | <!-- Failsafe pour les tests d' | ||
| - | < | ||
| - | < | ||
| - | < | ||
| - | < | ||
| - | < | ||
| - | < | ||
| - | < | ||
| - | </ | ||
| - | </ | ||
| - | < | ||
| - | < | ||
| - | < | ||
| - | < | ||
| - | < | ||
| - | </ | ||
| - | </ | ||
| - | </ | ||
| </ | </ | ||
| </ | </ | ||
| Ligne 849: | Ligne 903: | ||
| ==== 4.2 Commandes Maven ==== | ==== 4.2 Commandes Maven ==== | ||
| - | <sxh bash; | + | <sxh bash> |
| - | # Tests unitaires uniquement (rapides) | + | # Exécuter les tests et générer le rapport |
| - | mvn clean test | + | |
| - | + | ||
| - | # Tests unitaires + rapport | + | |
| mvn clean test jacoco: | mvn clean test jacoco: | ||
| + | |||
| + | # Vérifier que le seuil de couverture est atteint | ||
| + | mvn jacoco: | ||
| # Tous les tests (unitaires + intégration) | # Tous les tests (unitaires + intégration) | ||
| Ligne 865: | Ligne 919: | ||
| ==== 4.3 Exclusion de certaines classes ==== | ==== 4.3 Exclusion de certaines classes ==== | ||
| - | <sxh xml> | + | Déjà configuré dans la section précédente. Les exclusions communes pour un projet Kotlin : |
| - | < | + | * Entités |
| - | < | + | |
| - | <!-- Exclure les entités | + | |
| - | < | + | * Classe |
| - | <!-- Exclure les DTOs --> | + | |
| - | < | + | |
| - | < | + | |
| - | < | + | |
| - | </ | + | |
| - | </ | + | |
| - | </ | + | |
| <WRAP round bloc todo> | <WRAP round bloc todo> | ||
| Ligne 920: | Ligne 967: | ||
| - name: Run unit tests | - name: Run unit tests | ||
| - | run: mvn clean test | + | run: mvn clean test -DskipIntegrationTests |
| - name: Upload test results | - name: Upload test results | ||
| Ligne 929: | Ligne 976: | ||
| path: target/ | path: target/ | ||
| - | # Job 2 : Tests d' | + | # Job 2 : Tests d' |
| integration-tests: | integration-tests: | ||
| name: Integration Tests | name: Integration Tests | ||
| runs-on: ubuntu-latest | runs-on: ubuntu-latest | ||
| - | needs: unit-tests | + | needs: unit-tests |
| | | ||
| steps: | steps: | ||
| Ligne 956: | Ligne 1003: | ||
| path: target/ | path: target/ | ||
| - | # Job 3 : Analyse | + | # Job 3 : Couverture |
| - | coverage: | + | |
| name: Code Coverage | name: Code Coverage | ||
| runs-on: ubuntu-latest | runs-on: ubuntu-latest | ||
| - | needs: integration-tests | + | needs: |
| | | ||
| steps: | steps: | ||
| Ligne 973: | Ligne 1020: | ||
| cache: maven | cache: maven | ||
| - | - name: Generate | + | - name: Run tests with coverage |
| run: mvn clean verify jacoco: | run: mvn clean verify jacoco: | ||
| - | - name: Upload | + | - name: Check coverage |
| - | | + | |
| - | with: | + | |
| - | files: ./ | + | |
| - | flags: unittests | + | |
| - | name: codecov-umbrella | + | |
| - | fail_ci_if_error: false | + | |
| - | - name: Upload | + | - name: Upload |
| uses: actions/ | uses: actions/ | ||
| with: | with: | ||
| - | name: jacoco-report | + | name: coverage-report |
| path: target/ | path: target/ | ||
| - | | + | - name: Comment PR with coverage |
| + | if: github.event_name == ' | ||
| + | uses: codecov/ | ||
| + | with: | ||
| + | files: target/ | ||
| + | fail_ci_if_error: | ||
| + | |||
| + | | ||
| build: | build: | ||
| name: Build Application | name: Build Application | ||
| runs-on: ubuntu-latest | runs-on: ubuntu-latest | ||
| - | needs: coverage | + | needs: |
| | | ||
| steps: | steps: | ||
| Ligne 1010: | Ligne 1059: | ||
| run: mvn clean package -DskipTests | run: mvn clean package -DskipTests | ||
| - | - name: Upload | + | - name: Upload |
| uses: actions/ | uses: actions/ | ||
| with: | with: | ||
| Ligne 1017: | Ligne 1066: | ||
| </ | </ | ||
| - | ==== 5.2 Configuration pour séparer les tests ==== | + | ==== 5.2 Séparation des tests (pom.xml) |
| <sxh xml> | <sxh xml> | ||
| - | <!-- pom.xml - Ajout de propriétés --> | ||
| < | < | ||
| < | < | ||
| Ligne 1028: | Ligne 1076: | ||
| < | < | ||
| < | < | ||
| + | <!-- Tests unitaires avec Surefire --> | ||
| < | < | ||
| < | < | ||
| Ligne 1033: | Ligne 1082: | ||
| < | < | ||
| < | < | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | </ | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | </ | ||
| </ | </ | ||
| </ | </ | ||
| + | <!-- Tests d' | ||
| < | < | ||
| < | < | ||
| Ligne 1041: | Ligne 1099: | ||
| < | < | ||
| < | < | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | </ | ||
| </ | </ | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | < | ||
| + | </ | ||
| + | </ | ||
| + | </ | ||
| </ | </ | ||
| </ | </ | ||
| Ligne 1049: | Ligne 1119: | ||
| ==== 5.3 Badges pour le README ==== | ==== 5.3 Badges pour le README ==== | ||
| - | <sxh markdown; | + | <sxh markdown> |
| # E-Commerce API | # E-Commerce API | ||
|  |
| * Tests paramétrés pour au moins 1 cas | * Tests paramétrés pour au moins 1 cas | ||
| * Tous les tests passent ('' | * Tous les tests passent ('' | ||
| Ligne 1199: | Ligne 1283: | ||
| ^ Aspect ^ Test Unitaire ^ Test d' | ^ Aspect ^ Test Unitaire ^ Test d' | ||
| - | | **Vitesse** | ⚡ Très rapide (<10ms) | 🐌 Plus lent (100-500ms) | | + | | **Vitesse** | Très rapide (<10ms) | Plus lent (100-500ms) | |
| | **Base de données** | ❌ Non (mocks) | ✅ Oui (H2 en mémoire) | | | **Base de données** | ❌ Non (mocks) | ✅ Oui (H2 en mémoire) | | ||
| | **Contexte Spring** | ❌ Non | ✅ Oui (toute l'app) | | | **Contexte Spring** | ❌ Non | ✅ Oui (toute l'app) | | ||
| - | | **Annotations** | '' | + | | **Annotations** | Pas d'annotation Spring |
| + | | **Mocking** | MockK / Mockito | Vrai composants Spring | ||
| | **Ce qu'on teste** | Logique métier isolée | Flux complet de bout en bout | | | **Ce qu'on teste** | Logique métier isolée | Flux complet de bout en bout | | ||
| | **Quand ça échoue** | Bug dans la logique | Bug d' | | **Quand ça échoue** | Bug dans la logique | Bug d' | ||
| | **Commande Maven** | '' | | **Commande Maven** | '' | ||
| - | | **Fichier de tests** | '' | + | | **Fichier de tests** | '' |
| ===== Bonnes pratiques à retenir ===== | ===== Bonnes pratiques à retenir ===== | ||
| Ligne 1216: | Ligne 1301: | ||
| * **AAA Pattern** : Arrange, Act, Assert | * **AAA Pattern** : Arrange, Act, Assert | ||
| * **1 test = 1 comportement** : ne pas tester plusieurs choses | * **1 test = 1 comportement** : ne pas tester plusieurs choses | ||
| - | * **Nommage explicite** : '' | + | * **Nommage explicite** : backticks pour noms descriptifs en Kotlin |
| * **Mocks minimalistes** : seulement les dépendances nécessaires | * **Mocks minimalistes** : seulement les dépendances nécessaires | ||
| + | * **MockK pour Kotlin** : meilleure intégration que Mockito | ||
| ==== Tests d' | ==== Tests d' | ||
| Ligne 1225: | Ligne 1311: | ||
| * **Performance** : détecter les N+1 avec Hypersistence | * **Performance** : détecter les N+1 avec Hypersistence | ||
| * **Cas d' | * **Cas d' | ||
| + | * **DSL Kotlin** : utiliser les extensions MockMvc pour Kotlin | ||
| ==== CI/CD ==== | ==== CI/CD ==== | ||
| Ligne 1238: | Ligne 1325: | ||
| * [[https:// | * [[https:// | ||
| * [[https:// | * [[https:// | ||
| + | * [[https:// | ||
| * [[https:// | * [[https:// | ||
| * [[https:// | * [[https:// | ||
| * [[https:// | * [[https:// | ||
| * [[https:// | * [[https:// | ||
| - | * [[https:// | + | * [[https:// |
| + | * [[https:// | ||