MFormations
Modern Java Engineering

Chapitre 10

10 - Testing Java

10 - Testing Java

Cours 10 : Testing Java

1. Introduction aux tests Java

1.1 La pyramide des tests

         /\
        /  \         Tests E2E (lents, fragiles)
       /    \
      /──────\       Tests d'intégration
     /        \
    /──────────\     Tests unitaires (rapides, nombreux)
   /            \

1.2 Pourquoi tester ?

  • Qualité : Détecter les bugs avant la mise en production
  • Confiance : Pouvoir refactorer sans peur
  • Documentation : Les tests décrivent le comportement attendu
  • Design : Un code testable est souvent mieux conçu

1.3 Types de tests en Java

  • Tests unitaires : Testent une unité de code isolément
  • Tests d'intégration : Testent l'interaction entre plusieurs composants
  • Tests fonctionnels : Testent un cas d'utilisation complet
  • Tests de performance : Mesurent les performances (JMH)
  • Tests d'architecture : Vérifient la structure du code (ArchUnit)

2. JUnit 5

2.1 Architecture de JUnit 5

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

2.2 Annotations principales

class OrderServiceTest {
    
    @BeforeAll
    static void initAll() { /* Une fois avant tous les tests */ }
    
    @BeforeEach
    void init() { /* Avant chaque test */ }
    
    @Test
    void shouldCreateOrder() {
        // Test standard
    }
    
    @Test
    @DisplayName("La création d'une commande sans produit doit lever une exception")
    void shouldThrowExceptionWhenOrderEmpty() {
        // Test avec nom lisible
    }
    
    @Test
    @Disabled("En attendant la correction du bug #42")
    void notReady() { /* Test désactivé */ }
    
    @AfterEach
    void tearDown() { /* Après chaque test */ }
    
    @AfterAll
    static void tearDownAll() { /* Une fois après tous les tests */ }
}

2.3 Assertions

@Test
void standardAssertions() {
    assertEquals(4, calculator.add(2, 2));
    assertTrue(server.isRunning());
    assertFalse(queue.isEmpty());
    assertNull(cache.get("unknown"));
    assertNotNull(order.getId());
    assertThrows(IllegalArgumentException.class, () -> new Money(-1, "EUR"));
    assertTimeout(ofSeconds(2), () -> service.processOrder(order));
}

2.4 Tests paramétrés

@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "level" })
void shouldDetectPalindromes(String word) {
    assertTrue(PalindromeChecker.isPalindrome(word));
}

@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "2, 3, 5",
    "10, 20, 30"
})
void shouldAddNumbers(int a, int b, int expected) {
    assertEquals(expected, calculator.add(a, b));
}

@ParameterizedTest
@MethodSource("provideOrders")
void shouldCalculateTotal(Order order, Money expectedTotal) {
    assertEquals(expectedTotal, order.getTotalAmount());
}

static Stream<Arguments> provideOrders() {
    return Stream.of(
        Arguments.of(new Order(/*...*/), new Money(100, "EUR")),
        Arguments.of(new Order(/*...*/), new Money(250, "EUR"))
    );
}

@ParameterizedTest
@EnumSource(OrderStatus.class)
void shouldRecognizeAllStatuses(OrderStatus status) {
    assertNotNull(status.name());
}

2.5 Extensions

@ExtendWith(SpringExtension.class)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    // Extension personnalisée
}

// Extension personnalisée
public class DatabaseExtension implements BeforeEachCallback, AfterEachCallback {
    @Override
    public void beforeEach(ExtensionContext context) {
        DatabaseManager.start();
    }
    
    @Override
    public void afterEach(ExtensionContext context) {
        DatabaseManager.stop();
    }
}

2.6 Tests conditionnels

@Test
@EnabledOnOs(OS.WINDOWS)
void onlyOnWindows() { /* ... */ }

@Test
@EnabledOnJre(JRE.JAVA_17)
void onlyOnJava17() { /* ... */ }

@Test
@EnabledIfSystemProperty(named = "env", matches = "ci")
void onlyOnCI() { /* ... */ }

@Test
@EnabledIfEnvironmentVariable(named = "DATABASE_URL", matches = ".*localhost.*")
void onlyWithLocalDb() { /* ... */ }

3. AssertJ

3.1 Principe

AssertJ fournit des assertions fluides avec une auto-complétion exceptionnelle.

3.2 Assertions de base

import static org.assertj.core.api.Assertions.*;

@Test
void basicAssertions() {
    assertThat("Hello World")
        .isNotNull()
        .isNotEmpty()
        .startsWith("Hello")
        .endsWith("World")
        .contains(" ")
        .hasSize(11);
    
    assertThat(42)
        .isGreaterThan(10)
        .isLessThan(100)
        .isBetween(0, 50)
        .isPositive();
}

3.3 Assertions sur les collections

@Test
void collectionAssertions() {
    List<String> items = List.of("apple", "banana", "cherry");
    
    assertThat(items)
        .hasSize(3)
        .contains("banana")
        .doesNotContain("date")
        .containsExactly("apple", "banana", "cherry")
        .anyMatch(s -> s.startsWith("b"))
        .allMatch(s -> s.length() > 3)
        .filteredOn(s -> s.startsWith("a"))
        .hasSize(1);
}

3.4 Assertions sur les exceptions

@Test
void exceptionAssertions() {
    assertThatThrownBy(() -> order.submit())
        .isInstanceOf(EmptyOrderException.class)
        .hasMessage("Cannot submit empty order")
        .hasNoCause();
    
    assertThatExceptionOfType(InvalidOrderStateException.class)
        .isThrownBy(() -> order.addProduct(product))
        .withMessageContaining("DRAFT");
}

3.5 Assertions sur les objets

@Test
void objectAssertions() {
    Order order1 = new Order(customerId);
    Order order2 = new Order(customerId);
    
    assertThat(order1)
        .usingRecursiveComparison()
        .isNotEqualTo(order2); // Différents IDs
    
    assertThat(order1)
        .extracting(Order::getStatus)
        .isEqualTo(OrderStatus.DRAFT);
    
    assertThat(order1)
        .hasFieldOrPropertyWithValue("status", OrderStatus.DRAFT);
}

3.6 Soft assertions

@Test
void softAssertions() {
    SoftAssertions softly = new SoftAssertions();
    
    softly.assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
    softly.assertThat(order.getLines()).hasSize(2);
    softly.assertThat(order.getTotalAmount()).isEqualTo(new Money(100, "EUR"));
    
    softly.assertAll(); // Vérifie toutes les assertions
}

4. Mockito

4.1 Configuration

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    
    @Mock
    private OrderRepository orderRepository;
    
    @Mock
    private ProductRepository productRepository;
    
    @InjectMocks
    private OrderService orderService;
    
    // Alternative : création manuelle
    @BeforeEach
    void setUp() {
        orderRepository = mock(OrderRepository.class);
        productRepository = mock(ProductRepository.class);
        orderService = new OrderService(orderRepository, productRepository);
    }
}

4.2 Stubbing

@Test
void shouldCreateOrder() {
    // Given
    Product product = new Product("Laptop", new Money(1000, "EUR"));
    when(productRepository.findById(any(ProductId.class)))
        .thenReturn(Optional.of(product));
    
    when(orderRepository.save(any(Order.class)))
        .thenAnswer(invocation -> invocation.getArgument(0));
    
    // When
    Order order = orderService.createOrder(customerId, List.of(product.getId()));
    
    // Then
    assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
}

4.3 Vérification

@Test
void shouldVerifyInteractions() {
    orderService.deleteOrder(orderId);
    
    verify(orderRepository).delete(orderId);
    verify(orderRepository, times(1)).delete(any(OrderId.class));
    verify(orderRepository, never()).save(any());
    verifyNoMoreInteractions(orderRepository);
    
    verify(eventPublisher, timeout(1000)).publish(any(DomainEvent.class));
}

4.4 Argument Captor

@Test
void shouldCaptureDomainEvent() {
    // When
    orderService.submitOrder(orderId);
    
    // Then
    ArgumentCaptor<DomainEvent> eventCaptor = ArgumentCaptor.forClass(DomainEvent.class);
    verify(eventPublisher).publish(eventCaptor.capture());
    
    DomainEvent event = eventCaptor.getValue();
    assertThat(event).isInstanceOf(OrderSubmittedEvent.class);
    OrderSubmittedEvent submittedEvent = (OrderSubmittedEvent) event;
    assertThat(submittedEvent.orderId()).isEqualTo(orderId);
}

4.5 Mock avancé

@Test
void shouldUseAnswer() {
    when(productRepository.findById(any()))
        .thenAnswer(invocation -> {
            ProductId id = invocation.getArgument(0);
            if (id.value().toString().startsWith("P1")) {
                return Optional.of(new Product(id, "Special", new Money(100, "EUR")));
            }
            return Optional.empty();
        });
}

@Test
void shouldMockVoidMethod() {
    doThrow(new DatabaseException("Connection failed"))
        .when(orderRepository).save(any(Order.class));
    
    assertThatThrownBy(() -> orderService.createOrder(customerId, items))
        .isInstanceOf(DatabaseException.class);
}

@Test
void shouldSpy() {
    Order realOrder = new Order(customerId);
    Order spyOrder = spy(realOrder);
    
    when(spyOrder.getStatus()).thenReturn(OrderStatus.CONFIRMED);
    
    assertThat(spyOrder.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
}

5. Testcontainers

5.1 Principe

Testcontainers permet de lancer des conteneurs Docker pour les tests d'intégration.

5.2 PostgreSQL

@Testcontainers
@SpringBootTest
class OrderRepositoryTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");
    
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Test
    void shouldSaveAndFindOrder() {
        Order order = new Order(customerId);
        order = orderRepository.save(order);
        
        Optional<Order> found = orderRepository.findById(order.getId());
        assertThat(found).isPresent();
        assertThat(found.get().getStatus()).isEqualTo(OrderStatus.DRAFT);
    }
}

5.3 Kafka

@Testcontainers
@SpringBootTest
class OrderEventPublisherTest {
    
    @Container
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
    
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
    }
    
    @Autowired
    private OrderEventPublisher eventPublisher;
    
    @Autowired
    private KafkaConsumer<String, String> consumer;
    
    @Test
    void shouldPublishOrderCreatedEvent() {
        eventPublisher.publish(new OrderCreatedEvent(orderId, customerId));
        
        // Vérifier que l'événement a été publié sur Kafka
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));
        assertThat(records).hasSize(1);
    }
}

5.4 Redis

@Testcontainers
@SpringBootTest
class OrderCacheTest {
    
    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);
    
    @BeforeAll
    static void init() {
        System.setProperty("spring.data.redis.host", redis.getHost());
        System.setProperty("spring.data.redis.port", redis.getMappedPort(6379).toString());
    }
    
    @Test
    void shouldCacheOrder() {
        // Test de cache
    }
}

5.5 Modules spécialisés

// Testcontainers pour modules spécialisés
@Container
static MongoDBContainer mongo = new MongoDBContainer("mongo:7");

@Container
static Neo4jContainer<?> neo4j = new Neo4jContainer<>("neo4j:5");

@Container
static LocalStackContainer localstack = new LocalStackContainer(
    DockerImageName.parse("localstack/localstack:3.0"))
    .withServices(S3, SQS);

6. ArchUnit

6.1 Vérifications de base

@AnalyzeClasses(packages = "com.orderhub")
class ArchitectureTest {
    
    @Test
    void ensureDomainDoesNotDependOnSpring() {
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("org.springframework..")
            .check(importedClasses());
    }
    
    @Test
    void ensureNoCyclicDependencies() {
        slices()
            .matching("com.orderhub.(*)..")
            .should().beFreeOfCycles()
            .check(importedClasses());
    }
}

6.2 Vérifications de nommage

@Test
void ensureClassesAreNamedCorrectly() {
    classes()
        .that().resideInAPackage("..service..")
        .should().haveSimpleNameEndingWith("Service")
        .check(importedClasses());
    
    classes()
        .that().resideInAPackage("..controller..")
        .should().haveSimpleNameEndingWith("Controller")
        .check(importedClasses());
    
    classes()
        .that().areAnnotatedWith(RestController.class)
        .should().haveSimpleNameEndingWith("Controller")
        .check(importedClasses());
}

7. Cucumber (BDD)

7.1 Structure d'un feature file

Feature: Order Management
  As a customer
  I want to create and manage orders
  So that I can purchase products

  Background:
    Given a customer with ID "CUST-001"
    And a product "Laptop" costing 1000 EUR

  Scenario: Create a valid order
    When I create an order with 2 "Laptop"
    Then the order should be created
    And the order total should be 2000 EUR
    And the order status should be "SUBMITTED"

  Scenario: Try to create empty order
    When I try to create an order without products
    Then the system should reject the order
    And an error message "Cannot submit empty order" should be shown

7.2 Step definitions

@SpringBootTest
@CucumberContextConfiguration
public class OrderStepDefinitions {
    
    @Autowired
    private OrderService orderService;
    
    private CreateOrderResult result;
    private Throwable exception;
    
    @Given("a customer with ID {string}")
    public void aCustomerWithId(String customerId) {
        // Setup customer
    }
    
    @Given("a product {string} costing {double} EUR")
    public void aProductWithPrice(String productName, double price) {
        // Setup product
    }
    
    @When("I create an order with {int} {string}")
    public void createOrder(int quantity, String productName) {
        result = orderService.createOrder(customerId, List.of(new OrderItem(productName, quantity)));
    }
    
    @When("I try to create an order without products")
    public void createEmptyOrder() {
        try {
            orderService.createOrder(customerId, List.of());
        } catch (Exception e) {
            exception = e;
        }
    }
    
    @Then("the order should be created")
    public void orderCreated() {
        assertThat(result).isNotNull();
        assertThat(result.order()).isNotNull();
    }
    
    @Then("the order total should be {double} EUR")
    public void verifyTotal(double expected) {
        assertThat(result.order().getTotalAmount())
            .isEqualTo(new Money(BigDecimal.valueOf(expected), Currency.getInstance("EUR")));
    }
    
    @Then("the system should reject the order")
    public void verifyRejection() {
        assertThat(exception).isNotNull();
    }
}

7.3 Tags et rapports

@smoke @regression
Feature: Quick smoke tests

@slow @performance
Feature: Performance tests

8. JMH (Java Microbenchmark Harness)

8.1 Configuration Maven

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.37</version>
    <scope>test</scope>
</dependency>

8.2 Benchmark basique

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class OrderBenchmark {
    
    private Order order;
    private Product product;
    
    @Setup
    public void setup() {
        order = new Order(new CustomerId(UUID.randomUUID()));
        product = new Product("Laptop", new Money(1000, "EUR"));
    }
    
    @Benchmark
    public void addProduct() {
        order.addProduct(product.getId(), product.getName(), product.getPrice(), 1);
    }
    
    @Benchmark
    public void submitOrder(Blackhole blackhole) {
        order.submit();
        blackhole.consume(order.getStatus());
    }
    
    public static void main(String[] args) throws Exception {
        Options opt = new OptionsBuilder()
            .include(OrderBenchmark.class.getSimpleName())
            .forks(1)
            .warmupIterations(5)
            .measurementIterations(5)
            .build();
        new Runner(opt).run();
    }
}

9. Spring Boot Test Slicing

9.1 Slices disponibles

@WebMvcTest(OrderController.class)  // Contrôleurs MVC
@JsonTest                            // Sérialisation JSON
@DataJpaTest                         // Repositories JPA
@JdbcTest                            // Repositories JDBC
@DataRedisTest                       // Redis
@DataMongoTest                       // MongoDB
@RestClientTest                      // Clients REST

9.2 @WebMvcTest

@WebMvcTest(OrderController.class)
class OrderControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private CreateOrderUseCase createOrderUseCase;
    
    @Test
    void shouldCreateOrder() throws Exception {
        Order order = new Order(new CustomerId(UUID.randomUUID()));
        when(createOrderUseCase.createOrder(any())).thenReturn(order);
        
        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {
                        "customerId": "c1",
                        "items": [{"productId": "p1", "quantity": 2}]
                    }
                    """))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").isNotEmpty())
            .andExpect(jsonPath("$.status").value("DRAFT"));
    }
}

9.3 @DataJpaTest

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryJpaTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private SpringDataJpaOrderRepository repository;
    
    @Test
    void shouldSaveAndFindOrder() {
        OrderEntity entity = new OrderEntity();
        entity.setCustomerId(UUID.randomUUID().toString());
        entity.setStatus("DRAFT");
        
        OrderEntity saved = entityManager.persistAndFlush(entity);
        
        Optional<OrderEntity> found = repository.findById(saved.getId());
        assertThat(found).isPresent();
    }
}

Points clés à retenir

  • JUnit 5 est le framework de test standard avec des extensions puissantes
  • AssertJ rend les assertions lisibles et expressives
  • Mockito isole le code testé de ses dépendances
  • Testcontainers permet des tests d'intégration réalistes
  • ArchUnit vérifie les règles architecturales
  • Cucumber facilite la collaboration métier/technique
  • JMH mesure les performances avec précision
  • Les slices Spring Boot optimisent les tests