| 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:45] – [Aide-mémoire : Différences clés] jcheron | eadl:bloc3:dev_av:td3 [2025/11/09 16:36] (Version actuelle) – jcheron |
|---|
| ====== Séance 3 - Tests et CI/CD (4h) ====== | ====== 3 - Tests et CI/CD ====== |
| |
| ===== Objectifs pédagogiques ===== | ===== Objectifs pédagogiques ===== |
| * Qui a terminé les associations Order/OrderItem/User ? | * Qui a terminé les associations Order/OrderItem/User ? |
| * Qui a résolu des problèmes N+1 ? | * Qui a résolu des problèmes N+1 ? |
| * **Décision :** Ceux qui ont fini peuvent commencer les tests, les autres finalisent le TD2 | * Ceux qui ont fini peuvent commencer les tests, les autres finalisent le TD2 |
| </WRAP> | </WRAP> |
| |
| </WRAP> | </WRAP> |
| |
| === Structure des fichiers === | === Profiles === |
| |
| <sxh;gutter:false> | La création de profiles permet de gérer des configurations différentes, et des fichiers de configuration spécifiques à chaque profile. |
| src/main/resources/ | |
| ├── application.properties # Configuration commune | === pom.xml - Configuration des profils Maven === |
| ├── application-dev.properties # Développement local | |
| ├── application-test.properties # Tests automatisés | <sxh xml> |
| └── application-prod.properties # Production | <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> |
| </sxh> | </sxh> |
| |
| === application.properties (commun) === | === application.properties (commun) === |
| |
| <sxh properties;gutter:false> | <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 |
| === application-dev.properties === | === application-dev.properties === |
| |
| <sxh properties;gutter:false> | <sxh properties> |
| # Base H2 fichier pour le dev | # Base H2 fichier pour le dev |
| spring.datasource.url=jdbc:h2:file:./data/ecommerce-dev | spring.datasource.url=jdbc:h2:file:./data/ecommerce-dev |
| === application-test.properties === | === application-test.properties === |
| |
| <sxh properties;gutter:false> | <sxh properties> |
| # Base H2 en mémoire pour les tests | # Base H2 en mémoire pour les tests |
| spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1 | spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1 |
| === application-prod.properties === | === application-prod.properties === |
| |
| <sxh properties;gutter:false> | <sxh properties> |
| # Base PostgreSQL (exemple) | # Base PostgreSQL (exemple) |
| spring.datasource.url=${DATABASE_URL} | spring.datasource.url=${DATABASE_URL} |
| ==== 1.2 Activation des profils ==== | ==== 1.2 Activation des profils ==== |
| |
| <sxh bash;gutter:false> | <sxh bash> |
| # Dans IntelliJ : Run Configuration > Active profiles: dev | # Dans IntelliJ : Run Configuration > Active profiles: dev |
| # Ou via variable d'environnement | # Ou via variable d'environnement |
| <groupId>org.springframework.boot</groupId> | <groupId>org.springframework.boot</groupId> |
| <artifactId>spring-boot-starter-test</artifactId> | <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> | <scope>test</scope> |
| </dependency> | </dependency> |
| ==== 2.2 Premier test simple : ProductService ==== | ==== 2.2 Premier test simple : ProductService ==== |
| |
| <sxh java> | <sxh kotlin> |
| 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 static org.assertj.core.api.Assertions.*; | import com.ecommerce.domain.Product |
| import static org.mockito.Mockito.*; | 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.* |
| |
| @ExtendWith(MockitoExtension.class) | |
| @DisplayName("ProductService - Unit Tests") | @DisplayName("ProductService - Unit Tests") |
| class ProductServiceTest { | class ProductServiceTest { |
| |
| @Mock | private lateinit var productRepository: ProductRepository |
| private ProductRepository productRepository; | private lateinit var productService: ProductService |
| | private lateinit var category: Category |
| |
| @InjectMocks | @BeforeEach |
| private ProductService productService; | fun setUp() { |
| | productRepository = mockk() |
| | productService = ProductService(productRepository) |
| | category = Category(name = "Electronics", description = "Electronic products") |
| | category.id = UUID.randomUUID() |
| | } |
| |
| @Test | @Test |
| @DisplayName("Should return product when it exists") | @DisplayName("Should create product with valid data") |
| void getProduct_WhenExists_ShouldReturnProduct() { | fun `createProduct with valid data should return product`() { |
| // Given (Arrange) | // Given |
| UUID productId = UUID.randomUUID(); | val productSlot = slot<Product>() |
| Category category = new Category("Electronics", "Devices"); | val savedProduct = Product( |
| Product expectedProduct = Product.builder() | name = "iPhone 15", |
| .id(productId) | price = BigDecimal("999.99"), |
| .name("iPhone") | stock = 10, |
| .price(new BigDecimal("999.99")) | category = category |
| .stock(10) | ).apply { id = UUID.randomUUID() } |
| .category(category) | |
| .build(); | |
| | |
| when(productRepository.findById(productId)) | |
| .thenReturn(Optional.of(expectedProduct)); | |
| |
| // When (Act) | every { productRepository.save(capture(productSlot)) } returns savedProduct |
| Product result = productService.getProduct(productId); | |
| |
| // Then (Assert) | // When |
| assertThat(result).isNotNull(); | val result = productService.createProduct( |
| assertThat(result.getName()).isEqualTo("iPhone"); | name = "iPhone 15", |
| assertThat(result.getPrice()).isEqualByComparingTo("999.99"); | price = BigDecimal("999.99"), |
| assertThat(result.getStock()).isEqualTo(10); | stock = 10, |
| | categoryId = category.id!! |
| verify(productRepository, times(1)).findById(productId); | ) |
| verifyNoMoreInteractions(productRepository); | |
| | // 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 | @Test |
| @DisplayName("Should throw exception when product not found") | @DisplayName("Should throw exception when product not found") |
| void getProduct_WhenNotExists_ShouldThrowException() { | fun `getProduct when not exists should throw exception`() { |
| // Given | // Given |
| UUID productId = UUID.randomUUID(); | val productId = UUID.randomUUID() |
| when(productRepository.findById(productId)) | every { productRepository.findById(productId) } returns Optional.empty() |
| .thenReturn(Optional.empty()); | |
| |
| // When & Then | // When & Then |
| assertThatThrownBy(() -> productService.getProduct(productId)) | assertThatThrownBy { productService.getProduct(productId) } |
| .isInstanceOf(ProductNotFoundException.class) | .isInstanceOf(ProductNotFoundException::class.java) |
| .hasMessageContaining(productId.toString()); | .hasMessageContaining(productId.toString()) |
| | |
| verify(productRepository).findById(productId); | |
| } | } |
| |
| @Test | @Test |
| @DisplayName("Should decrease stock when updating with negative quantity") | @DisplayName("Should decrease stock when sufficient") |
| void updateStock_WithNegativeQuantity_ShouldDecreaseStock() { | fun `decreaseStock with sufficient stock should update product`() { |
| // Given | // Given |
| UUID productId = UUID.randomUUID(); | val productId = UUID.randomUUID() |
| Product product = Product.builder() | val product = Product( |
| .id(productId) | name = "Test Product", |
| .name("Test Product") | price = BigDecimal("10.00"), |
| .price(BigDecimal.TEN) | stock = 10, |
| .stock(10) | category = category |
| .build(); | ).apply { id = productId } |
| | |
| when(productRepository.findById(productId)) | every { productRepository.findById(productId) } returns Optional.of(product) |
| .thenReturn(Optional.of(product)); | every { productRepository.save(any()) } returns product |
| when(productRepository.save(any(Product.class))) | |
| .thenReturn(product); | |
| |
| // When | // When |
| productService.updateStock(productId, -3); | 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("Should throw exception when insufficient stock") | @DisplayName("Should throw exception when insufficient stock") |
| void updateStock_WithInsufficientStock_ShouldThrowException() { | fun `decreaseStock with insufficient stock should throw exception`() { |
| // Given | // Given |
| UUID productId = UUID.randomUUID(); | val productId = UUID.randomUUID() |
| Product product = Product.builder() | val product = Product( |
| .id(productId) | name = "Test Product", |
| .name("Test Product") | price = BigDecimal("10.00"), |
| .price(BigDecimal.TEN) | stock = 2, |
| .stock(5) | category = category |
| .build(); | ).apply { id = productId } |
| | |
| when(productRepository.findById(productId)) | every { productRepository.findById(productId) } returns Optional.of(product) |
| .thenReturn(Optional.of(product)); | |
| |
| // When & Then | // When & Then |
| assertThatThrownBy(() -> productService.updateStock(productId, -10)) | assertThatThrownBy { productService.decreaseStock(productId, 5) } |
| .isInstanceOf(InsufficientStockException.class); | .isInstanceOf(InsufficientStockException::class.java) |
| | .hasMessageContaining("Insufficient stock") |
| verify(productRepository, never()).save(any()); | |
| } | } |
| |
| @Test | @Test |
| @DisplayName("Should increase stock when updating with positive quantity") | @DisplayName("Should update stock correctly") |
| void updateStock_WithPositiveQuantity_ShouldIncreaseStock() { | fun `updateStock should change product stock and save`() { |
| // Given | // Given |
| UUID productId = UUID.randomUUID(); | val productId = UUID.randomUUID() |
| Product product = Product.builder() | val product = Product( |
| .id(productId) | name = "Test Product", |
| .name("Test Product") | price = BigDecimal("10.00"), |
| .price(BigDecimal.TEN) | stock = 10, |
| .stock(10) | category = category |
| .build(); | ).apply { id = productId } |
| | |
| when(productRepository.findById(productId)) | every { productRepository.findById(productId) } returns Optional.of(product) |
| .thenReturn(Optional.of(product)); | every { productRepository.save(any()) } returns product |
| when(productRepository.save(any(Product.class))) | |
| .thenReturn(product); | |
| |
| // When | // When |
| productService.updateStock(productId, 5); | productService.updateStock(productId, 5) |
| |
| // Then | // Then |
| assertThat(product.getStock()).isEqualTo(15); | assertThat(product.stock).isEqualTo(15) |
| verify(productRepository).save(product); | verify(exactly = 1) { productRepository.save(product) } |
| } | } |
| } | } |
| ==== 2.3 Concepts clés ==== | ==== 2.3 Concepts clés ==== |
| |
| <sxh java;gutter:false> | <sxh kotlin> |
| // @Mock : Crée un faux objet (ne fait rien par défaut) | // MockK pour Kotlin (alternative à Mockito) |
| @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<ProductRepository>() |
| private ProductService productService; | |
| |
| // when(...).thenReturn(...) : Définit le comportement du mock | // Définir le comportement du mock |
| when(productRepository.findById(id)).thenReturn(Optional.of(product)); | every { repository.findById(id) } returns Optional.of(product) |
| | 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, times(1)).save(any()); | val productSlot = slot<Product>() |
| verify(productRepository, never()).delete(any()); | every { repository.save(capture(productSlot)) } returns product |
| |
| // assertThat(...) : Vérifie le résultat (AssertJ - plus lisible que assertEquals) | // 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 qu'une exception est levée | // AssertJ - Assertions plus lisibles |
| assertThatThrownBy(() -> service.doSomething()) | assertThat(result.stock).isEqualTo(7) |
| .isInstanceOf(MyException.class) | assertThat(result).isNotNull() |
| .hasMessage("Expected message"); | 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") |
| </sxh> | </sxh> |
| |
| ==== 2.4 Tests paramétrés (en plus) ==== | ==== 2.4 Tests paramétrés (en plus) ==== |
| |
| <sxh java> | <sxh kotlin> |
| 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("Should validate price is positive") | |
| @ValueSource(strings = {"-10.00", "-0.01", "0.00"}) | |
| void createProduct_WithInvalidPrice_ShouldThrowException(String price) { | |
| // Given | |
| CreateProductDto dto = new CreateProductDto(); | |
| dto.setName("Test"); | |
| dto.setPrice(new BigDecimal(price)); | |
| dto.setStock(10); | |
| |
| // When & Then | @ParameterizedTest |
| assertThatThrownBy(() -> productService.createProduct(dto)) | @DisplayName("Should validate price is positive") |
| .isInstanceOf(InvalidPriceException.class); | @ValueSource(strings = ["-10.00", "-0.01", "0.00"]) |
| } | fun `createProduct with invalid price should throw exception`(price: String) { |
| | // Given |
| | val productService = ProductService(mockk()) |
| |
| @ParameterizedTest | // When & Then |
| @DisplayName("Should calculate correct total for different quantities") | assertThatThrownBy { |
| @CsvSource({ | productService.createProduct( |
| "1, 10.00, 10.00", | name = "Test", |
| "2, 10.00, 20.00", | price = BigDecimal(price), |
| "5, 9.99, 49.95" | stock = 10, |
| }) | categoryId = UUID.randomUUID() |
| void calculateTotal_WithDifferentQuantities_ShouldReturnCorrectAmount( | ) |
| int quantity, | }.isInstanceOf(InvalidPriceException::class.java) |
| String unitPrice, | } |
| String expectedTotal | |
| ) { | |
| // Given | |
| Product product = Product.builder() | |
| .price(new BigDecimal(unitPrice)) | |
| .build(); | |
| |
| // When | @ParameterizedTest |
| BigDecimal total = productService.calculateTotal(product, quantity); | @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()) |
| |
| // Then | // When |
| assertThat(total).isEqualByComparingTo(expectedTotal); | val total = productService.calculateTotal(product, quantity) |
| | |
| | // Then |
| | assertThat(total).isEqualByComparingTo(expectedTotal) |
| | } |
| } | } |
| </sxh> | </sxh> |
| |
| **Template fourni :** | **Template fourni :** |
| <code java> | <sxh kotlin> |
| @ExtendWith(MockitoExtension.class) | |
| @DisplayName("UserService - Unit Tests") | @DisplayName("UserService - Unit Tests") |
| class UserServiceTest { | class UserServiceTest { |
| | |
| @Mock | private lateinit var userRepository: UserRepository |
| private UserRepository userRepository; | private lateinit var orderRepository: OrderRepository |
| | private lateinit var userService: UserService |
| | |
| @Mock | @BeforeEach |
| private OrderRepository orderRepository; | fun setUp() { |
| | userRepository = mockk() |
| @InjectMocks | orderRepository = mockk() |
| private UserService userService; | userService = UserService(userRepository, orderRepository) |
| | } |
| | |
| @Test | @Test |
| @DisplayName("Should create user with valid data") | @DisplayName("Should create user with valid data") |
| void createUser_WithValidData_ShouldReturnUser() { | fun `createUser with valid data should return user`() { |
| // Given | // Given |
| CreateUserDto dto = new CreateUserDto("John Doe", "john@example.com"); | val email = "john@example.com" |
| User user = User.builder() | val user = User(name = "John Doe", email = email).apply { |
| .id(UUID.randomUUID()) | id = UUID.randomUUID() |
| .name(dto.getName()) | } |
| .email(dto.getEmail()) | |
| .build(); | |
| | |
| when(userRepository.existsByEmail(dto.getEmail())).thenReturn(false); | every { userRepository.existsByEmail(email) } returns false |
| when(userRepository.save(any(User.class))).thenReturn(user); | every { userRepository.save(any()) } returns user |
| |
| // When | // When |
| User result = userService.createUser(dto); | val result = userService.createUser(name = "John Doe", email = email) |
| |
| // Then | // Then |
| assertThat(result).isNotNull(); | assertThat(result).isNotNull() |
| assertThat(result.getEmail()).isEqualTo("john@example.com"); | 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 :** |
| ==== 3.1 Configuration de base ==== | ==== 3.1 Configuration de base ==== |
| |
| <sxh java> | <sxh kotlin> |
| 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("test") | @ActiveProfiles("test") |
| @Transactional // Rollback automatique après chaque test | @Transactional |
| @DisplayName("ProductController - Integration Tests") | @DisplayName("ProductController - Integration Tests") |
| class ProductControllerIntegrationTest { | class ProductControllerIntegrationTest { |
| |
| @Autowired | @Autowired |
| private MockMvc mockMvc; | private lateinit var mockMvc: MockMvc |
| |
| @Autowired | @Autowired |
| private ProductRepository productRepository; | private lateinit var productRepository: ProductRepository |
| |
| @Autowired | @Autowired |
| private CategoryRepository categoryRepository; | private lateinit var categoryRepository: CategoryRepository |
| |
| private Category electronics; | private lateinit var electronics: Category |
| |
| @BeforeEach | @BeforeEach |
| void setUp() { | fun setUp() { |
| // Nettoyage (si @Transactional ne suffit pas) | productRepository.deleteAll() |
| productRepository.deleteAll(); | categoryRepository.deleteAll() |
| categoryRepository.deleteAll(); | |
| | |
| // Données de test | |
| electronics = categoryRepository.save( | electronics = categoryRepository.save( |
| new Category("Electronics", "Electronic devices") | Category( |
| ); | name = "Electronics", |
| | description = "Electronic products" |
| | ) |
| | ) |
| } | } |
| |
| @Test | @Test |
| @DisplayName("GET /products/{id} should return 200 when product exists") | @DisplayName("POST /products should create a new product") |
| void getProduct_WhenExists_ShouldReturn200() throws Exception { | fun `createProduct should return 201 with created product`() { |
| // Given | // Given |
| Product product = productRepository.save( | val requestBody = """ |
| Product.builder() | { |
| .name("iPhone 15") | "name": "iPhone 15", |
| .price(new BigDecimal("999.99")) | "price": 999.99, |
| .stock(50) | "stock": 10, |
| .category(electronics) | "categoryId": "${electronics.id}" |
| .build() | } |
| ); | """.trimIndent() |
| |
| // When & Then | // When & Then |
| mockMvc.perform(get("/products/{id}", product.getId())) | mockMvc.post("/products") { |
| .andDo(print()) // Affiche la requête/réponse (utile pour déboguer) | contentType = MediaType.APPLICATION_JSON |
| .andExpect(status().isOk()) | content = requestBody |
| .andExpect(content().contentType(MediaType.APPLICATION_JSON)) | }.andExpect { |
| .andExpect(jsonPath("$.id").value(product.getId().toString())) | status { isCreated() } |
| .andExpect(jsonPath("$.name").value("iPhone 15")) | jsonPath("$.name") { value("iPhone 15") } |
| .andExpect(jsonPath("$.price").value(999.99)) | jsonPath("$.price") { value(999.99) } |
| .andExpect(jsonPath("$.stock").value(50)) | jsonPath("$.stock") { value(10) } |
| .andExpect(jsonPath("$.category.name").value("Electronics")); | jsonPath("$.category.name") { value("Electronics") } |
| | } |
| } | } |
| |
| @Test | @Test |
| @DisplayName("GET /products/{id} should return 404 when product not found") | @DisplayName("GET /products/{id} should return product when exists") |
| void getProduct_WhenNotExists_ShouldReturn404() throws Exception { | 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 | // When & Then |
| mockMvc.perform(get("/products/{id}", "00000000-0000-0000-0000-000000000000")) | mockMvc.get("/products/${product.id}") |
| .andExpect(status().isNotFound()) | .andExpect { |
| .andExpect(jsonPath("$.message").exists()); | status { isOk() } |
| | jsonPath("$.id") { value(product.id.toString()) } |
| | jsonPath("$.name") { value("MacBook Pro") } |
| | jsonPath("$.price") { value(1999.99) } |
| | } |
| } | } |
| |
| @Test | @Test |
| @DisplayName("POST /products should return 201 with valid data") | @DisplayName("GET /products/{id} should return 404 when not found") |
| void createProduct_WithValidData_ShouldReturn201() throws Exception { | fun `getProduct when not exists should return 404`() { |
| // Given | // Given |
| String requestBody = """ | val nonExistentId = java.util.UUID.randomUUID() |
| { | |
| "name": "iPad Pro", | |
| "price": 799.99, | |
| "stock": 30, | |
| "categoryId": "%s" | |
| } | |
| """.formatted(electronics.getId()); | |
| |
| // When & Then | // When & Then |
| mockMvc.perform(post("/products") | mockMvc.get("/products/$nonExistentId") |
| .contentType(MediaType.APPLICATION_JSON) | .andExpect { |
| .content(requestBody)) | status { isNotFound() } |
| .andDo(print()) | } |
| .andExpect(status().isCreated()) | |
| .andExpect(header().exists("Location")) | |
| .andExpect(jsonPath("$.id").exists()) | |
| .andExpect(jsonPath("$.name").value("iPad Pro")) | |
| .andExpect(jsonPath("$.price").value(799.99)) | |
| .andExpect(jsonPath("$.stock").value(30)); | |
| } | } |
| |
| @Test | @Test |
| @DisplayName("POST /products should return 400 with invalid data") | @DisplayName("PUT /products/{id} should update product") |
| void createProduct_WithInvalidData_ShouldReturn400() throws Exception { | fun `updateProduct should return 200 with updated product`() { |
| // Given - prix négatif | // Given |
| String requestBody = """ | val product = productRepository.save( |
| | Product( |
| | name = "iPad", |
| | price = BigDecimal("699.99"), |
| | stock = 15, |
| | category = electronics |
| | ) |
| | ) |
| | |
| | val updateRequest = """ |
| { | { |
| "name": "Invalid Product", | "name": "iPad Pro", |
| "price": -10.00, | "price": 899.99, |
| "stock": 10, | "stock": 20 |
| "categoryId": "%s" | |
| } | } |
| """.formatted(electronics.getId()); | """.trimIndent() |
| |
| // When & Then | // When & Then |
| mockMvc.perform(post("/products") | mockMvc.put("/products/${product.id}") { |
| .contentType(MediaType.APPLICATION_JSON) | contentType = MediaType.APPLICATION_JSON |
| .content(requestBody)) | content = updateRequest |
| .andExpect(status().isBadRequest()) | }.andExpect { |
| .andExpect(jsonPath("$.errors").isArray()); | status { isOk() } |
| | jsonPath("$.name") { value("iPad Pro") } |
| | jsonPath("$.price") { value(899.99) } |
| | jsonPath("$.stock") { value(20) } |
| | } |
| } | } |
| |
| @Test | @Test |
| @DisplayName("PUT /products/{id}/stock should update stock correctly") | @DisplayName("DELETE /products/{id} should delete product") |
| void updateStock_WithValidQuantity_ShouldReturn200() throws Exception { | fun `deleteProduct should return 204`() { |
| // Given | // Given |
| Product product = productRepository.save( | val product = productRepository.save( |
| Product.builder() | Product( |
| .name("Test Product") | name = "AirPods", |
| .price(BigDecimal.TEN) | price = BigDecimal("199.99"), |
| .stock(10) | stock = 50, |
| .category(electronics) | category = electronics |
| .build() | ) |
| ); | ) |
| |
| String requestBody = """ | // When & Then |
| { | mockMvc.delete("/products/${product.id}") |
| "quantity": 5 | .andExpect { |
| | status { isNoContent() } |
| } | } |
| """; | |
| |
| // When & Then | // Verify deletion |
| mockMvc.perform(put("/products/{id}/stock", product.getId()) | mockMvc.get("/products/${product.id}") |
| .contentType(MediaType.APPLICATION_JSON) | .andExpect { |
| .content(requestBody)) | status { isNotFound() } |
| .andExpect(status().isOk()) | } |
| .andExpect(jsonPath("$.stock").value(15)); | |
| } | } |
| |
| @Test | @Test |
| @DisplayName("GET /products should return paginated list") | @DisplayName("GET /products should return paginated list") |
| void getProducts_ShouldReturnPaginatedList() throws Exception { | fun `getProducts should return paginated results`() { |
| // Given | // Given |
| productRepository.save(Product.builder() | productRepository.save( |
| .name("Product 1") | Product( |
| .price(BigDecimal.TEN) | name = "iPhone", |
| .stock(10) | price = BigDecimal("999"), |
| .category(electronics) | stock = 10, |
| .build()); | category = electronics |
| | ) |
| productRepository.save(Product.builder() | ) |
| .name("Product 2") | productRepository.save( |
| .price(BigDecimal.valueOf(20)) | Product( |
| .stock(20) | name = "MacBook", |
| .category(electronics) | price = BigDecimal("1999"), |
| .build()); | stock = 5, |
| | category = electronics |
| | ) |
| | ) |
| |
| // When & Then | // When & Then |
| mockMvc.perform(get("/products") | mockMvc.get("/products") { |
| .param("page", "0") | param("page", "0") |
| .param("size", "10")) | param("size", "10") |
| .andExpect(status().isOk()) | }.andExpect { |
| .andExpect(jsonPath("$.content").isArray()) | status { isOk() } |
| .andExpect(jsonPath("$.content", hasSize(2))) | jsonPath("$.content") { isArray() } |
| .andExpect(jsonPath("$.totalElements").value(2)); | jsonPath("$.content.length()") { value(2) } |
| | jsonPath("$.totalElements") { value(2) } |
| | } |
| } | } |
| |
| @Test | @Test |
| @DisplayName("GET /products should filter by category") | @DisplayName("GET /products should filter by category") |
| void getProducts_WithCategoryFilter_ShouldReturnFilteredList() throws Exception { | fun `getProducts with category filter should return filtered list`() { |
| // Given | // Given |
| Category books = categoryRepository.save(new Category("Books", "Books category")); | val books = categoryRepository.save( |
| | Category("Books", "Books category") |
| | ) |
| | |
| productRepository.save(Product.builder() | productRepository.save( |
| .name("iPhone") | Product( |
| .price(BigDecimal.valueOf(999)) | name = "iPhone", |
| .stock(10) | price = BigDecimal("999"), |
| .category(electronics) | stock = 10, |
| .build()); | category = electronics |
| | ) |
| | ) |
| | |
| productRepository.save(Product.builder() | productRepository.save( |
| .name("Java Book") | Product( |
| .price(BigDecimal.valueOf(50)) | name = "Java Book", |
| .stock(20) | price = BigDecimal("50"), |
| .category(books) | stock = 20, |
| .build()); | category = books |
| | ) |
| | ) |
| |
| // When & Then | // When & Then |
| mockMvc.perform(get("/products") | mockMvc.get("/products") { |
| .param("categoryId", electronics.getId().toString())) | param("categoryId", electronics.id.toString()) |
| .andExpect(status().isOk()) | }.andExpect { |
| .andExpect(jsonPath("$.content", hasSize(1))) | status { isOk() } |
| .andExpect(jsonPath("$.content[0].name").value("iPhone")); | jsonPath("$.content.length()") { value(1) } |
| | jsonPath("$.content[0].name") { value("iPhone") } |
| | } |
| } | } |
| } | } |
| ==== 3.2 Concepts clés ==== | ==== 3.2 Concepts clés ==== |
| |
| <sxh java;gutter:false> | <sxh kotlin> |
| // @SpringBootTest : Lance toute l'application Spring | // @SpringBootTest : Lance toute l'application Spring |
| @SpringBootTest | @SpringBootTest |
| @Transactional | @Transactional |
| |
| // MockMvc : Simule des requêtes HTTP sans démarrer le serveur | // MockMvc DSL Kotlin : Simule des requêtes HTTP sans démarrer le serveur |
| mockMvc.perform(get("/products/123")) | mockMvc.get("/products/123") |
| .andExpect(status().isOk()) | .andExpect { |
| .andExpect(jsonPath("$.name").value("iPhone")); | 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 : Parcourt la réponse JSON avec des expressions |
| jsonPath("$.category.name") // Objet imbriqué | jsonPath("$.category.name") // Objet imbriqué |
| jsonPath("$.items[0].name") // Premier élément d'un tableau | jsonPath("$.items[0].name") // Premier élément d'un tableau |
| jsonPath("$.items", hasSize(3)) // Taille du tableau | jsonPath("$.items.length()") // Taille du tableau |
| </sxh> | </sxh> |
| |
| ==== 3.3 Test avec détection N+1 ==== | ==== 3.3 Test avec détection N+1 ==== |
| |
| <sxh java> | <sxh kotlin> |
| import io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator; | import io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator |
| import static io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator.*; | import io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator.* |
| |
| @Test | @Test |
| @DisplayName("GET /users/{id}/orders should not trigger N+1 queries") | @DisplayName("GET /users/{id}/orders should not trigger N+1 queries") |
| void getUserOrders_ShouldNotTriggerNPlusOne() throws Exception { | fun `getUserOrders should not trigger NPlusOne`() { |
| // Given | // Given |
| User user = userRepository.save(new User("John", "john@example.com")); | val user = userRepository.save(User("John", "john@example.com")) |
| | |
| for (int i = 0; i < 5; i++) { | repeat(5) { |
| Order order = new Order(user); | val order = Order(user = user) |
| order.addItem(new OrderItem(product1, 1, product1.getPrice())); | order.addItem(OrderItem(product1, 1, product1.price)) |
| order.addItem(new OrderItem(product2, 2, product2.getPrice())); | order.addItem(OrderItem(product2, 2, product2.price)) |
| orderRepository.save(order); | orderRepository.save(order) |
| } | } |
| |
| // When | // When |
| SQLStatementCountValidator.reset(); | SQLStatementCountValidator.reset() |
| | |
| mockMvc.perform(get("/users/{id}/orders", user.getId())) | mockMvc.get("/users/${user.id}/orders") |
| .andExpect(status().isOk()) | .andExpect { |
| .andExpect(jsonPath("$", hasSize(5))) | status { isOk() } |
| .andExpect(jsonPath("$[0].items", hasSize(2))); | jsonPath("$.length()") { value(5) } |
| | jsonPath("$[0].items.length()") { value(2) } |
| | } |
| |
| // 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) |
| } | } |
| </sxh> | </sxh> |
| |
| <sxh xml> | <sxh xml> |
| <!-- pom.xml --> | |
| <build> | <build> |
| <plugins> | <plugins> |
| <!-- JaCoCo pour la couverture de code --> | <!-- JaCoCo Maven Plugin --> |
| <plugin> | <plugin> |
| <groupId>org.jacoco</groupId> | <groupId>org.jacoco</groupId> |
| <version>0.8.11</version> | <version>0.8.11</version> |
| <executions> | <executions> |
| | <!-- Préparation de l'agent JaCoCo --> |
| <execution> | <execution> |
| | <id>prepare-agent</id> |
| <goals> | <goals> |
| <goal>prepare-agent</goal> | <goal>prepare-agent</goal> |
| </goals> | </goals> |
| </execution> | </execution> |
| | |
| | <!-- Génération du rapport après les tests --> |
| <execution> | <execution> |
| <id>report</id> | <id>report</id> |
| </goals> | </goals> |
| </execution> | </execution> |
| | |
| | <!-- Vérification du seuil minimum de couverture --> |
| <execution> | <execution> |
| <id>jacoco-check</id> | <id>check</id> |
| <goals> | <goals> |
| <goal>check</goal> | <goal>check</goal> |
| </execution> | </execution> |
| </executions> | </executions> |
| </plugin> | |
| |
| <!-- Surefire pour les tests unitaires --> | |
| <plugin> | |
| <groupId>org.apache.maven.plugins</groupId> | |
| <artifactId>maven-surefire-plugin</artifactId> | |
| <version>3.2.2</version> | |
| <configuration> | <configuration> |
| <includes> | |
| <include>**/*Test.java</include> | |
| </includes> | |
| <excludes> | <excludes> |
| <exclude>**/*IntegrationTest.java</exclude> | <!-- Exclure les entités JPA --> |
| | <exclude>**/domain/**</exclude> |
| | <!-- Exclure les DTOs --> |
| | <exclude>**/dto/**</exclude> |
| | <!-- Exclure la classe main --> |
| | <exclude>**/EcommerceApplicationKt.class</exclude> |
| | <!-- Exclure les configurations --> |
| | <exclude>**/config/**</exclude> |
| </excludes> | </excludes> |
| </configuration> | </configuration> |
| </plugin> | |
| |
| <!-- Failsafe pour les tests d'intégration --> | |
| <plugin> | |
| <groupId>org.apache.maven.plugins</groupId> | |
| <artifactId>maven-failsafe-plugin</artifactId> | |
| <version>3.2.2</version> | |
| <configuration> | |
| <includes> | |
| <include>**/*IntegrationTest.java</include> | |
| </includes> | |
| </configuration> | |
| <executions> | |
| <execution> | |
| <goals> | |
| <goal>integration-test</goal> | |
| <goal>verify</goal> | |
| </goals> | |
| </execution> | |
| </executions> | |
| </plugin> | </plugin> |
| </plugins> | </plugins> |
| ==== 4.2 Commandes Maven ==== | ==== 4.2 Commandes Maven ==== |
| |
| <sxh bash;gutter:false> | <sxh bash> |
| # Tests unitaires uniquement (rapides) | # Exécuter les tests et générer le rapport |
| mvn clean test | |
| | |
| # Tests unitaires + rapport de couverture | |
| mvn clean test jacoco:report | mvn clean test jacoco:report |
| | |
| | # Vérifier que le seuil de couverture est atteint |
| | mvn jacoco:check |
| |
| # Tous les tests (unitaires + intégration) | # Tous les tests (unitaires + intégration) |
| ==== 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 : |
| <configuration> | * Entités JPA (''domain/*'') |
| <excludes> | * DTOs (''dto/*'') |
| <!-- Exclure les entités JPA --> | * Configuration (''config/*'') |
| <exclude>**/domain/**</exclude> | * Classe main (''*ApplicationKt.class'') |
| <!-- Exclure les DTOs --> | |
| <exclude>**/dto/**</exclude> | |
| <!-- Exclure la classe main --> | |
| <exclude>**/EcommerceApplication.class</exclude> | |
| </excludes> | |
| </configuration> | |
| </sxh> | |
| |
| <WRAP round bloc todo> | <WRAP round bloc todo> |
| |
| - name: Run unit tests | - name: Run unit tests |
| run: mvn clean test -P test | run: mvn clean test -DskipIntegrationTests |
| |
| - name: Upload test results | - name: Upload test results |
| path: target/surefire-reports/ | path: target/surefire-reports/ |
| |
| # Job 2 : Tests d'intégration (plus longs) | # Job 2 : Tests d'intégration |
| integration-tests: | integration-tests: |
| name: Integration Tests | name: Integration Tests |
| runs-on: ubuntu-latest | runs-on: ubuntu-latest |
| needs: unit-tests # Attend que les tests unitaires passent | needs: unit-tests |
| | |
| steps: | steps: |
| |
| - name: Run integration tests | - name: Run integration tests |
| run: mvn clean verify -P test -DskipUnitTests | run: mvn clean verify -DskipUnitTests |
| |
| - name: Upload test results | - name: Upload test results |
| path: target/failsafe-reports/ | path: target/failsafe-reports/ |
| |
| # Job 3 : Analyse de couverture | # Job 3 : Couverture de code |
| coverage: | code-coverage: |
| name: Code Coverage | name: Code Coverage |
| runs-on: ubuntu-latest | runs-on: ubuntu-latest |
| needs: integration-tests | needs: [unit-tests, integration-tests] |
| | |
| steps: | steps: |
| cache: maven | cache: maven |
| |
| - name: Generate coverage report | - name: Run tests with coverage |
| run: mvn clean verify jacoco:report -P test | run: mvn clean verify jacoco:report |
| |
| - name: Upload coverage to Codecov | - name: Check coverage threshold |
| uses: codecov/codecov-action@v3 | run: mvn jacoco:check |
| with: | |
| files: ./target/site/jacoco/jacoco.xml | |
| flags: unittests | |
| name: codecov-umbrella | |
| fail_ci_if_error: false | |
| |
| - name: Upload JaCoCo report | - name: Upload coverage report |
| uses: actions/upload-artifact@v3 | uses: actions/upload-artifact@v3 |
| with: | with: |
| name: jacoco-report | name: coverage-report |
| path: target/site/jacoco/ | path: target/site/jacoco/ |
| |
| # Job 4 : Build (optionnel - pour vérifier que l'app compile) | - name: Comment PR with coverage |
| | if: github.event_name == 'pull_request' |
| | uses: codecov/codecov-action@v3 |
| | with: |
| | files: target/site/jacoco/jacoco.xml |
| | fail_ci_if_error: true |
| | |
| | # Job 4 : Build et packaging |
| build: | build: |
| name: Build Application | name: Build Application |
| runs-on: ubuntu-latest | runs-on: ubuntu-latest |
| needs: coverage | needs: [unit-tests, integration-tests, code-coverage] |
| | |
| steps: | steps: |
| |
| - name: Build with Maven | - name: Build with Maven |
| run: mvn clean package -P prod -DskipTests | run: mvn clean package -DskipTests |
| |
| - name: Upload artifact | - name: Upload JAR |
| uses: actions/upload-artifact@v3 | uses: actions/upload-artifact@v3 |
| with: | with: |
| </sxh> | </sxh> |
| |
| ==== 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 --> | |
| <properties> | <properties> |
| <skipUnitTests>false</skipUnitTests> | <skipUnitTests>false</skipUnitTests> |
| <build> | <build> |
| <plugins> | <plugins> |
| | <!-- Tests unitaires avec Surefire --> |
| <plugin> | <plugin> |
| <groupId>org.apache.maven.plugins</groupId> | <groupId>org.apache.maven.plugins</groupId> |
| <configuration> | <configuration> |
| <skipTests>${skipUnitTests}</skipTests> | <skipTests>${skipUnitTests}</skipTests> |
| | <includes> |
| | <include>**/*Test.kt</include> |
| | <include>**/*Test.class</include> |
| | </includes> |
| | <excludes> |
| | <exclude>**/*IntegrationTest.kt</exclude> |
| | <exclude>**/*IntegrationTest.class</exclude> |
| | </excludes> |
| </configuration> | </configuration> |
| </plugin> | </plugin> |
| |
| | <!-- Tests d'intégration avec Failsafe --> |
| <plugin> | <plugin> |
| <groupId>org.apache.maven.plugins</groupId> | <groupId>org.apache.maven.plugins</groupId> |
| <configuration> | <configuration> |
| <skipTests>${skipIntegrationTests}</skipTests> | <skipTests>${skipIntegrationTests}</skipTests> |
| | <includes> |
| | <include>**/*IntegrationTest.kt</include> |
| | <include>**/*IntegrationTest.class</include> |
| | </includes> |
| </configuration> | </configuration> |
| | <executions> |
| | <execution> |
| | <goals> |
| | <goal>integration-test</goal> |
| | <goal>verify</goal> |
| | </goals> |
| | </execution> |
| | </executions> |
| </plugin> | </plugin> |
| </plugins> | </plugins> |
| ==== 5.3 Badges pour le README ==== | ==== 5.3 Badges pour le README ==== |
| |
| <sxh markdown;gutter:false> | <sxh markdown> |
| # E-Commerce API | # E-Commerce API |
| |
|  |  |
|  |  |
| |  |
| |  |
| |
| ## Description | ## Description |
| API REST pour un système e-commerce avec Spring Boot 3. | API REST pour un système e-commerce avec Spring Boot 3 et Kotlin. |
| |
| ## Badges de statut | ## Badges de statut |
| mvn test jacoco:report | mvn test jacoco:report |
| ``` | ``` |
| | |
| | ## Technologies |
| | |
| | - Kotlin 1.9.22 |
| | - Spring Boot 3.2.0 |
| | - Spring Data JPA |
| | - H2 Database |
| | - JUnit 5 |
| | - MockK |
| | - JaCoCo |
| </sxh> | </sxh> |
| |
| ===== Récapitulatif des commandes ===== | ===== Récapitulatif des commandes ===== |
| |
| <sxh bash;gutter:false> | <sxh bash> |
| # ========== Développement local ========== | # ========== Développement local ========== |
| |
| * ''ProductServiceTest'' complet (≥5 tests) | * ''ProductServiceTest'' complet (≥5 tests) |
| * ''UserServiceTest'' complet (≥5 tests) | * ''UserServiceTest'' complet (≥5 tests) |
| * Utilisation correcte des mocks | * Utilisation correcte des mocks (MockK) |
| * Tests paramétrés pour au moins 1 cas | * Tests paramétrés pour au moins 1 cas |
| * Tous les tests passent (''mvn test'') | * Tous les tests passent (''mvn test'') |
| | **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** | ''@ExtendWith(MockitoExtension.class)'' | ''@SpringBootTest'' | | | **Annotations** | Pas d'annotation Spring | ''@SpringBootTest'' | |
| | | **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'intégration/config | | | **Quand ça échoue** | Bug dans la logique | Bug d'intégration/config | |
| | **Commande Maven** | ''mvn test'' | ''mvn verify'' | | | **Commande Maven** | ''mvn test'' | ''mvn verify'' | |
| | **Fichier de tests** | ''*Test.java'' | ''*IntegrationTest.java'' | | | **Fichier de tests** | ''*Test.kt'' | ''*IntegrationTest.kt'' | |
| |
| ===== Bonnes pratiques à retenir ===== | ===== Bonnes pratiques à retenir ===== |
| * **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** : ''methodName_WhenCondition_ShouldExpectedBehavior'' | * **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'intégration ==== | ==== Tests d'intégration ==== |
| * **Performance** : détecter les N+1 avec Hypersistence | * **Performance** : détecter les N+1 avec Hypersistence |
| * **Cas d'erreur** : tester les 400, 404, 409, 500 | * **Cas d'erreur** : tester les 400, 404, 409, 500 |
| | * **DSL Kotlin** : utiliser les extensions MockMvc pour Kotlin |
| |
| ==== CI/CD ==== | ==== CI/CD ==== |
| * [[https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.testing|Spring Boot Testing - Documentation officielle]] | * [[https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.testing|Spring Boot Testing - Documentation officielle]] |
| * [[https://junit.org/junit5/docs/current/user-guide/|JUnit 5 User Guide]] | * [[https://junit.org/junit5/docs/current/user-guide/|JUnit 5 User Guide]] |
| | * [[https://mockk.io/|MockK Documentation (Kotlin)]] |
| * [[https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html|Mockito Documentation]] | * [[https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html|Mockito Documentation]] |
| * [[https://assertj.github.io/doc/|AssertJ Documentation]] | * [[https://assertj.github.io/doc/|AssertJ Documentation]] |
| * [[https://www.jacoco.org/jacoco/trunk/doc/maven.html|JaCoCo Maven Plugin]] | * [[https://www.jacoco.org/jacoco/trunk/doc/maven.html|JaCoCo Maven Plugin]] |
| * [[https://docs.github.com/en/actions|GitHub Actions - Documentation]] | * [[https://docs.github.com/en/actions|GitHub Actions - Documentation]] |
| * [[https://www.baeldung.com/spring-boot-testing|Baeldung - Testing in Spring Boot]] | * [[https://www.baeldung.com/kotlin/spring-boot-testing|Baeldung - Testing in Spring Boot with Kotlin]] |
| | * [[https://kotlinlang.org/docs/jvm-test-using-junit.html|Kotlin Testing with JUnit]] |