CodeGym /Java Blog /Random-IT /Tutto sui test unitari: tecniche, concetti, pratica
John Squirrels
Livello 41
San Francisco

Tutto sui test unitari: tecniche, concetti, pratica

Pubblicato nel gruppo Random-IT
Oggi non troverai un'applicazione che non sia coperta di test, quindi questo argomento sarà più rilevante che mai per gli sviluppatori alle prime armi: non puoi avere successo senza test. Consideriamo quali tipi di test vengono utilizzati in linea di principio, quindi studieremo in dettaglio tutto ciò che c'è da sapere sui test unitari. Tutto sui test unitari: tecniche, concetti, pratica - 1

Tipi di test

Cos'è una prova? Secondo Wikipedia: "Il test del software comporta l'esecuzione di un componente software o di un componente di sistema per valutare una o più proprietà di interesse". In altre parole, è un controllo della correttezza del nostro sistema in determinate situazioni. Bene, vediamo quali tipi di test ci sono in generale:
  • Test unitario: test il cui scopo è controllare ogni modulo del sistema separatamente. Questi test dovrebbero essere applicati alle parti atomiche più piccole del sistema, ad esempio i moduli.
  • Test di sistema: test di alto livello per verificare il funzionamento di una parte più ampia dell'applicazione o del sistema nel suo insieme.
  • Test di regressione: test utilizzato per verificare se nuove funzionalità o correzioni di bug influiscono sulla funzionalità esistente dell'applicazione o introducono vecchi bug.
  • Test funzionale: verifica se una parte dell'applicazione soddisfa i requisiti indicati nelle specifiche, nelle user story, ecc.

    Tipi di test funzionali:

    • White-box testing — Verifica se una parte dell'applicazione soddisfa i requisiti pur conoscendo l'implementazione interna del sistema;
    • Test black-box: verifica se una parte dell'applicazione soddisfa i requisiti senza conoscere l'implementazione interna del sistema.

  • Test delle prestazioni: test scritti per determinare le prestazioni del sistema o di parte del sistema sotto un determinato carico.
  • Test di carico: test progettati per verificare la stabilità del sistema sotto carichi standard e per trovare il carico massimo a cui l'applicazione funziona ancora correttamente.
  • Test di stress: test progettati per verificare le prestazioni dell'applicazione sotto carichi non standard e per determinare il carico massimo prima del guasto del sistema.
  • Test di sicurezza: test utilizzati per verificare la sicurezza del sistema (da hacker, virus, accesso non autorizzato a dati riservati e altri attacchi deliziosi).
  • Test di localizzazione: test della localizzazione dell'applicazione.
  • Test di usabilità: test volti a verificare l'usabilità, la comprensibilità, l'attrattiva e l'apprendibilità.
Tutto questo suona bene, ma come funziona in pratica? Semplice! Usiamo la piramide di test di Mike Cohn: Tutto sui test unitari: tecniche, concetti, pratica - 2questa è una versione semplificata della piramide: ora è divisa in parti ancora più piccole. Ma oggi non diventeremo troppo sofisticati. Prenderemo in considerazione la versione più semplice.
  1. Unità: questa sezione fa riferimento ai test unitari, che vengono applicati in diversi livelli dell'applicazione. Testano la più piccola unità divisibile della logica dell'applicazione. Ad esempio, classi, ma molto spesso metodi. Questi test di solito cercano il più possibile di isolare ciò che viene testato da qualsiasi logica esterna. Cioè, cercano di creare l'illusione che il resto dell'applicazione funzioni come previsto.

    Dovrebbero esserci sempre molti di questi test (più di qualsiasi altro tipo), poiché testano piccoli pezzi e sono molto leggeri, non consumando molte risorse (ovvero RAM e tempo).

  2. Integrazione: questa sezione fa riferimento ai test di integrazione. Questo test controlla parti più grandi del sistema. Cioè, combina diversi pezzi di logica (diversi metodi o classi) o controlla la correttezza dell'interazione con un componente esterno. Questi test sono generalmente più piccoli dei test unitari perché sono più pesanti.

    Un esempio di test di integrazione potrebbe essere la connessione a un database e il controllo della correttezza del funzionamento dei metodi per lavorare con esso.

  3. UI — Questa sezione fa riferimento ai test che controllano il funzionamento dell'interfaccia utente. Coinvolgono la logica a tutti i livelli dell'applicazione, motivo per cui sono anche chiamati test end-to-end. Di norma ce ne sono molti meno, perché sono i più ingombranti e devono controllare i percorsi (utilizzati) più necessari.

    Nell'immagine sopra, vediamo che le diverse parti del triangolo variano in dimensioni: approssimativamente le stesse proporzioni esistono nel numero di diversi tipi di test nel lavoro reale.

    Oggi daremo un'occhiata più da vicino ai test più comuni, gli unit test, poiché tutti gli sviluppatori Java che si rispetti dovrebbero essere in grado di utilizzarli a livello base.

Concetti chiave nel test unitario

La copertura del test (copertura del codice) è una delle misure principali di quanto bene viene testata un'applicazione. Questa è la percentuale del codice coperta dai test (0-100%). In pratica, molti perseguono questa percentuale come obiettivo. Questo è qualcosa con cui non sono d'accordo, poiché significa che i test iniziano ad essere applicati dove non sono necessari. Ad esempio, supponiamo di avere operazioni CRUD (create/get/update/delete) standard nel nostro servizio senza logica aggiuntiva. Questi metodi sono puramente intermediari che delegano il lavoro al livello che lavora con il repository. In questa situazione, non abbiamo nulla da testare, tranne forse se il metodo dato chiama un metodo DAO, ma è uno scherzo. Di solito vengono utilizzati strumenti aggiuntivi per valutare la copertura del test: JaCoCo, Cobertura, Clover, Emma, ​​ecc. Per uno studio più dettagliato di questo argomento, TDD sta per sviluppo guidato dai test. In questo approccio, prima di fare qualsiasi altra cosa, scrivi un test che controllerà il codice specifico. Questo risulta essere un test black-box: sappiamo che l'input è e sappiamo quale dovrebbe essere l'output. Ciò consente di evitare la duplicazione del codice. Lo sviluppo basato sui test inizia con la progettazione e lo sviluppo di test per ogni bit di funzionalità nell'applicazione. Nell'approccio TDD, per prima cosa creiamo un test che definisce e verifica il comportamento del codice. L'obiettivo principale di TDD è rendere il tuo codice più comprensibile, più semplice e privo di errori. Tutto sui test unitari: tecniche, concetti, pratica - 3L'approccio è costituito da quanto segue:
  • Scriviamo il nostro test.
  • Eseguiamo il test. Non sorprende che fallisca, dal momento che non abbiamo ancora implementato la logica richiesta.
  • Aggiungi il codice che determina il superamento del test (eseguiamo nuovamente il test).
  • Riformuliamo il codice.
TDD si basa sui test unitari, poiché sono gli elementi costitutivi più piccoli nella piramide dell'automazione dei test. Con i test unitari, possiamo testare la logica aziendale di qualsiasi classe. BDD sta per sviluppo guidato dal comportamento. Questo approccio si basa su TDD. Più specificamente, utilizza esempi di linguaggio semplice che spiegano il comportamento del sistema per tutti coloro che sono coinvolti nello sviluppo. Non approfondiremo questo termine, poiché riguarda principalmente tester e analisti aziendali. Un caso di test è uno scenario che descrive i passaggi, le condizioni specifiche e i parametri necessari per verificare il codice sottoposto a test. Un dispositivo di test è un codice che imposta l'ambiente di test in modo che abbia lo stato necessario affinché il metodo sottoposto a test venga eseguito correttamente. È un insieme predefinito di oggetti e il loro comportamento in condizioni specificate.

Fasi di test

Un test si compone di tre fasi:
  • Specificare i dati di prova (apparecchi).
  • Esercitare il codice sotto test (chiamare il metodo testato).
  • Verificare i risultati e confrontarli con i risultati attesi.
Tutto sui test unitari: tecniche, concetti, pratica - 4Per garantire la modularità del test, è necessario isolarsi dagli altri livelli dell'applicazione. Questo può essere fatto usando stub, mock e spie. I mock sono oggetti che possono essere personalizzati (ad esempio, su misura per ogni test). Ci permettono di specificare cosa ci aspettiamo dalle chiamate di metodo, cioè le risposte attese. Usiamo oggetti fittizi per verificare che otteniamo ciò che ci aspettiamo. Gli stub forniscono una risposta codificata alle chiamate durante il test. Possono anche memorizzare informazioni sulla chiamata (ad esempio, parametri o numero di chiamate). Questi sono a volte indicati come spie. A volte le persone confondono i termini stub e mock: la differenza è che uno stub non controlla nulla, simula solo un dato stato. Un finto è un oggetto che ha delle aspettative. Ad esempio, che un determinato metodo deve essere chiamato un certo numero di volte. In altre parole,

Ambienti di prova

Quindi, ora al punto. Esistono diversi ambienti di test (framework) disponibili per Java. I più popolari sono JUnit e TestNG. Per la nostra recensione qui, usiamo: Tutto sui test unitari: tecniche, concetti, pratica - 5Un test JUnit è un metodo in una classe che viene utilizzato solo per il test. La classe ha solitamente lo stesso nome della classe che verifica, con "Test" aggiunto alla fine. Ad esempio, CarService -> CarServiceTest. Il sistema di compilazione Maven include automaticamente tali classi nell'ambito del test. In effetti, questa classe è chiamata classe di test. Esaminiamo brevemente le annotazioni di base:

  • @Test indica che il metodo è un test (in pratica, un metodo contrassegnato con questa annotazione è uno unit test).
  • @Before indica un metodo che verrà eseguito prima di ogni test. Ad esempio, per popolare una classe con dati di test, leggere dati di input, ecc.
  • @After viene utilizzato per contrassegnare un metodo che verrà chiamato dopo ogni test (ad esempio per cancellare i dati o ripristinare i valori predefiniti).
  • @BeforeClass è posizionato sopra un metodo, analogo a @Before. Ma tale metodo viene chiamato solo una volta prima di tutti i test per la classe data e quindi deve essere statico. Viene utilizzato per eseguire operazioni che richiedono più risorse, come la creazione di un database di test.
  • @AfterClass è l'opposto di @BeforeClass: viene eseguito una volta per la classe data, ma solo dopo tutti i test. Viene utilizzato, ad esempio, per cancellare risorse persistenti o disconnettersi da un database.
  • @Ignore indica che un metodo è disabilitato e verrà ignorato durante l'esecuzione complessiva del test. Viene utilizzato in varie situazioni, ad esempio, se il metodo di base è stato modificato e il test non è stato ancora rielaborato per adattarsi alle modifiche. In tali casi, è consigliabile aggiungere anche una descrizione, ad esempio @Ignore("Some description").
  • @Test(expected = Exception.class) viene utilizzato per i test negativi. Si tratta di test che verificano come si comporta il metodo in caso di errore, ovvero il test si aspetta che il metodo generi qualche tipo di eccezione. Tale metodo è indicato dall'annotazione @Test, ma con un'indicazione di quale errore rilevare.
  • @Test(timeout = 100) verifica che il metodo venga eseguito in non più di 100 millisecondi.
  • @Mock viene utilizzato sopra un campo per assegnare un oggetto fittizio (questa non è un'annotazione JUnit, ma proviene invece da Mockito). Se necessario, impostiamo il comportamento del mock per una situazione specifica direttamente nel metodo di test.
  • @RunWith(MockitoJUnitRunner.class) è posizionato sopra una classe. Questa annotazione indica a JUnit di richiamare i test nella classe. Esistono vari corridori, inclusi questi: MockitoJUnitRunner, JUnitPlatform e SpringRunner. In JUnit 5, l'annotazione @RunWith è stata sostituita con la più potente annotazione @ExtendWith.
Diamo un'occhiata ad alcuni metodi utilizzati per confrontare i risultati:

  • assertEquals(Object expecteds, Object actuals) — controlla se gli oggetti passati sono uguali.
  • assertTrue(boolean flag) — controlla se il valore passato è vero.
  • assertFalse(flag booleano) — controlla se il valore passato è falso.
  • assertNull(Object object) — controlla se l'oggetto passato è nullo.
  • assertSame(Object firstObject, Object secondObject) — controlla se i valori passati si riferiscono allo stesso oggetto.
  • assertQuello(T t, Matcher accoppiatore) — Controlla se t soddisfa la condizione specificata in matcher.
AssertJ fornisce anche un utile metodo di confronto: assertThat(firstObject).isEqualTo(secondObject) . Qui ho menzionato i metodi di base: gli altri sono variazioni di quanto sopra.

Test in pratica

Ora diamo un'occhiata al materiale di cui sopra in un esempio specifico. Verificheremo il metodo di aggiornamento di un servizio. Non considereremo il livello DAO, poiché stiamo usando quello predefinito. Aggiungiamo uno starter per i test:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-test</artifactId>
   <version>2.2.2.RELEASE</version>
   <scope>test</scope>
</dependency>
E qui abbiamo la classe di servizio:

@Service
@RequiredArgsConstructor
public class RobotServiceImpl implements RobotService {
   private final RobotDAO robotDAO;

   @Override
   public Robot update(Long id, Robot robot) {
       Robot found = robotDAO.findById(id);
       return robotDAO.update(Robot.builder()
               .id(id)
               .name(robot.getName() != null ? robot.getName() : found.getName())
               .cpu(robot.getCpu() != null ? robot.getCpu() : found.getCpu())
               .producer(robot.getProducer() != null ? robot.getProducer() : found.getProducer())
               .build());
   }
}
Riga 8: estrarre l'oggetto aggiornato dal database. Righe 9-14 — crea un oggetto tramite il builder. Se l'oggetto in entrata ha un campo, impostalo. In caso contrario, lasceremo ciò che è nel database. Ora guarda il nostro test:

@RunWith(MockitoJUnitRunner.class)
public class RobotServiceImplTest {
   @Mock
   private RobotDAO robotDAO;

   private RobotServiceImpl robotService;

   private static Robot testRobot;

   @BeforeClass
   public static void prepareTestData() {
       testRobot = Robot
               .builder()
               .id(123L)
               .name("testRobotMolly")
               .cpu("Intel Core i7-9700K")
               .producer("China")
               .build();
   }

   @Before
   public void init() {
       robotService = new RobotServiceImpl(robotDAO);
   }
Linea 1: il nostro Runner. Riga 4: isoliamo il servizio dal livello DAO sostituendo un mock. Riga 11: impostiamo un'entità di test (quella che useremo come cavia) per la classe. Riga 22: impostiamo l'oggetto servizio, che è ciò che testeremo.

@Test
public void updateTest() {
   when(robotDAO.findById(any(Long.class))).thenReturn(testRobot);
   when(robotDAO.update(any(Robot.class))).then(returnsFirstArg());
   Robot robotForUpdate = Robot
           .builder()
           .name("Vally")
           .cpu("AMD Ryzen 7 2700X")
           .build();

   Robot resultRobot = robotService.update(123L, robotForUpdate);

   assertNotNull(resultRobot);
   assertSame(resultRobot.getId(),testRobot.getId());
   assertThat(resultRobot.getName()).isEqualTo(robotForUpdate.getName());
   assertTrue(resultRobot.getCpu().equals(robotForUpdate.getCpu()));
   assertEquals(resultRobot.getProducer(),testRobot.getProducer());
}
Qui vediamo che il test ha tre chiare divisioni: Righe 3-9 — che specificano le partite. Riga 11: esecuzione del codice sottoposto a test. Righe 13-17: controllo dei risultati. Più in dettaglio: Righe 3-4 — imposta il comportamento per il mock DAO. Riga 5: imposta l'istanza che aggiorneremo in cima al nostro standard. Riga 11: usa il metodo e prendi l'istanza risultante. Riga 13: controlla che non sia nullo. Riga 14: confronta l'ID del risultato e gli argomenti del metodo forniti. Riga 15: controlla se il nome è stato aggiornato. Riga 16: vedere il risultato della CPU. Riga 17: non abbiamo specificato questo campo nell'istanza, quindi dovrebbe rimanere lo stesso. Controlliamo questa condizione qui. Eseguiamolo:Tutto sui test unitari: tecniche, concetti, pratica - 6Il test è verde! Possiamo tirare un sospiro di sollievo :) In sintesi, il testing migliora la qualità del codice e rende il processo di sviluppo più flessibile e affidabile. Immagina quanto impegno ci vuole per riprogettare il software che coinvolge centinaia di file di classe. Quando disponiamo di test unitari scritti per tutte queste classi, possiamo eseguire il refactoring con sicurezza. E, cosa più importante, ci aiuta a trovare facilmente i bug durante lo sviluppo. Ragazzi e ragazze, è tutto quello che ho oggi. Metti mi piace e lascia un commento :)
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION