Introduzione al Testing Automatico per Sviluppatori Backend (Spring Boot/Java 21)

Attività di formazione interna Key2

Alessandro Accardo

permalink qr code

Panoramica generale del Testing Automatico

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.

Perché dovresti interessarti al testing automatico?

Parliamoci chiaro: nessuno vuole essere svegliato alle 3 di notte perché qualcosa è esploso in produzione. Ecco perché il testing automatico è cruciale:

  • Qualità del software: Individua i bug prima che i tuoi utenti lo facciano
  • Refactoring sicuro: Modifica il codice esistente senza il terrore di rompere funzionalità esistenti
  • Documentazione viva: I test sono la documentazione più affidabile del comportamento atteso del sistema
  • Sviluppo più veloce: Sì, scrivere test richiede tempo, ma riduce drasticamente il debugging a lungo termine
  • Fiducia: Puoi rilasciare nuove funzionalità con la certezza che tutto funzionerà

La Piramide del Testing

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.

Test-Pyramid.png

Figure 1: La Piramide del Testing

La Piramide del Testing

Come puoi vedere nell'immagine, la piramide è suddivisa in tre livelli principali:

  1. Test Unitari (alla base della piramide):
    • Sono i più numerosi, veloci ed economici
    • Testano singole unità di codice in isolamento
    • Danno feedback immediato agli sviluppatori
  2. Test di Integrazione (al centro):
    • Testano l'interazione tra componenti diversi
    • Verificano che le parti lavorino bene insieme
    • Sono più lenti e complessi dei test unitari
  3. Test End-to-End/UI (al vertice):
    • Testano l'applicazione nel suo complesso
    • Simulano il comportamento reale dell'utente
    • Sono i più costosi in termini di tempo e risorse

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.

Framework e Librerie per il Testing in Spring Boot/Java 21

Per le applicazioni Spring Boot con Java 21, abbiamo a disposizione un arsenale completo di strumenti per il testing. Ecco i principali che utilizzeremo:

  • JUnit 5 (Jupiter): Il framework base per la creazione ed esecuzione dei test
  • Mockito: Per creare mock di dipendenze e simulare comportamenti
  • AssertJ: Per asserzioni fluenti e leggibili che rendono i test più espressivi
  • Spring Test: Integrazione specifica per testare applicazioni Spring
  • JPA-Unit: Per testare specificamente il layer di persistenza
  • HTML-Unit: Per testare componenti web senza browser reali
  • JSON-Unit: Per testare e comparare strutture JSON
  • Arch-Unit: Per verificare che l'architettura dell'applicazione rispetti i vincoli definiti
  • TestContainers: Per creare e gestire container Docker nei test

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.

Test Unitari vs. Test di Integrazione vs. Test End-to-End

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 vs. Test di Integrazione vs. Test End-to-End

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 Unitari vs. Test di Integrazione vs. Test End-to-End

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 Unitari vs. Test di Integrazione vs. Test End-to-End

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);
    }
}

Test Unitari vs. Test di Integrazione vs. Test End-to-End

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.

Tipologie di Test Automatici in Spring Boot/Java

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.

Test Unitari

I test unitari sono i mattoncini LEGO della tua strategia di testing. Piccoli, precisi e fondamentali.

Definizione e scopo

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.

Caratteristiche

  • Isolamento: L'unità testata deve essere completamente isolata dalle sue dipendenze
  • Velocità: Estremamente veloci (millisecondi per l'esecuzione)
  • Semplicità: Focalizzati su un comportamento specifico
  • Indipendenza: Non dipendono da altre parti del sistema o da risorse esterne
  • Ripetibilità: Producono sempre lo stesso risultato in condizioni identiche

Schema AAA (Arrange-Act-Assert)

Un buon test unitario segue lo schema AAA:

  1. Arrange: Prepara i dati e configura l'ambiente
  2. Act: Esegui l'azione da testare
  3. Assert: Verifica che il risultato sia quello atteso

Esempio di test unitario con JUnit 5 e Mockito

@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.

Quando usare i test unitari

I test unitari sono particolarmente utili per:

  • Verificare la logica di business complessa
  • Testare casi limite ed eccezioni
  • Assicurarsi che le modifiche non rompano funzionalità esistenti
  • Guidare lo sviluppo attraverso il TDD (Test-Driven Development)

Test di Integrazione

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.

Definizione e scopo

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.

Caratteristiche

  • Ambito più ampio: Coinvolgono più componenti o servizi
  • Realismo: Più vicini al comportamento reale dell'applicazione
  • Complessità: Più complessi da impostare e mantenere
  • Velocità: Più lenti dei test unitari, ma più veloci dei test end-to-end
  • Configurazione: Spesso richiedono configurazioni specifiche

Approcci ai test di integrazione

Spring Boot offre diverse soluzioni per i test di integrazione:

  • @SpringBootTest: Per test di integrazione completi con contesto Spring
  • Test slice con @DataJpaTest, @WebMvcTest, ecc.: Per testare "fette" specifiche dell'applicazione

Esempio di test di integrazione con @SpringBootTest

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.

Esempio di test di integrazione con @SpringBootTest

@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();
    }
}

Quando usare i test di integrazione

I test di integrazione sono particolarmente utili per:

  • Verificare l'interazione con database
  • Testare l'integrazione tra diversi servizi
  • Assicurarsi che le API REST funzionino correttamente
  • Verificare il comportamento dell'applicazione in scenari più completi

Test delle Slices (Test di Strato)

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.

Definizione e scopo

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.

Principali annotazioni per il testing di slices

  1. @WebMvcTest: Per testare i controller MVC
  2. @DataJpaTest: Per testare i repository JPA
  3. @JdbcTest: Per testare componenti JDBC
  4. @JsonTest: Per testare la serializzazione/deserializzazione JSON
  5. @WebServiceClientTest: Per testare client di web service
  6. @DataMongoTest: Per testare componenti MongoDB

Esempio di test della slice web con @WebMvcTest

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.

Esempio di test della slice web con @WebMvcTest

@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"));
    }
}

Quando usare i test delle slices

I test delle slices sono particolarmente utili per:

  • Testare rapidamente strati specifici dell'applicazione
  • Ridurre i tempi di esecuzione dei test
  • Isolare problemi in componenti specifici
  • Creare test più focalizzati e manutenibili

Test dei Contract

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.

Definizione e scopo

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.

Caratteristiche

  • Focus sull'interfaccia: Testano il formato dei messaggi scambiati
  • Bi-direzionali: Possono essere scritti dal punto di vista del provider o del consumer
  • Prevenzione dei problemi di integrazione: Individuano incompatibilità prima del deploy

Strumenti

  • Spring Cloud Contract: Framework per il consumer-driven contract testing

Esempio di test del contract (provider side)

@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.

Quando usare i test dei contract

I test dei contract sono particolarmente utili per:

  • Microservizi che interagiscono tra loro
  • API pubbliche
  • Integrazione con servizi esterni
  • Ambienti con sviluppo distribuito tra più team

Test di Componente

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.

Definizione e scopo

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.

Caratteristiche

  • Ambito intermedio: Più ampi dei test unitari ma più focalizzati dei test di integrazione completa
  • Configurazione specifica: Caricano solo ciò che è necessario per testare il componente
  • Velocità moderata: Più rapidi dei test di integrazione completi

Esempio di test di componente

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.

Esempio di test di componente

@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();
        }
    }
}

Quando usare i test di componente

I test di componente sono particolarmente utili per:

  • Testare componenti complessi con molte dipendenze
  • Verificare scenari di business end-to-end all'interno di un componente
  • Testare la corretta integrazione di un componente con le sue dipendenze immediate

Test Funzionali

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.

Definizione e scopo

I test funzionali verificano che l'applicazione soddisfi i requisiti funzionali specificati, testando i flussi di utilizzo reali dell'utente.

Caratteristiche

  • Orientati ai casi d'uso: Testano scenari di utilizzo reali
  • Black box: Non si preoccupano dell'implementazione interna
  • Copertura delle funzionalità: Verificano che tutte le funzionalità richieste siano implementate correttamente

Esempio di test funzionale in Spring Boot

In questo esempio, stiamo testando l'intera funzionalità di registrazione utente, dalla richiesta HTTP fino al salvataggio nel database.

Esempio di test funzionale in Spring Boot

@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();
    }
}

Quando usare i test funzionali

I test funzionali sono particolarmente utili per:

  • Verificare il comportamento end-to-end dell'applicazione
  • Assicurarsi che i requisiti funzionali siano soddisfatti
  • Testare flussi di utilizzo completi
  • Simulare il comportamento dell'utente

Test di Architettura

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.

Definizione e scopo

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.

Caratteristiche

  • Regole strutturali: Verificano l'aderenza a vincoli architetturali
  • Prevenzione del degrado: Evitano il deterioramento dell'architettura nel tempo
  • Documentazione vivente: Documentano le regole architetturali in modo eseguibile

Strumenti

  • ArchUnit: Libreria per testare l'architettura del codice Java

Esempio di test di architettura con ArchUnit

In questo esempio, stiamo verificando che:

  1. Le dipendenze tra i layer rispettino l'architettura a strati (controller -> service -> repository)
  2. Le classi nel package service abbiano il suffisso "Service"

Esempio di test di architettura con ArchUnit

@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");
}

Quando usare i test di architettura

I test di architettura sono particolarmente utili per:

  • Mantenere l'integrità architetturale nel tempo
  • Prevenire il degrado dell'architettura con l'evoluzione dell'applicazione
  • Documentare e far rispettare le regole architetturali
  • Facilitare l'onboarding di nuovi sviluppatori

Best Practices per il Testing Automatico

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.

Organizzazione dei Test

Un codice di test ben organizzato è più facile da comprendere, mantenere ed estendere. Ecco alcune linee guida:

Naming espressivo

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:

  • Cosa stiamo testando: il metodo `saveUser`
  • Cosa ci aspettiamo: che lanci un'eccezione
  • In quali condizioni: quando l'email è invalida

Struttura AAA o Given-When-Then

Organizza i test seguendo una struttura chiara:

  • Arrange/Given: Preparazione dello scenario
  • Act/When: Esecuzione dell'azione da testare
  • Assert/Then: Verifica dei risultati

Questa struttura rende i test più leggibili e manutenibili.

Organizzazione dei package

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/

Separazione per tipologia

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.

Indipendenza e Isolamento

Test indipendenti e isolati sono più affidabili, più facili da debuggare e più resistenti ai cambiamenti.

Test indipendenti

Ogni test dovrebbe essere completamente indipendente dagli altri. Un test non dovrebbe mai dipendere dall'esecuzione di un altro test o dai suoi risultati.

Stato iniziale pulito

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
}

Dati di test isolati

Usa dati specifici per ogni test o ripulisci l'ambiente tra i test. Evita di condividere stato tra i test.

Profili Spring specifici

Utilizza profili Spring specifici per i test:

@ActiveProfiles("test")
@SpringBootTest
class UserServiceIntegrationTest {
    // Test code
}

Database indipendenti

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
}

Performance

Test performanti rendono il ciclo di feedback più rapido e migliorano l'esperienza di sviluppo.

Utilizzo delle test slices

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 { /* ... */ }

Limita l'uso di @SpringBootTest

Usa `@SpringBootTest` solo quando necessario, ad esempio per test di integrazione completi. Per test più focalizzati, preferisci approcci più leggeri.

Organizzazione in suite

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 { }

Esecuzione parallela

Configura JUnit per eseguire i test in parallelo:

// junit-platform.properties
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent

Mocking e Stubbing

L'uso appropriato di mock e stub rende i test più isolati, affidabili e focalizzati.

Utilizza mock per dipendenze esterne

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
}

Preferisci i mock creati tramite annotazioni

Usa `@Mock` e `@MockBean` anziché creare mock manualmente.

Limita lo stubbing al necessario

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")));

Verifica solo ciò che è rilevante

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

Asserzioni ben strutturate rendono i test più espressivi e facilitano l'identificazione dei problemi.

Utilizza asserzioni espressive

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();

Verifica un solo concetto per test

Ogni test dovrebbe verificare un solo concetto o comportamento:

Verifica un solo concetto per test

// 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() { /* ... */ }

Usa asserzioni specifiche per eccezioni

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");

Manutenibilità

Test manutenibili restano utili nel tempo e non diventano un peso per lo sviluppo.

Evita la duplicazione nei test

Utilizza metodi di supporto per evitare la duplicazione:

Evita la duplicazione nei test

// 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;
}

Utilizza fixture riutilizzabili

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;
    }
}

Mantieni i test semplici

I test dovrebbero essere più semplici del codice che testano. Evita logica complessa nei test.

Refactoring delle utility di 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);
    }
    
    // ...
}

Copertura

Una buona copertura dei test aumenta la fiducia nel codice e riduce il rischio di regressioni.

Mira a una copertura adeguata

Non inseguire necessariamente il 100% di copertura, ma assicurati che le parti critiche e complesse del codice siano ben coperte.

Concentrati sui casi limite

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() { /* ... */ }

Utilizza strumenti di copertura

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>

Definisci soglie di copertura

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>

Strategie di Testing in Stile BDD per Spring Boot

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.

Cos'è il BDD e perché dovrebbe interessarti

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:

  1. Comunicazione: Migliora la comunicazione tra sviluppatori, tester e stakeholder
  2. Leggibilità: I test diventano più leggibili e comprensibili
  3. Focus sul valore: Ci si concentra sul valore per l'utente piuttosto che sui dettagli implementativi
  4. Documentazione viva: I test diventano una documentazione eseguibile delle specifiche

La struttura Given-When-Then

Il cuore del BDD è la struttura "Given-When-Then" (Dato-Quando-Allora), che rende i test più narrativi e comprensibili:

  1. Given (Dato): Stabilisce il contesto iniziale, le precondizioni per il test
  2. When (Quando): Descrive l'azione o l'evento che si sta testando
  3. Then (Allora): Specifica il risultato atteso o il comportamento previsto

È 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.

La struttura Given-When-Then

// 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 struttura Given-When-Then

La differenza può sembrare sottile, ma il secondo approccio comunica molto più chiaramente l'intento del test.

Implementazione con JUnit 5, Mockito e AssertJ

Vediamo come implementare test in stile BDD utilizzando le librerie che abbiamo a disposizione.

JUnit 5

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 BDD

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

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);

Test Unitari in Stile BDD

Ecco un esempio completo di test unitario in stile BDD:

Test Unitari 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)
        ));
    }
}

Test di Controller in Stile BDD

I test dei controller si prestano particolarmente bene allo stile BDD, dato che riflettono direttamente le interazioni degli utenti:

Test di Controller in Stile BDD

@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())));
    }
}

Test di Repository in Stile BDD

Anche i test dei repository possono beneficiare dello stile BDD:

Test di Repository in 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();
    }
}

Test di Integrazione in Stile BDD

I test di integrazione sono un'ottima opportunità per adottare lo stile BDD, dato che testano flussi di utilizzo più completi:

Test di Integrazione in Stile BDD

@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();
            });
    }
}

Vantaggi dell'Approccio BDD

Adottare un approccio BDD per i test offre numerosi vantaggi:

  1. Leggibilità: I test sono più facili da comprendere, anche per non sviluppatori
  2. Focus sul comportamento: Ci si concentra su cosa il sistema dovrebbe fare, non su come lo fa
  3. Documentazione vivente: I test diventano documentazione eseguibile delle specifiche
  4. Comunicazione: Migliora la comunicazione tra sviluppatori, tester e stakeholder
  5. Manutenibilità: I test risultano più facili da mantenere e aggiornare

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.

Panoramica delle Librerie di Testing per Spring Boot/Java 21

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 (Jupiter)

JUnit 5 è il framework standard per il testing in Java, completamente riscritto rispetto alle versioni precedenti. È composto da diversi moduli:

junit-component-diagram.svg

Figure 2: Architettura di JUnit 5

JUnit 5 (Jupiter)

  • JUnit Platform: L'infrastruttura per eseguire i test
  • JUnit Jupiter: API per scrivere test in JUnit 5
  • JUnit Vintage: Supporto per eseguire test JUnit 3 e 4

Annotazioni principali

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

Asserzioni

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");

Test parametrizzati

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));
}

Test ripetuti

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
}

Estensioni

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

Mockito è un framework per il mocking in Java che consente di creare e configurare oggetti finti (mock) per isolare il codice in test.

Creazione di mock

// 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);

Stubbing

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);

Verifica

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());

Cattura argomenti

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
}

Spy

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

AssertJ è una libreria di asserzioni fluenti che rende i test più leggibili ed espressivi.

Asserzioni di base

// 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 collezioni

// 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

// 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 su oggetti complessi

// 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

Spring Test è il modulo di testing di Spring Framework che include supporto per il testing di applicazioni Spring.

Test slices

Spring Boot offre annotazioni specifiche per testare "fette" dell'applicazione:

Test slices

// 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

`@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

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

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

TestContainers è una libreria che permette di creare container Docker per i test, ideale per testare l'integrazione con database, messaggistica e altri servizi.

Configurazione base

@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
}

Container multipli

È 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
}

Network condiviso

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
}

Container personalizzati

È 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();

Altre Librerie Utili

Esistono molte altre librerie utili per il testing di applicazioni Spring Boot:

JPA-Unit

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
    }
}

HTML-Unit

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");
    }
}

JSON-Unit

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");
}

Arch-Unit

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();
}

Conclusione

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.

Conclusione

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!