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