Hey! Stai per immergerti nel fantastico mondo del testing automatico, specificamente pensato per le applicazioni backend sviluppate con Spring Boot e Java 21. Preparati a un viaggio che trasformerà il tuo codice da "sembra funzionare" a "sono dannatamente sicuro che funzioni".
Il testing automatico è quel processo magico che verifica in modo sistematico e ripetibile il corretto funzionamento del tuo codice. Non si tratta semplicemente di far correre qualche test prima di rilasciare in produzione, ma di integrare una vera e propria cultura della qualità nel processo di sviluppo.
Parliamoci chiaro: nessuno vuole essere svegliato alle 3 di notte perché qualcosa è esploso in produzione. Ecco perché il testing automatico è cruciale:
La piramide del testing è un modello concettuale che ci aiuta a capire come strutturare efficacemente i nostri test. È stata proposta inizialmente da Mike Cohn nel suo libro "Succeeding with Agile" e rimane fondamentale per organizzare una strategia di testing efficace.
Figure 1: La Piramide del Testing
Come puoi vedere nell'immagine, la piramide è suddivisa in tre livelli principali:
La forma a piramide non è casuale: dovremmo avere molti test unitari, un numero moderato di test di integrazione e pochi test end-to-end. Questo approccio ci permette di massimizzare l'efficacia dei nostri test mantenendo tempi di esecuzione ragionevoli.
Per le applicazioni Spring Boot con Java 21, abbiamo a disposizione un arsenale completo di strumenti per il testing. Ecco i principali che utilizzeremo:
Non preoccuparti se questi nomi ti sembrano intimidatori. Li esploreremo uno per uno nelle prossime sessioni, vedrai che diventeranno presto i tuoi migliori amici nello sviluppo di applicazioni robuste e affidabili.
Per capire meglio le differenze tra i vari tipi di test, facciamo un esempio concreto. Immagina di sviluppare un'applicazione per la gestione degli ordini in un e-commerce:
Test Unitari:
@Test void calcolaTotaleOrdine_conDueArticoli_restituisceSommaPrezzi() { // Dato un ordine con due articoli Articolo articolo1 = new Articolo("Libro", 10.0); Articolo articolo2 = new Articolo("Penna", 2.0); List<Articolo> articoli = List.of(articolo1, articolo2); // Quando calcolo il totale double totale = calcolatorePrezzi.calcolaTotale(articoli); // Allora il risultato deve essere la somma dei prezzi assertThat(totale).isEqualTo(12.0); }
Test di Integrazione:
@SpringBootTest class OrdineServiceIntegrationTest { @Autowired private OrdineService ordineService; @Autowired private OrdineRepository ordineRepository; @Test void creaOrdine_salvaNelDatabase() { // Dato un nuovo ordine OrdineDto ordineDto = new OrdineDto("cliente1", List.of( new ArticoloDto("Libro", 10.0), new ArticoloDto("Penna", 2.0) )); // Quando creo l'ordine Ordine ordineCreato = ordineService.creaOrdine(ordineDto); // Allora l'ordine viene salvato nel database Ordine ordineDalDb = ordineRepository.findById(ordineCreato.getId()).orElseThrow(); assertThat(ordineDalDb).isNotNull(); assertThat(ordineDalDb.getTotale()).isEqualTo(12.0); } }
Test End-to-End:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class OrdineControllerE2ETest { @Autowired private TestRestTemplate restTemplate; @Test void creazioneOrdineCompletaFlusso() { // Dato un nuovo ordine OrdineDto ordineDto = new OrdineDto("cliente1", List.of( new ArticoloDto("Libro", 10.0), new ArticoloDto("Penna", 2.0) )); // Quando invio la richiesta REST per creare l'ordine ResponseEntity<OrdineResponseDto> response = restTemplate.postForEntity( "/api/ordini", ordineDto, OrdineResponseDto.class); // Allora l'ordine viene creato correttamente assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(response.getBody().getTotale()).isEqualTo(12.0); // E posso verificare l'ordine creato ResponseEntity<OrdineResponseDto> ordineRecuperato = restTemplate.getForEntity( "/api/ordini/{id}", OrdineResponseDto.class, response.getBody().getId()); assertThat(ordineRecuperato.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(ordineRecuperato.getBody().getArticoli()).hasSize(2); } }
Come vedi, man mano che saliamo nella piramide, i test diventano più complessi e testano più parti del sistema insieme. Ma tutti hanno il loro ruolo fondamentale nella verifica della correttezza del nostro codice.
Ok, adesso entriamo nel vivo della questione ed esploriamo in dettaglio le diverse tipologie di test automatici che possiamo implementare nelle nostre applicazioni Spring Boot con Java 21. Ogni tipologia ha le sue peculiarità, i suoi punti di forza e i suoi casi d'uso ideali.
I test unitari sono i mattoncini LEGO della tua strategia di testing. Piccoli, precisi e fondamentali.
Un test unitario verifica il corretto funzionamento di una singola "unità" di codice in isolamento dal resto del sistema. Un'unità è tipicamente un metodo, una classe o un gruppo di classi strettamente correlate. Lo scopo principale è assicurarsi che ogni componente atomico funzioni correttamente secondo le specifiche.
Un buon test unitario segue lo schema AAA:
@ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository userRepository; @InjectMocks private UserService userService; @Test void findById_dovrebbeRestituireUtente_quandoEsiste() { // Arrange Long userId = 1L; User expectedUser = new User(userId, "test@example.com"); when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser)); // Act User result = userService.findById(userId); // Assert assertThat(result).isNotNull(); assertThat(result.getId()).isEqualTo(userId); assertThat(result.getEmail()).isEqualTo("test@example.com"); } }
In questo esempio, stiamo testando il metodo `findById` del nostro `UserService`. Nota come utilizziamo Mockito per simulare il comportamento del `UserRepository`, isolando completamente il service dal repository reale.
I test unitari sono particolarmente utili per:
Se i test unitari verificano che i singoli pezzi funzionino correttamente, i test di integrazione verificano che questi pezzi funzionino bene insieme. È come passare dal testare i singoli ingredienti al testare la ricetta completa.
I test di integrazione verificano il corretto funzionamento dell'interazione tra diversi componenti o moduli dell'applicazione. Lo scopo è assicurarsi che le diverse parti dell'applicazione si integrino correttamente.
Spring Boot offre diverse soluzioni per i test di integrazione:
In questo esempio, stiamo testando l'integrazione tra il `UserService` e il `UserRepository` reale. Il test verifica che l'utente venga correttamente salvato nel database.
@SpringBootTest class UserIntegrationTest { @Autowired private UserService userService; @Autowired private UserRepository userRepository; @BeforeEach void setup() { userRepository.deleteAll(); } @Test void createUser_dovrebbeSalvareUtente() { // Arrange UserDto userDto = new UserDto(null, "test@example.com", "password"); // Act User savedUser = userService.createUser(userDto); // Assert assertThat(savedUser).isNotNull(); assertThat(savedUser.getId()).isNotNull(); assertThat(savedUser.getEmail()).isEqualTo("test@example.com"); // Verifica nel database Optional<User> fromDb = userRepository.findById(savedUser.getId()); assertThat(fromDb).isPresent(); } }
I test di integrazione sono particolarmente utili per:
I test delle slices rappresentano un approccio intelligente introdotto da Spring Boot: testare "fette" specifiche dell'applicazione senza caricare l'intero contesto. È come testare singoli capitoli di un libro anziché l'intero volume.
I test delle slices permettono di testare un componente specifico dell'applicazione caricando solo le parti necessarie del contesto Spring, migliorando le performance e l'isolamento dei test.
In questo esempio, stiamo testando solo il layer dei controller, utilizzando un mock per il `UserService`. Questo approccio è molto più leggero rispetto a caricare l'intero contesto Spring.
@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void getUserById_dovrebbeRestituireUtente() throws Exception { // Arrange Long userId = 1L; User user = new User(userId, "test@example.com"); when(userService.findById(userId)).thenReturn(user); // Act & Assert mockMvc.perform(get("/api/users/{id}", userId) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(userId)) .andExpect(jsonPath("$.email").value("test@example.com")); } }
I test delle slices sono particolarmente utili per:
I test dei contract assicurano che produttori e consumatori di API rispettino un "contratto" predefinito. È come verificare che due persone parlino la stessa lingua prima di iniziare una conversazione.
I test dei contract verificano che le interfacce di comunicazione tra servizi rispettino un contratto stabilito, garantendo che le modifiche in un servizio non rompano l'interazione con altri servizi.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureMockMvc @AutoConfigureMessageVerifier class ContractVerifierTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @BeforeEach void setup() { User user = new User(1L, "test@example.com"); when(userService.findById(1L)).thenReturn(user); } }
Questo è solo un esempio di base. I test dei contract richiedono anche la definizione del contratto stesso, tipicamente in formato YAML o Groovy.
I test dei contract sono particolarmente utili per:
I test di componente si concentrano su un componente specifico dell'applicazione, testando la sua funzionalità in un contesto limitato ma realistico. È come testare un singolo organo del corpo, assicurandosi che funzioni correttamente con i sistemi direttamente collegati ad esso.
I test di componente verificano il comportamento di un componente software specifico (un servizio, un controller) in un contesto che include le sue dipendenze immediate.
In questo esempio, stiamo testando il `UserService` in un contesto che include un'implementazione in-memory del repository e un mock del servizio di email.
@SpringBootTest @Import(TestConfiguration.class) class UserServiceComponentTest { @Autowired private UserService userService; @MockBean private EmailService emailService; @Test void registerUser_dovrebbeInviareEmailBenvenuto() { // Arrange UserRegistrationRequest request = new UserRegistrationRequest("test@example.com", "password"); // Act userService.registerUser(request); // Assert verify(emailService).sendWelcomeEmail(eq("test@example.com"), any()); } @TestConfiguration static class TestConfig { @Bean public UserRepository userRepository() { return new InMemoryUserRepository(); } } }
I test di componente sono particolarmente utili per:
I test funzionali verificano che l'applicazione implementi correttamente le specifiche funzionali dal punto di vista dell'utente. È come sedersi al posto dell'utente e verificare che tutto funzioni come ci si aspetta.
I test funzionali verificano che l'applicazione soddisfi i requisiti funzionali specificati, testando i flussi di utilizzo reali dell'utente.
In questo esempio, stiamo testando l'intera funzionalità di registrazione utente, dalla richiesta HTTP fino al salvataggio nel database.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class UserRegistrationFunctionalTest { @Autowired private TestRestTemplate restTemplate; @Autowired private UserRepository userRepository; @BeforeEach void setup() { userRepository.deleteAll(); } @Test void registrazioneUtente_dovrebbeCreareUtenteERestituireRispostaSuccesso() { // Arrange UserRegistrationRequest request = new UserRegistrationRequest("test@example.com", "password"); // Act ResponseEntity<RegistrationResponse> response = restTemplate.postForEntity( "/api/register", request, RegistrationResponse.class); // Assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody().isSuccess()).isTrue(); // Verifica che l'utente sia stato creato nel database Optional<User> user = userRepository.findByEmail("test@example.com"); assertThat(user).isPresent(); } }
I test funzionali sono particolarmente utili per:
I test di architettura verificano che l'applicazione rispetti i vincoli e i pattern architetturali definiti. È come assicurarsi che un edificio segua il progetto dell'architetto, rispettando le normative edilizie.
I test di architettura verificano che il codice rispetti le regole architetturali definite, come la separazione degli strati, la dipendenza tra package, i pattern di denominazione, ecc.
In questo esempio, stiamo verificando che:
@AnalyzeClasses(packages = "com.example.application") class ArchitectureTest { @ArchTest static final ArchRule layerDependenciesRule = layeredArchitecture() .layer("Controller").definedBy("..controller..") .layer("Service").definedBy("..service..") .layer("Repository").definedBy("..repository..") .whereLayer("Controller").mayNotBeAccessedByAnyLayer() .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller") .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service"); @ArchTest static final ArchRule serviceClassesShouldHaveServiceSuffix = classes().that().resideInAPackage("..service..") .should().haveSimpleNameEndingWith("Service"); }
I test di architettura sono particolarmente utili per:
Ora che abbiamo visto le principali tipologie di test, vediamo alcune best practices che ci aiuteranno a scrivere test efficaci, manutenibili e che aggiungono reale valore al nostro processo di sviluppo.
Mi piace pensare al testing come all'arte di dormire tranquilli la notte. Seguendo queste best practices, potrai affrontare i rilasci con fiducia anziché con terrore.
Un codice di test ben organizzato è più facile da comprendere, mantenere ed estendere. Ecco alcune linee guida:
I nomi dei test dovrebbero essere descrittivi e seguire un pattern coerente. Un buon pattern è: `nomeMetodocomportamentoAttesocondizioni`
Esempio:
@Test void saveUser_shouldThrowException_whenEmailIsInvalid() { // Test code }
Questo nome ci dice chiaramente:
Organizza i test seguendo una struttura chiara:
Questa struttura rende i test più leggibili e manutenibili.
Mantieni una struttura dei package di test che rispecchia quella del codice principale. Ad esempio:
src/
├── main/
│ └── java/
│ └── com/
│ └── example/
│ ├── controller/
│ ├── service/
│ └── repository/
└── test/
└── java/
└── com/
└── example/
├── controller/
├── service/
└── repository/
Separa i test in base alla loro natura:
src/ ├── test/ │ └── java/ │ └── com/ │ └── example/ │ ├── unit/ │ ├── integration/ │ └── e2e/ └── // ...
Questo ti permette di eseguire selettivamente i test unitari, più veloci, durante lo sviluppo, e i test di integrazione o end-to-end in fasi specifiche del processo di CI/CD.
Test indipendenti e isolati sono più affidabili, più facili da debuggare e più resistenti ai cambiamenti.
Ogni test dovrebbe essere completamente indipendente dagli altri. Un test non dovrebbe mai dipendere dall'esecuzione di un altro test o dai suoi risultati.
Assicurati che ogni test inizi da uno stato noto e pulito. Usa `@BeforeEach` o `@BeforeAll` per configurare lo stato iniziale:
@BeforeEach void setup() { userRepository.deleteAll(); // Pulisce il database prima di ogni test }
Usa dati specifici per ogni test o ripulisci l'ambiente tra i test. Evita di condividere stato tra i test.
Utilizza profili Spring specifici per i test:
@ActiveProfiles("test") @SpringBootTest class UserServiceIntegrationTest { // Test code }
Utilizza database in-memory o TestContainers per i test che richiedono un database:
@DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @Testcontainers class UserRepositoryTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14") .withDatabaseName("testdb") .withUsername("test") .withPassword("test"); @DynamicPropertySource static void registerPgProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } // Test code }
Test performanti rendono il ciclo di feedback più rapido e migliorano l'esperienza di sviluppo.
Utilizza le test slices di Spring Boot quando possibile, anziché caricare l'intero contesto:
// Invece di: @SpringBootTest class UserControllerTest { /* ... */ } // Usa: @WebMvcTest(UserController.class) class UserControllerTest { /* ... */ }
Usa `@SpringBootTest` solo quando necessario, ad esempio per test di integrazione completi. Per test più focalizzati, preferisci approcci più leggeri.
Organizza i test in suite per poterli eseguire separatamente:
@Suite @SelectPackages({ "com.example.unit", "com.example.integration" }) class AllTests { } @Suite @SelectPackages("com.example.unit") class UnitTests { }
Configura JUnit per eseguire i test in parallelo:
// junit-platform.properties junit.jupiter.execution.parallel.enabled = true junit.jupiter.execution.parallel.mode.default = concurrent
L'uso appropriato di mock e stub rende i test più isolati, affidabili e focalizzati.
Utilizza mock per isolare l'unità in test dalle sue dipendenze esterne:
@ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository userRepository; @InjectMocks private UserService userService; // Test code }
Usa `@Mock` e `@MockBean` anziché creare mock manualmente.
Stub solo i metodi che vengono effettivamente chiamati dal codice in test:
// Bene: when(userRepository.findById(1L)).thenReturn(Optional.of(new User(1L, "test@example.com"))); // Evita: when(userRepository.findById(any())).thenReturn(Optional.of(new User(1L, "test@example.com")));
Limita le verifiche a ciò che è effettivamente rilevante per il test:
// Verifica solo che il metodo sia stato chiamato con i parametri corretti verify(userRepository).save(argThat(user -> user.getEmail().equals("test@example.com") && user.getName().equals("Test User") )); // Evita di verificare troppi dettagli: verify(userRepository).save(user); verify(emailService).sendWelcomeEmail(user); verify(loggingService).logUserCreation(user); // ...
Asserzioni ben strutturate rendono i test più espressivi e facilitano l'identificazione dei problemi.
Sfrutta AssertJ per asserzioni fluenti e leggibili:
// Invece di: assertEquals("test@example.com", user.getEmail()); assertTrue(user.isActive()); // Usa: assertThat(user.getEmail()).isEqualTo("test@example.com"); assertThat(user.isActive()).isTrue();
Ogni test dovrebbe verificare un solo concetto o comportamento:
// Invece di: @Test void userRegistration() { // Registro un utente // ... assertThat(user.getId()).isNotNull(); assertThat(user.getEmail()).isEqualTo("test@example.com"); // Verifico che l'email sia stata inviata // ... assertThat(emailSent).isTrue(); // Verifico che l'utente possa fare login // ... assertThat(loginSuccessful).isTrue(); } // Meglio: @Test void registerUser_shouldCreateUserWithCorrectData() { /* ... */ } @Test void registerUser_shouldSendWelcomeEmail() { /* ... */ } @Test void registerUser_shouldAllowImmediateLogin() { /* ... */ }
Per testare le eccezioni, usa approcci specifici:
// Opzione 1: assertThrows Exception exception = assertThrows(UserNotFoundException.class, () -> userService.getUserById(999L) ); assertThat(exception.getMessage()).contains("999"); // Opzione 2: assertThatThrownBy (AssertJ) assertThatThrownBy(() -> userService.getUserById(999L)) .isInstanceOf(UserNotFoundException.class) .hasMessageContaining("999");
Test manutenibili restano utili nel tempo e non diventano un peso per lo sviluppo.
Utilizza metodi di supporto per evitare la duplicazione:
// Invece di ripetere questa logica in ogni test: User user = new User(); user.setId(1L); user.setEmail("test@example.com"); user.setName("Test User"); user.setActive(true); // ... // Crea un metodo di factory: private User createTestUser() { User user = new User(); user.setId(1L); user.setEmail("test@example.com"); user.setName("Test User"); user.setActive(true); return user; }
Per dati complessi, usa fixture riutilizzabili:
class TestFixtures { static final User ADMIN_USER = createUser(1L, "admin@example.com", "Admin", true, Set.of(Role.ADMIN)); static final User REGULAR_USER = createUser(2L, "user@example.com", "User", true, Set.of(Role.USER)); private static User createUser(Long id, String email, String name, boolean active, Set<Role> roles) { User user = new User(); user.setId(id); user.setEmail(email); user.setName(name); user.setActive(active); user.setRoles(roles); return user; } }
I test dovrebbero essere più semplici del codice che testano. Evita logica complessa nei test.
Estrai utility di test riutilizzabili:
public class TestUtils { public static <T> ResponseEntity<T> createOkResponse(T body) { return ResponseEntity.ok(body); } public static <T> ResponseEntity<T> createCreatedResponse(T body) { return ResponseEntity.status(HttpStatus.CREATED).body(body); } // ... }
Una buona copertura dei test aumenta la fiducia nel codice e riduce il rischio di regressioni.
Non inseguire necessariamente il 100% di copertura, ma assicurati che le parti critiche e complesse del codice siano ben coperte.
Assicurati di testare i casi limite e i percorsi alternativi:
@Test void findUser_shouldReturnUser_whenUserExists() { /* ... */ } @Test void findUser_shouldThrowException_whenUserDoesNotExist() { /* ... */ } @Test void findUser_shouldThrowException_whenIdIsNull() { /* ... */ }
Configura strumenti come JaCoCo per monitorare la copertura dei test:
<!-- pom.xml --> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.7</version> <executions> <execution> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> <phase>test</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin>
Imposta soglie minime di copertura per il tuo progetto:
<configuration> <rules> <rule> <element>BUNDLE</element> <limits> <limit> <counter>INSTRUCTION</counter> <value>COVEREDRATIO</value> <minimum>0.80</minimum> </limit> </limits> </rule> </rules> </configuration>
Il Behavior-Driven Development (BDD) non è solo una metodologia di sviluppo, ma anche un approccio al testing che si concentra sul comportamento atteso del sistema dal punto di vista dell'utente o del business. Adottare uno stile BDD per i test può migliorare significativamente la loro leggibilità e il loro valore come documentazione.
In parole povere, il BDD sposta il focus dal "come funziona il codice" al "cosa dovrebbe fare il sistema". È come passare dal pensare "questo metodo dovrebbe restituire un oggetto User con id=1" a "quando un utente richiede i propri dati, il sistema dovrebbe mostrargli le informazioni corrette".
I vantaggi principali sono:
Il cuore del BDD è la struttura "Given-When-Then" (Dato-Quando-Allora), che rende i test più narrativi e comprensibili:
È una struttura che si allinea naturalmente con lo schema AAA (Arrange-Act-Assert) che abbiamo visto in precedenza, ma con un focus più marcato sul comportamento anziché sulla struttura del test.
// Struttura AAA @Test void saveUser() { // Arrange UserDto userDto = new UserDto("test@example.com", "password"); // Act User savedUser = userService.saveUser(userDto); // Assert assertThat(savedUser.getEmail()).isEqualTo("test@example.com"); } // Struttura BDD Given-When-Then @Test void givenValidUserData_whenSavingUser_thenUserIsSavedCorrectly() { // Given UserDto userDto = new UserDto("test@example.com", "password"); // When User savedUser = userService.saveUser(userDto); // Then assertThat(savedUser.getEmail()).isEqualTo("test@example.com"); }
La differenza può sembrare sottile, ma il secondo approccio comunica molto più chiaramente l'intento del test.
Vediamo come implementare test in stile BDD utilizzando le librerie che abbiamo a disposizione.
JUnit 5 non ha un supporto nativo per il BDD, ma possiamo adattarlo facilmente usando naming e struttura appropriati:
@DisplayName("User registration") class UserRegistrationTest { @Test @DisplayName("Given valid user data, when registering a new user, then the user is created") void givenValidUserData_whenRegisteringNewUser_thenUserIsCreated() { // Test code } }
L'annotazione `@DisplayName` ci aiuta a rendere i test più leggibili anche nell'output dei report.
Mockito offre un'API in stile BDD che rende i test più espressivi:
// Approccio standard when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user)); // Approccio BDD given(userRepository.findByEmail("test@example.com")).willReturn(Optional.of(user));
Per le verifiche:
// Approccio standard verify(userRepository).save(user); // Approccio BDD then(userRepository).should().save(user);
AssertJ ha già una sintassi fluente che si integra perfettamente con lo stile BDD:
// Given UserDto userDto = new UserDto("test@example.com", "password"); // When User result = userService.registerUser(userDto); // Then assertThat(result) .isNotNull() .extracting(User::getEmail, User::isActive) .containsExactly("test@example.com", true);
Ecco un esempio completo di test unitario in stile BDD:
@ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository userRepository; @Mock private PasswordEncoder passwordEncoder; @InjectMocks private UserService userService; @Test void givenExistingEmail_whenRegisteringUser_thenThrowsException() { // Given String email = "existing@example.com"; UserRegistrationDto dto = new UserRegistrationDto(email, "password", "John Doe"); given(userRepository.existsByEmail(email)).willReturn(true); // When & Then assertThatThrownBy(() -> userService.registerUser(dto)) .isInstanceOf(UserAlreadyExistsException.class) .hasMessageContaining(email); then(passwordEncoder).shouldHaveNoInteractions(); then(userRepository).should(never()).save(any()); } @Test void givenValidRegistrationData_whenRegisteringUser_thenUserIsSaved() { // Given String email = "new@example.com"; String password = "password"; String encodedPassword = "encoded"; String name = "John Doe"; UserRegistrationDto dto = new UserRegistrationDto(email, password, name); User savedUser = new User(1L, email, encodedPassword, name, true); given(userRepository.existsByEmail(email)).willReturn(false); given(passwordEncoder.encode(password)).willReturn(encodedPassword); given(userRepository.save(any(User.class))).willReturn(savedUser); // When User result = userService.registerUser(dto); // Then assertThat(result).isNotNull(); assertThat(result.getId()).isEqualTo(1L); assertThat(result.getEmail()).isEqualTo(email); assertThat(result.getName()).isEqualTo(name); then(userRepository).should().save(argThat(user -> user.getEmail().equals(email) && user.getPassword().equals(encodedPassword) && user.getName().equals(name) )); } }
I test dei controller si prestano particolarmente bene allo stile BDD, dato che riflettono direttamente le interazioni degli utenti:
@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void givenExistingUser_whenGetUserById_thenReturnsUser() throws Exception { // Given Long userId = 1L; User user = new User(userId, "test@example.com", "encoded", "Test User", true); UserDto userDto = new UserDto(userId, "test@example.com", "Test User"); given(userService.findById(userId)).willReturn(user); // When & Then mockMvc.perform(get("/api/users/{id}", userId) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(userId)) .andExpect(jsonPath("$.email").value("test@example.com")) .andExpect(jsonPath("$.name").value("Test User")); then(userService).should().findById(userId); } @Test void givenNonExistingUser_whenGetUserById_thenReturns404() throws Exception { // Given Long userId = 999L; given(userService.findById(userId)) .willThrow(new UserNotFoundException("User not found with id: " + userId)); // When & Then mockMvc.perform(get("/api/users/{id}", userId) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.message").value(containsString("User not found"))) .andExpect(jsonPath("$.message").value(containsString(userId.toString()))); } }
Anche i test dei repository possono beneficiare dello stile BDD:
@DataJpaTest class UserRepositoryTest { @Autowired private UserRepository userRepository; @Test void givenUserInDatabase_whenFindByEmail_thenReturnsUser() { // Given String email = "test@example.com"; User user = new User(); user.setEmail(email); user.setName("Test User"); user.setPassword("encoded"); user.setActive(true); userRepository.save(user); // When Optional<User> result = userRepository.findByEmail(email); // Then assertThat(result) .isPresent() .get() .satisfies(foundUser -> { assertThat(foundUser.getEmail()).isEqualTo(email); assertThat(foundUser.getName()).isEqualTo("Test User"); }); } @Test void givenNonExistingEmail_whenFindByEmail_thenReturnsEmpty() { // Given String email = "nonexisting@example.com"; // When Optional<User> result = userRepository.findByEmail(email); // Then assertThat(result).isEmpty(); } }
I test di integrazione sono un'ottima opportunità per adottare lo stile BDD, dato che testano flussi di utilizzo più completi:
@SpringBootTest class UserServiceIntegrationTest { @Autowired private UserService userService; @Autowired private UserRepository userRepository; @BeforeEach void setup() { userRepository.deleteAll(); } @Test void givenNewUser_whenRegisterUser_thenUserIsPersistedAndReturned() { // Given UserRegistrationDto registrationDto = new UserRegistrationDto( "test@example.com", "password", "Test User"); // When User result = userService.registerUser(registrationDto); // Then assertThat(result).isNotNull(); assertThat(result.getId()).isNotNull(); assertThat(result.getEmail()).isEqualTo("test@example.com"); // Verify user is in the database Optional<User> fromDb = userRepository.findById(result.getId()); assertThat(fromDb) .isPresent() .get() .satisfies(user -> { assertThat(user.getEmail()).isEqualTo("test@example.com"); assertThat(user.getName()).isEqualTo("Test User"); assertThat(user.isActive()).isTrue(); }); } }
Adottare un approccio BDD per i test offre numerosi vantaggi:
L'approccio BDD incoraggia anche a pensare prima al comportamento atteso e poi all'implementazione, il che porta spesso a un design migliore del codice.
Ora approfondiamo le principali librerie che abbiamo a disposizione per il testing di applicazioni Spring Boot con Java 21. Conoscere a fondo questi strumenti è fondamentale per scrivere test efficaci e manutenibili.
JUnit 5 è il framework standard per il testing in Java, completamente riscritto rispetto alle versioni precedenti. È composto da diversi moduli:
Figure 2: Architettura di JUnit 5
| Annotazione | Descrizione |
|---|---|
| @Test | Marca un metodo come test |
| @BeforeEach | Esegue il metodo prima di ogni test |
| @AfterEach | Esegue il metodo dopo ogni test |
| @BeforeAll | Esegue il metodo una volta prima di tutti i test |
| @AfterAll | Esegue il metodo una volta dopo tutti i test |
| @DisplayName | Specifica un nome personalizzato per il test |
| @Disabled | Disabilita un test |
| @Timeout | Specifica un timeout per il test |
| @ExtendWith | Estende il test con funzionalità aggiuntive |
| @ParameterizedTest | Marca un metodo come test parametrizzato |
| @RepeatedTest | Marca un metodo come test da ripetere |
JUnit 5 include un set completo di asserzioni:
// Asserzioni di base assertEquals(expected, actual); assertTrue(condition); assertFalse(condition); // Asserzioni per eccezioni assertThrows(ExpectedException.class, () -> { /* code */ }); // Asserzioni multiple assertAll( () -> assertEquals(expected1, actual1), () -> assertEquals(expected2, actual2) ); // Asserzioni con messaggi personalizzati assertEquals(expected, actual, "Il valore non corrisponde");
I test parametrizzati permettono di eseguire lo stesso test con input diversi:
@ParameterizedTest @ValueSource(strings = {"test@example.com", "another@example.com"}) void isEmailValid_shouldReturnTrue_forValidEmails(String email) { assertTrue(EmailValidator.isValid(email)); } @ParameterizedTest @CsvSource({ "test@example.com, true", "invalid-email, false", "@example.com, false" }) void isEmailValid_shouldReturnExpectedResult(String email, boolean expected) { assertEquals(expected, EmailValidator.isValid(email)); }
I test ripetuti permettono di eseguire lo stesso test più volte:
@RepeatedTest(5) void repeatedTest() { // Test code } @RepeatedTest(value = 5, name = "Ripetizione {currentRepetition} di {totalRepetitions}") void repeatedTestWithCustomName() { // Test code }
JUnit 5 offre un meccanismo di estensione molto potente:
@ExtendWith(SpringExtension.class) class SpringTest { // Test code } @ExtendWith(MockitoExtension.class) class MockitoTest { // Test code } @ExtendWith({SpringExtension.class, MockitoExtension.class}) class MultipleExtensionsTest { // Test code }
Mockito è un framework per il mocking in Java che consente di creare e configurare oggetti finti (mock) per isolare il codice in test.
// Creazione con annotazioni @ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository userRepository; @InjectMocks private UserService userService; // Test code } // Creazione manuale UserRepository mockRepository = Mockito.mock(UserRepository.class); UserService userService = new UserService(mockRepository);
Lo stubbing consente di definire il comportamento dei mock:
// Approccio standard when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(userRepository.findById(999L)).thenThrow(new NotFoundException()); // Approccio BDD given(userRepository.findById(1L)).willReturn(Optional.of(user)); given(userRepository.findById(999L)).willThrow(new NotFoundException()); // Stubbing con argomenti generici when(userRepository.findById(any())).thenReturn(Optional.of(user)); // Stubbing con match personalizzato when(userRepository.save(argThat(u -> u.getEmail().contains("example.com")))) .thenReturn(savedUser);
La verifica consente di controllare che i mock siano stati chiamati correttamente:
// Verifica base verify(userRepository).save(user); // Verifica del numero di invocazioni verify(userRepository, times(2)).save(any()); verify(userRepository, never()).delete(any()); verify(userRepository, atLeastOnce()).findById(any()); // Verifica dell'ordine InOrder inOrder = inOrder(userRepository, emailService); inOrder.verify(userRepository).save(any()); inOrder.verify(emailService).sendWelcomeEmail(any()); // Verifica con argomenti specifici verify(userRepository).save(argThat(user -> user.getEmail().equals("test@example.com") && user.getName().equals("Test User") )); // Approccio BDD then(userRepository).should().save(user); then(userRepository).should(times(2)).findById(any()); then(userRepository).should(never()).delete(any());
La cattura degli argomenti permette di catturare e verificare gli argomenti passati ai mock:
@Test void shouldSaveUserWithEncryptedPassword() { // Arrange UserDto userDto = new UserDto("test@example.com", "password"); // Act userService.saveUser(userDto); // Cattura l'utente passato al repository ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class); verify(userRepository).save(userCaptor.capture()); // Assert User savedUser = userCaptor.getValue(); assertThat(savedUser.getEmail()).isEqualTo("test@example.com"); assertThat(savedUser.getPassword()).isNotEqualTo("password"); // Password criptata }
Gli spy sono versioni "spiate" di oggetti reali:
// Creazione di uno spy List<String> spyList = spy(new ArrayList<>()); // Uso dello spy spyList.add("item1"); spyList.add("item2"); // Verifica verify(spyList).add("item1"); assertThat(spyList).hasSize(2); // Stubbing su spy doReturn(100).when(spyList).size(); assertThat(spyList.size()).isEqualTo(100);
AssertJ è una libreria di asserzioni fluenti che rende i test più leggibili ed espressivi.
// Asserzioni sui valori assertThat(actual).isEqualTo(expected); assertThat(actual).isNotEqualTo(unexpected); assertThat(actual).isIn(possibleValues); assertThat(actual).isNotIn(impossibleValues); // Asserzioni sui booleani assertThat(condition).isTrue(); assertThat(condition).isFalse(); // Asserzioni sulle stringhe assertThat(string).isEqualTo("expected"); assertThat(string).contains("substring"); assertThat(string).startsWith("prefix"); assertThat(string).endsWith("suffix"); assertThat(string).matches("regex"); // Asserzioni sui numeri assertThat(number).isPositive(); assertThat(number).isNegative(); assertThat(number).isZero(); assertThat(number).isBetween(min, max); assertThat(number).isCloseTo(expected, within(0.01));
// Asserzioni su liste assertThat(list).hasSize(3); assertThat(list).contains("item1", "item2"); assertThat(list).containsExactly("item1", "item2", "item3"); assertThat(list).containsExactlyInAnyOrder("item3", "item1", "item2"); assertThat(list).doesNotContain("item4"); assertThat(list).isEmpty(); assertThat(list).isNotEmpty(); // Asserzioni su mappe assertThat(map).containsKey("key"); assertThat(map).containsValue("value"); assertThat(map).containsEntry("key", "value");
// Asserzioni su eccezioni assertThatThrownBy(() -> { throw new Exception("error"); }) .isInstanceOf(Exception.class) .hasMessage("error"); assertThatExceptionOfType(NotFoundException.class) .isThrownBy(() -> service.findById(999L)) .withMessage("User not found with id: 999"); assertThatNoException().isThrownBy(() -> service.doSomething());
// Asserzioni con estrazione di proprietà assertThat(user) .extracting(User::getName, User::getEmail) .containsExactly("Test User", "test@example.com"); // Asserzioni con predicati assertThat(user).satisfies(u -> { assertThat(u.getName()).isEqualTo("Test User"); assertThat(u.getEmail()).isEqualTo("test@example.com"); assertThat(u.isActive()).isTrue(); }); // Asserzioni sofisticate assertThat(users) .filteredOn(user -> user.getEmail().contains("example.com")) .extracting(User::getName) .containsExactly("User 1", "User 2");
Spring Test è il modulo di testing di Spring Framework che include supporto per il testing di applicazioni Spring.
Spring Boot offre annotazioni specifiche per testare "fette" dell'applicazione:
// Test completo con contesto Spring @SpringBootTest class FullIntegrationTest { // Test code } // Test solo dei controller MVC @WebMvcTest(UserController.class) class UserControllerTest { // Test code } // Test solo dei repository JPA @DataJpaTest class UserRepositoryTest { // Test code } // Test solo della serializzazione JSON @JsonTest class UserDtoJsonTest { // Test code } // Test solo dei client REST @RestClientTest(UserClient.class) class UserClientTest { // Test code }
`@MockBean` permette di sostituire bean nel contesto Spring con mock:
@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; // Test code }
MockMvc permette di testare i controller MVC senza un server reale:
@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void testGetUser() throws Exception { // Arrange given(userService.findById(1L)).willReturn(new User(1L, "test@example.com")); // Act & Assert mockMvc.perform(get("/api/users/1") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(1)) .andExpect(jsonPath("$.email").value("test@example.com")) .andDo(print()); // Per debug } @Test void testCreateUser() throws Exception { // Arrange UserDto userDto = new UserDto("test@example.com", "password"); User savedUser = new User(1L, "test@example.com"); given(userService.saveUser(any())).willReturn(savedUser); // Act & Assert mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content(new ObjectMapper().writeValueAsString(userDto))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value(1)) .andExpect(jsonPath("$.email").value("test@example.com")); } }
TestRestTemplate permette di testare i controller REST con un server reale:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class UserControllerIntegrationTest { @Autowired private TestRestTemplate restTemplate; @Test void testGetUser() { // Act ResponseEntity<UserDto> response = restTemplate.getForEntity("/api/users/1", UserDto.class); // Assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().getId()).isEqualTo(1L); assertThat(response.getBody().getEmail()).isEqualTo("test@example.com"); } }
TestContainers è una libreria che permette di creare container Docker per i test, ideale per testare l'integrazione con database, messaggistica e altri servizi.
@DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @Testcontainers class UserRepositoryTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14") .withDatabaseName("testdb") .withUsername("test") .withPassword("test"); @DynamicPropertySource static void registerPgProperties(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 UserRepository userRepository; // Test code }
È possibile utilizzare più container contemporaneamente:
@SpringBootTest @Testcontainers class IntegrationTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14"); @Container static RabbitMQContainer rabbitmq = new RabbitMQContainer("rabbitmq:3.8"); @Container static RedisContainer redis = new RedisContainer("redis:6"); @DynamicPropertySource static void registerProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); // ... altre proprietà registry.add("spring.rabbitmq.host", rabbitmq::getHost); registry.add("spring.rabbitmq.port", rabbitmq::getAmqpPort); registry.add("spring.redis.host", redis::getHost); registry.add("spring.redis.port", redis::getFirstMappedPort); } // Test code }
I container possono condividere la stessa rete:
@SpringBootTest @Testcontainers class NetworkedContainersTest { static Network network = Network.newNetwork(); @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14") .withNetwork(network) .withNetworkAliases("postgres"); @Container static GenericContainer<?> app = new GenericContainer<>("myapp:latest") .withNetwork(network) .withEnv("DB_HOST", "postgres") .withExposedPorts(8080); // Test code }
È possibile creare container personalizzati:
static class MyCustomContainer extends GenericContainer<MyCustomContainer> { public MyCustomContainer() { super("my-custom-image:latest"); withExposedPorts(8080); withEnv("APP_ENV", "test"); // Altre configurazioni } public String getUrl() { return "http://" + getHost() + ":" + getMappedPort(8080); } } @Container static MyCustomContainer myContainer = new MyCustomContainer();
Esistono molte altre librerie utili per il testing di applicazioni Spring Boot:
Per testare specificamente il layer di persistenza:
@ExtendWith(JpaUnitExtension.class) @JpaUnit(persistenceUnit = "my-persistence-unit") class UserRepositoryJpaUnitTest { @PersistenceContext private EntityManager em; @Test void testSaveUser() { // Test code using EntityManager } }
Per testare applicazioni web a livello di HTML:
@WebMvcTest(UserController.class) class UserControllerHtmlUnitTest { @Autowired private WebClient webClient; @MockBean private UserService userService; @Test void testUserPage() throws Exception { // Arrange User user = new User(1L, "test@example.com", "Test User"); given(userService.findById(1L)).willReturn(user); // Act HtmlPage page = webClient.getPage("/users/1"); // Assert assertThat(page.getTitleText()).isEqualTo("User Details"); assertThat(page.getBody().getTextContent()).contains("test@example.com"); } }
Per testare e confrontare strutture JSON:
@Test void testJsonEquality() { String expected = "{\"id\":1,\"email\":\"test@example.com\"}"; String actual = "{\"id\":1,\"email\":\"test@example.com\"}"; // Asserzione base assertThatJson(actual).isEqualTo(expected); // Ignora campi specifici assertThatJson(actual).when(IGNORING_EXTRA_FIELDS).isEqualTo("{\"id\":1}"); // Verifica valori specifici assertThatJson(actual).node("email").isString().isEqualTo("test@example.com"); }
Per testare l'architettura dell'applicazione:
@AnalyzeClasses(packages = "com.example") class ArchitectureTest { @ArchTest static final ArchRule layerDependenciesRule = layeredArchitecture() .layer("Controller").definedBy("..controller..") .layer("Service").definedBy("..service..") .layer("Repository").definedBy("..repository..") .whereLayer("Controller").mayNotBeAccessedByAnyLayer() .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller") .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service"); @ArchTest static final ArchRule namingConventionRule = classes() .that().resideInAPackage("..controller..") .should().haveSimpleNameEndingWith("Controller"); @ArchTest static final ArchRule dtoShouldBeImmutable = classes() .that().resideInAPackage("..dto..") .should().haveOnlyFinalFields(); }
Il testing automatico è una componente fondamentale nello sviluppo di applicazioni Spring Boot di alta qualità. Attraverso un approccio strutturato che combina diversi tipi di test (unitari, di integrazione, di architettura, ecc.) e utilizzando gli strumenti appropriati, è possibile garantire che l'applicazione funzioni correttamente e rimanga mantenibile nel tempo.
Nel corso di queste sessioni, abbiamo esplorato i principi fondamentali del testing automatico, le diverse tipologie di test e le best practices da seguire. Abbiamo visto come utilizzare JUnit 5, Mockito, AssertJ e altre librerie per scrivere test efficaci e manutenibili. Abbiamo anche approfondito l'approccio BDD (Behavior-Driven Development) e come applicarlo per migliorare la leggibilità e il valore dei test come documentazione.
La strada verso un'applicazione robusta e affidabile passa necessariamente attraverso una solida strategia di testing. Con le conoscenze acquisite, sarai in grado di implementare questa strategia e garantire la qualità del tuo codice nel lungo periodo.
Ricorda: un test oggi, più che una spesa, è un investimento. Un investimento che sarà ripagato con interessi ogni volta che il tuo codice cambierà, ogni volta che introdurrai nuove funzionalità e, soprattutto, ogni volta che dormirai sonni tranquilli sapendo che la tua applicazione funziona esattamente come dovrebbe.
Non resta che mettere in pratica quanto appreso e iniziare a testare. Buon testing!