Block Image

Una applicazione prima di essere distribuita, dovrebbe essere testata e validata. Lo scopo dei test è verificare che l'applicazione rispetti i requisiti funzionali, non funzionali e rilevi errori all'interno dell'applicativo.

TDD: Test-Driven Development

Una volta che i requisiti e le specifiche sono validate, può iniziare un processo chiamato Test-Driven Development, ovvero lo sviluppo guidato dai test. Si scrivono PRIMA i test e DOPO si sviluppa il codice. I test verranno creati in base ai requisiti e specifiche concordati; inizialmente i test falliranno, scriveremo codice nell'applicazione per far sì che i test vengano superati. Una volta che il test è superato, possiamo rifattorizzare il codice nell'applicativo per migliorarlo e lanciare di nuovo il test.
I test di questo tipo dovrebbero essere progettati dagli analisti e implementati dagli sviluppatori. Se notiamo che i test per una certa specifica sono difficili da sviluppare, dovremmo considerare il fatto che quella specifica forse non è corretta o quantomeno è ambigua.

Block Image

Grazie alla tecnica del TDD, possiamo individuare eventuali problemi già nelle prime fasi di sviluppo. Considera che lo sforzo per risolvere un problema cresce in maniera esponenziale proporzionalmente al tempo necessario per trovarlo.

Unit e Integration Test

Uno Unit Test verifica il funzionamento di una piccola parte dell'applicazione, come un metodo di una classe, ed è indipendente e isolata dalle altre unità dell'applicazione. Potremmo pensare alle singole unità come i singoli layer dell'applicativo.
Un buon test unitario quindi è indipendente dall'intera infrastruttura dell'app, come il tipo di database e dagli altri layer. Se il metodo da testare ha delle dipendenza con altre unità, queste possono essere mockate (magari usando delle librerie come Mockito).
Nell'esempio che faremo, testeremo un metodo del Service, e il test sarà indipendente sia dal database ma anche dal contesto di Spring (quindi questo test funziona con qualsiasi framework usato per la Dependency Injection).

Un Integration Test verifica il funzionamento di più unità dell'applicativo. Qui il contesto del framework usato viene utilizzato anche nella fase di test.

Esempio di requisito

Dai requisiti, ci viene chiesto d'implementare delle API REST di findById e create di un model User.
In particolare, nella findById, il client deve ricevere un 200 e nella response body il JSON dello User trovato.
Se non esiste nessun User con l'id dato in input, il client deve ricevere 404 con un body vuoto.
Inoltre il client vede 2 campi dello User: name e address; il name è composto dal cognome, uno spazio e il nome.

Il progetto avrà questi layers:

  1. entities che conterrà l'entity User con nome, cognome e indirizzo
  2. dtos che conterrà il DTO UserDTO che mappa l'entity User, con i campi nome ("cognome + nome") e indirizzo
  3. converters che ha la responsabilità di trasformare User in UserDTO e viceversa
  4. repos che conterrà un JpaRepository di Spring per l'entity User
  5. services che conterrà UserService la quale banalmente si occuperà di recuperare uno User dal DB e userà il converter per trasformarlo in DTO
  6. controllers che conterrà UserController la quale avrà la resposabilità di mappare chiamate REST e userà la business logic del Service.

Prerequisiti

  1. Aver installato una jdk (useremo la versione 8 ma va bene anche una successiva).
  2. Aver installato maven (https://maven.apache.org/install.html).

Primo passo: creiamo l'entity e il dto

@Entity
public class User implements Serializable {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    private String surname;
    
    private String address;

    public User() {}

    public User(String name, String surname, String address) {
        this.name = name;
        this.surname = surname;
        this.address = address;
    }

    
    //getter, setter, equals and hashcode
}

@Entity
public class UserDTO {

    //surname + name
    private String name;

    private String address;

    public UserDTO() {}


    public UserDTO(String name, String address) {
        this.name = name;
        this.address = address;
    }
    
    //getter, setter, equals and hashcode
}

Secondo passo: creiamo il converter

@Component
public class UserConverter {


    public UserDTO userToUserDTO(User user) {
        return new UserDTO(user.getSurname() + " " + user.getName(), user.getAddress());
    }

    public User userDTOToUser(UserDTO userDTO) {
        String[] surnameAndName = userDTO.getName().split(" ");
        return new User(surnameAndName[1], surnameAndName[0], userDTO.getAddress());
    }
}

Terzo passo: creimo un repository per l'entity User

public interface UserRepository extends JpaRepository<User,Long> {
}

Quarto passo: creiamo il service col metodo findById

@Service
public class UserServiceImpl implements UserService {

    private static final Logger LOGGER = LoggerFactory.getLogger(UserServiceImpl.class);

    private UserRepository userRepository;

    private UserConverter userConverter;

    public UserServiceImpl(UserRepository userRepository, UserConverter userConverter) {
        this.userRepository = userRepository;
        this.userConverter = userConverter;
    }

    @Override
    public UserDTO findById(Long id) {
        return null;
    }
}

Come possiamo notare, il metodo per ora restituisce null. Implementeremo la funzionalità dopo aver eseguito il test.

Quinto passo: creiamo la classe di test per testare il metodo findById del service

Un buon test dovrebbe verificare il comportamento di un metodo quando vengono forniti in input valori validi e anche quando vengono forniti valori non validi.
Il service ha una dipendenza sia del repository sia del converter. L'implementazione del repository non ci interessa per cui sarebbe una buona idea mockarla. Per il converter, potremmo pensare di fare la stessa cosa, ma siccome è una classe molto banale, potremmo pensare di usare nel test la classe reale, senza però tirar su il contesto di Spring.

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Spy
    private UserConverter userConverter;

    private UserService userService;

    @BeforeEach
    public void init() {
        userService = new UserServiceImpl(userRepository, userConverter);
    }

    @Test
    public void findByIdSuccess() {
        User user = new User("Vincenzo", "Racca", "via Roma");
        user.setId(1L);
        when(userRepository.findById(anyLong())).thenReturn(Optional.of(user));

        UserDTO userDTO = userService.findById(1L);

        verify(userRepository, times(1)).findById(anyLong());
        
        assertNotNull(userDTO);
        String[] surnameAndName = userDTO.getName().split( " ");
        assertEquals(2, surnameAndName.length);
        assertEquals(user.getSurname(), surnameAndName[0]);
        assertEquals(user.getName(), surnameAndName[1]);
        assertEquals(user.getAddress(), userDTO.getAddress());
    }
}

Analizziamo il codice:

  1. @ExtendWith(MockitoExtension.class) ci permette di usare il contesto mockato della libreria Mockito. Inoltre @ExtendWith

corrisponde a @RunWith di JUnit 4. 2) Annotiamo con @Mock il repository perchè vogliamo che Mockito crei un'implementazione mockata dell'interfaccia. 3) Annotiamo con @Spy il converter per indicare a Mockito che vogliamo usare la classe reale. 4) Annotiamo con @BeforeEach il metodo con init che inizializza lo userService ogni volta che viene eseguito un metodo del test. Siccome abbiamo usato la constructor dependency injection e non la field, ci basta passare nel costruttore il repository mockato per creare il service. @BeforeEach corrisponde all'annotation @Before di JUnit 4.

Nota 1: Se avessimo utilizzato la field injection, avremmo invece dovuto annotare con @InjectMocks il campo userService per indicare a Mockito di iniettare i mock nella classe.

Analizziamo il test:

Ci aspettiamo che per qualsiasi id dato in ingresso, il repository restituisca uno User con name Vincenzo, surname Racca e address via Roma.
Per fare ciò, usiamo il metodo statico di Mockito when insieme a anyLong (per indicare qualsiasi id) e thenReturn per indicare il valore di ritorno che ci aspettiamo dal metodo. Quando chiamiamo il service, ci aspettiamo che ritorni uno UserDTO con name Racca Vincenzo e address via Roma.
Con verify verifichiamo che il service chiami 1 volta il findById del repository. Seguono poi varie asserzioni banali.
Eseguiamo il test: fallisce già alla chiama verify poiché il metodo del service non ha mai chiamato il repository;

Sesto passo: fixiamo il metodo

@Override
    public UserDTO findById(Long id) {
    User user = userRepository.findById(id).get();
    return userConverter.userToUserDTO(user);
    }

Il test ora passa, possiamo valutare di migliorare il codice senza dimenticare di ritestare il metodo.

Settimo passo: testiamo il metodo con un input non valido

Creiamo un altro caso di test in cui il client passa un id di uno User non esistente. Lato backend se lo User non verrà trovato, lanceremo un'eccezione per facilitare la lettura dei log:

public class UserNotFoundException extends RuntimeException {

    public UserNotFoundException(Long id) {
        super("User with id " + id + " not found!");
    }
}

Il test:

@Test
public void findByIdUnSuccess() {
    when(userRepository.findById(anyLong())).thenReturn(Optional.empty());

    UserNotFoundException exp = assertThrows(UserNotFoundException.class, () -> userService.findById(1L));
    assertEquals("User with id 1 not found!", exp.getMessage());
}

Molto banalmente diciamo a Mockito che per qualsiasi id, il repository non troverà nessun User. Con assertThrows ci facciamo ritornare l'eccezione che ci aspettiamo venga lanciata dal service e poi scriviamo un assert sul messaggio dell'eccezione. Questo tipo di test è possibile farlo con JUnit 5. Con JUnit 4 infatti potevamo verificare al più che il metodo lanciava l'eccezione, ma non si poteva andare avanti con le asserzioni una volta che l'errore veniva lanciato.
Ovviamente il test fallisce, aggiustiamo quindi il metodo.

@Test
@Override
public UserDTO findById(Long id) {
    Optional<User> user = userRepository.findById(id);
    if(user.isPresent()) {
        return userConverter.userToUserDTO(user.get());
    }
    else {
        throw new UserNotFoundException(id);
    }
}

Il test ora passa

Ottavo passo: rifattorizziamo il metodo

Visto che usiamo java 8, possiamo rifattorizzare il metodo usando gli stream. Inoltre aggiungiamo un log.

@Override
public UserDTO findById(Long id) {
    User user = userRepository.findById(id).orElseThrow(() -> {
        UserNotFoundException exp = new UserNotFoundException(id);
        LOGGER.error("Exception is UserServiceImpl.findById", exp);
        return exp;
    });
    return userConverter.userToUserDTO(user);
}

Ritestiamo il metodo: il test è ancora verde, possiamo concludere questo caso di test.

Conclusioni

Abbiamo visto brevemente come funziona il Test-Driven Development sviluppando degli Unit Test con JUnit 5 e con l'ausilio di Mockito per mockare le unità che non sono di interessa al test. Nel prossimo articolo continueremo lo sviluppo del requisito creando un Controller e scriveremo un Integration Test sfruttando Spring Boot, JUnit 5 e H2 come database in-memory.

Potete trovare il progetto completo con anche il test sulla create sul mio github a questo link: Spring Test

Successivo articolo sugli Integration Test: TDD e Integration Test

Articoli su Spring: Spring

Libri consigliati su Spring: