Unit Testing e Test-Driven Development in Java con JUnit 5

Progettazione e Sviluppo del Software

C.D.L. Tecnologie dei Sistemi Informatici

Gianluca Aguzzi — gianluca.aguzzi@unibo.it

Angelo Filaseta — angelo.filaseta@unibo.it

versione stampabile

Outline

Goal della lezione

  • Introdurre il tema del testing del software
  • Introdurre strumenti e processi per il testing di progetti Java

Argomenti

  • Testing del software
  • JUnit 5
  • Il meccanismo delle Annotazioni in Java
  • Sviluppo guidato dai test (Test-Driven Development)

Introduzione al testing

Problema: verifica della correttezza dei programmi

  • Si consideri la seguente funzionalità: trovare il più piccolo e il più grande elemento in un array di interi
public class BuggyNumFinder {
    int smallest = Integer.MAX_VALUE;
    int largest = Integer.MIN_VALUE;

    public void find(int[] numbers){
        for(int n : numbers){
            if(n < smallest) smallest = n;
            else if(n > largest) largest = n;
        }
    }
}
  • Domanda: è corretto (ovvero, privo di difetti o bug)?

Un primo test

  • Scriviamo un programma che ci permetta di esercitare la funzionalità
import java.util.Scanner;

public class BuggyNumFinderProgram {
    public static void main(String[] args){
        // Lettura input dall'utente
        Scanner in = new Scanner(System.in);
        System.out.print("Quanti numeri? ");
        int n = in.nextInt();
        int[] array = new int[n];
        for(int i=0; i < n; i++) {
            System.out.print(i + "-esimo numero: ");
            array[i] = in.nextInt();
        }
        in.close();
        // Costruzione della UUT (Unit Under Test)
        BuggyNumFinder nf = new BuggyNumFinder();
        // Esecuzione della funzionalità
        nf.find(array);
        // Stampa dei risultati
        System.out.println("Smallest: " + nf.smallest);
        System.out.println("Largest: " + nf.largest);
    }
}
  • Ogni esecuzione sarebbe un test manuale
  • Vari test case possono essere verificati

Proviamo ad automatizzare i test

  • Scriviamo un programma che eserciti la funzionalità
public class BuggyNumFinderProgram2 {
    public static void main(String[] args){
        // Test case 1: some numbers
        BuggyNumFinder numFinder = new BuggyNumFinder();
        int[] input1 = new int[]{ 4, 25, 7, 9 };
        numFinder.find(input1);
        System.out.println("Apply to { 4, 25, 7, 9 } => " +
            " - smallest: " + numFinder.smallest +
            " - largest: " + numFinder.largest);
        // Test case 2: monotonically increasing sequence
        int[] input2 = new int[]{ 10, 20, 30 };
        numFinder.find(input2);
        System.out.println("Apply to { 10, 20, 30 }  => " +
            " - smallest: " + numFinder.smallest +
            " - largest: " + numFinder.largest);
        // Test case 3: monotonically decreasing sequence
        numFinder = new BuggyNumFinder();
        int[] input3 = new int[]{ 4, 3, 2, 1 };
        numFinder.find(input3);
        System.out.println("Apply to { 4, 3, 2, 1 }  => " +
            " - smallest: " + numFinder.smallest +
            " - largest: " + numFinder.largest);
    }
}
  • Il controllo dei risultati è ancora manuale

Proviamo ad automatizzare ulteriormente i test

  • Scriviamo un programma che testi la funzionalità
public class BuggyNumFinderTest {
    public static void main(String[] args){
        // Test case 1: some numbers
        BuggyNumFinder numFinder = new BuggyNumFinder();
        int[] input1 = new int[]{ 4, 25, 7, 9 };
        numFinder.find(input1);
        if(!(numFinder.smallest == 4 && numFinder.largest == 25)){
            System.out.println("Test Case #1 failed");
        }
        // Test case 2: monotonically increasing sequence
        int[] input2 = new int[]{ 10, 20, 30 };
        numFinder.find(input2);
        if(!(numFinder.smallest == 10 && numFinder.largest == 30)){
            System.out.println("Test Case #2 failed");
        }
        // Test case 3: monotonically decreasing sequence
        numFinder = new BuggyNumFinder();
        int[] input3 = new int[]{ 4, 3, 2, 1 };
        numFinder.find(input3);
        if(!(numFinder.smallest == 1 && numFinder.largest == 4)){
            System.out.println("Test Case #3 failed");
        }
    }
}
  • Controllo automatico attraverso confronto di risultato atteso vs. risultato effettivo
    • ma il codice ha ancora vari problemi di qualità (no isolamento, ripetizioni…)

Introduzione al testing: concetti preliminari

Errore vs. fallimento vs. difetto/bug

  • Fallimento (problema, anomalia): differenza osservata fra un risultato attuale rispetto a un risultato atteso
  • Falla (difetto, bug): causa di un’anomalia
    • Un fallimento può essere causato da più bug
    • Un singolo bug può causare varie anomalie
    • Un bug potrebbe rimanere latente, se non osserviamo anomalie
  • Errore: l’azione che ha causato una falla
    • ad esempio, una distrazione del programmatore
  • Quindi la progressione è Errore $\to$ Difetto/Bug $\to$ Fallimento

Una prima definizione di testing

  • Il testing del software è quell’attività di ricerca di anomalie al fine di localizzare e rimuovere i difetti nel software

Verifica vs. validazione

  • Verifica: controllo di correttezza del software rispetto a specifiche
    • “Have we built the thing right?” (abbiamo costruito [il software] nel modo giusto?)
    • “Un progetto senza specifiche non può essere giusto o sbagliato, ma solo sorprendente!”
    • Tuttavia, le specifiche potrebbero essere incomplete o sbagliate
  • Validazione: controllo dell’adeguatezza del software rispetto alle aspettative degli stakeholder
    • “Have we built the right thing?"(abbiamo costruito [il software] giusto?)

Definizione di “software testing”

Definizioni di software testing

  • “An investigation conducted to provide stakeholders with information about the quality of the software product or service under test” (Cem Kaner/Wikipedia)
  • Activity in which a system or component is executed under specified conditions, the results are observed or recorded, and an evaluation is made of some aspect of the system or component” (ISO/IEC/IEEE 24765:2010 Systems and software engineering – Vocabulary)
  • “The process consisting of all lifecycle activities, both static and dynamic, concerned with planning, preparation and evaluation of software products and related work products to determine that they satisfy specified requirements, to demonstrate that they are fit for purpose and to detect defects.” (ISTQB–International Software Testing Qualifications Board)
  • “The overall process of planning, preparing, and carrying out a suite of different types of tests designed to validate a system under development, in order to achieve an acceptable level of quality and to avoid unacceptable risks

Elementi chiave

  • Attività/processo
    • raccolta dei requisiti, preparazione dei test, esecuzione dei test, …
  • Varietà di tipologie
    • testing manuale, testing automatico, testing di unità, testing di integrazione, …
  • Verifica e validazione
    • si possono formalizzare dei “criteri di accettazione”
  • Riduzione dei rischi
    • il testing porta ad evitare problemi in futuro
  • Vari stakeholder
    • diversi ruoli coinvolti (programmatore, tester, cliente, integrator, …)

Testing in pratica

JUnit 5

  • Scriviamo un test per la classe BuggyNumFinder
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Assertions;

public class BuggyNumFinderJUnitTest {
    BuggyNumFinder numFinder;

    @BeforeEach 
    void setup(){
        System.out.println("Setup");
        numFinder = new BuggyNumFinder();
    }

    @Test
    public void someNumbers() {
        int[] input1 = new int[]{ 4, 25, 7, 9 };
        numFinder.find(input1);
        Assertions.assertEquals(4, numFinder.smallest);
        Assertions.assertEquals(25, numFinder.largest);
    }

    @Test
    public void positiveMonotonicallyIncreasingSequence() {
        int[] input1 = new int[]{ 10, 20, 30 };
        numFinder.find(input1);
        Assertions.assertEquals(10, numFinder.smallest);
        Assertions.assertEquals(30, numFinder.largest);
    }

    @Test
    public void positiveMonotonicallyDecreasingSequence() {
        int[] input1 = new int[]{ 30, 20, 10 };
        numFinder.find(input1);
        Assertions.assertEquals(10, numFinder.smallest);
        Assertions.assertEquals(30, numFinder.largest);
    }
}
  • La classe è una test suite che raccoglie test case associati all’unit under test (UUT) BuggyNumFinder
  • La classe usa l’API della libreria JUnit 5
    • cf. import org.junit.jupiter.api.*
  • Ogni metodo annotato con @Test denota uno (o più) test case
  • Il metodo annotato con @BeforeEach viene eseguito prima di ogni test case
    • Serve pr gestire il contesto
  • org.junit.jupiter.api.Assertions è una classe che fornisce vari metodi per esprimere asserzioni
    • Assertions.assertEquals(e, a) ha successo se l’oggetto atteso e è uguale all’oggetto attuale a

Annotazioni

  • Diciture come @Test e @BeforeEach sono delle annotazioni
  • Le annotazione sono un meccanismo di Java per fornire informazioni aggiuntive ai costrutti del programma
    • Possono essere analizzate a tempo di compilazione (ad es., dal compilatore di Java) o a tempo d’esecuzione da altri componenti software
  • Un’annotazione ha formato @NomeAnnotazione
    • Alcune annotazioni possono avere dei parametri
    @SomeAnnotation(field1 = 88, field2 = "someString")
    
  • Quali costrutti del linguaggio Java si possono annotare?
    • dichiarazioni di classe
    • dichiarazioni di campi, metodi
  • Non vediamo come implementare nuove annotazioni e come processarle, al momento
    • Ma sappiamo che esistono e che si possono usare

Separazione tra sorgenti di test e sorgenti dell’applicazione “principale”

  • E’ buona prassi tenere i sorgenti di test e i sorgenti dell’applicazione “principale” separati
    • Sorgenti dell’applicazione principale sotto src/main/java/
    • Sorgenti di test sotto src/test/java
  • Questa prassi è così consolidata che è diventata una convenzione di organizzazione di sorgenti in progetti Java
    • Strumenti di build come Maven e Gradle utilizzano tale convenzione: dunque, se mettiamo i sorgenti (di test e dell’applicazione principale) nel posto giusto, le cose funzioneranno senza bisogno di configurazione (principio convention over configuration)
  • Altra prassi è quella di dichiarare le classi di test nello stesso package delle classi di produzione
    • In questo modo, i test possono accedere ai membri package-private

JUnit 5 e Gradle

  • build.gradle.kts: supporto per i test
plugins {
    java // JavaPlugin aggiunge un source set "test" e un task "test"
}

repositories {
    mavenCentral() // occorre per risolvere le dipendenze di JUnit
}

dependencies {
    // Dipendenza per scrivere i test
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.1")
    // Dipendenza per eseguire i test
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.1")
}

tasks.test {
    useJUnitPlatform() // configura il task "test" per usare la JUnit Platform
    testLogging { events("passed", "skipped", "failed") } // per stampare l'esito di ogni test
}
$ ./gradlew test 
$ ./gradlew test --tests it.unibo.*.Buggy*Test # filtra i test da eseguire

JUnit 5: architettura

  • JUnit Platform: piattaforma comune per l’esecuzione dei test attraverso l’astrazione di engine
    • org.junit.platform:junit-platform-engine:_
    • org.junit.platform:junit-platform-launcher:_ API usata dai build tool e dagli IDE
      • i task Gradle interagiranno con tale componente
  • varie librerie di testing consentono di scrivere test per l’esecuzione sulla JUnit Platform
    • JUnit Jupiter (JUnit 5):
      • org.junit.jupiter:junit-jupiter-api:_ API per scrivere test
      • org.junit.jupiter:junit-jupiter-engine:_ engine corrispondente
    • JUnit Vintage (JUnit 4):
      • org.junit.vintage:junit-vintage-engine:_ engine per test JUnit 4

Unit testing

  • Lo unit testing è la pratica di testare una unità funzionale in isolamento
  • In pratica, facciamo unit testing quando testiamo una singola classe
    • cioè, la classe è l’unità
  • Se abbiamo una classe SomeClass, scriveremo un’altra classe SomeClassTest che conterrà metodi di test per esercitare la funzionalità offerta da SomeClass
  • Lo unit testing si distingue dall’integration testing che invece è volto a verificare la correttezza dell’integrazione di diverse unità

Esempio: classe di test

  • Supponiamo di voler testare la funzionalità della classe String
  • Possiamo definire una classe di test StringTest
    • sorgente sotto src/test/java/
public class StringTest {

}
  • E’ una classe normale.
    • Nel contesto di tale classe, String è la nostra Unit Under Test (UUT)
  • All’interno, possiamo aggiungere dei metodi di test (con opportune annotazioni)
    • Quindi, una classe modella una suite di test (case) correlati (ovvero una test suite)

Esempio: metodi di test


public class StringTest {
    @Test public void checkContainsWithSubstring() {
      String s = "hello world";
      String substring = "world";
      boolean contained = s.contains(substring);
      Assertions.assertTrue(contained);
    }
  
  @Test public void checkContainsWithNoSubstring() {
      String s = "hello world";
      String notSubstring = "bar";
      boolean contained = s.contains(notSubstring);
      Assertions.assertFalse(contained);
    }
  }

Test case (casi di test) e metodi di test

Test case

(ISTQB Glossary) Un test case è

  • un insieme di (1) valori di input; (2) precondizioni e postcondizioni d’esecuzione
  • sviluppato per un particolare obiettivo o “condizione di test”, come ad esempio per esercitare un percorso d’esecuzione di un programma o verificare la conformità con uno specifico requisito.

Metodi di test vs. test case

  • un metodo di test (ovvero, un metodo void annotato con @Test) può esprimere uno o più test case
    @Test public void checkContains(){
      String s = "hello world";
      Assertions.assertTrue(s.contains("hello") && !s.contains("bar"));
    } // questo metodo di test copre due test case
  • cf. test parametrizzati (nb: richiedono la dipendenza junit-jupiter-params)
@ParameterizedTest @ValueSource(strings = {"hello", "world", " ", ""})
public void checkContainsParameterized(String substr){
    String s = "hello world";
    Assertions.assertTrue(s.contains(substr));
}

Pattern AAA (Arrange - Act - Assert)

  • E’ il tipico pattern di organizzazione dei metodi di test
// Arrange: imposta la UUT (unit under test) e il contesto del test
String s = "hello world";
String substring = "world";
// Act: esercita la funzionalità
boolean contained = s.contains(substring);
// Assert: asserisce l'aspettativa rispetto al risultato effettivo
Assertions.assertTrue(contained);

Asserzioni

import static org.junit.jupiter.api.Assertions.*;

public class OnAssertions {
    @Test
    public void workingWithAssertions() {
        assertFalse("hello" == new String("hello"));
        assertEquals("hello", new String("hello"));
        assertNotSame("hello", new String("hello"));

        assertTrue(new int[] { 1, 2, 3 } != new int[] { 1, 2, 3 });
        assertNotEquals(new int[] { 1, 2, 3 }, new int[] { 1, 2, 3 });
        assertArrayEquals(new int[] { 1, 2, 3 }, new int[] { 1, 2, 3 });

        Object o = null;
        assertNull(o);
        assertNotNull(new int[]{ });
    }
}
  • Note
    • import static C.m; consente di importare nello scope il metodo statico m della classe C
    • E’ possibile importare tutti i membri statici con istruzione import static C.*;

Ciclo di vita dei test

  • Di default, JUnit crea una nuova istanza della classe di test prima di invocare ogni metodo di test
  • Esistono delle annotazioni per agganciarci alle varie fasi del ciclo di vita di nostri test
    • @BeforeAll, @AfterAll: da applicare a un metodo static; tale metodo verrà eseguito una sola volta prima o dopo l’esecuzione di tutti i test della classe di test
    • @BeforeEach, @AfterEach: da applicare a un metodo d’istanza; tale metodo verrà eseguito prima o dopo ogni test case

Introduzione allo sviluppo guidato dai test

Test-Driven Development (TDD)

  • Il testing non è da pensare solo come attività da svolgersi dopo la fase di sviluppo
  • I test possono essere usati per guidare l’attività di progettazione (design)
    • ovvero, prima dell’implementazione effettiva
  • Come funziona? Si segue il processo RED-GREEN-REFACTOR
    1. RED: si scrive un test per catturare la funzionalità che si vuole realizzare (visto che è ancora da implementare, questa fallirà)
    2. GREEN: si implementa la funzionalità fino a che il test passa
    3. REFACTOR: eventualmente, si migliora l’implementazione
      • rieseguendo i test, saremo sicuri che questi interventi non causano regressioni

Esempio TDD: step 1 (RED)

  • Si supponga di voler progettare/implementare la funzionalità di un televisore (diciamo, una classe Tv)

    • ad es. che modelli accensione/spegnimento e switch del canale
  • Prima di implementare la classe Tv, scriviamo un test associato

    public class TvTest {
        @Test
        public void testTurnOnWhenOff() {
            Tv tv = new Tv();
            Assumptions.assumeTrue(!tv.isOn());
            tv.turnOn();
            Assertions.assertTrue(tv.isOn());
        }
    }
    
    • Nota: Assumptions.assumeTrue(x) esprime una assunzione sul test, ovvero una precondizione che, se non verificata, farà sì che il test venga saltato (SKIPPED) dall’esecutore dei test
  • Assicuriamoci che tale test compili. Per farlo, dobbiamo inizializzare la classe Tv

public class Tv {
    public void turnOn(){  }
    public boolean isOn(){ return false; }
}
  • Ora dobbiamo lanciare i test: dobbiamo vedere RED (fallimento)

Esempio TDD: step 2 (GREEN)

  • Dopo che abbiamo implementato e visto fallire il test, procediamo ad implementare la funzionalità
    • scriviamo just enough code che consenta il test di passare
public class Tv {
    boolean on;

    public void turnOn(){ 
        this.on = true;
    }

    public boolean isOn(){ 
        return this.on;
    }
}
  • Eseguiamo i test per assicurarci che la funzionalità è stata implementata correttamente: dobbiamo vedere GREEN (cioè devono passare tutti i test)

Esempio TDD: step 3 (REFACTOR)

  • Dopo che la funzionalità è stata implementata correttamente, possiamo valutare di applicare miglioramenti al codice
    • sia della UUT, sia del test stesso
  • Per ogni modifica, rieseguiamo i test per assicurarci di non aver introdotto regressioni
    • Una regressione si ha quando si introduce un difetto su un componente che prima non presentava tale difetto
public class Tv {
    boolean on;

    public Tv(){
        this.on = false;
    }

    public void turnOn(){ 
        this.on = true;
    }

    public boolean isOn(){ 
        return this.on;
    }
}
    public class TvTest {
        Tv tv;

        @BeforeEach
        public void setUp(){ tv = new Tv(); }

        @Test
        public void testTurnOnWhenOff() {
            Assumptions.assumeTrue(!tv.isOn());
            tv.turnOn();
            Assertions.assertTrue(tv.isOn());
        }
    }

Esempio TDD: dopo REFACTOR?

  • Terminata un’iterazione RED-GREEN-REFACTOR, si può procedere a un’altra iterazione, ovvero alla realizzazione di un incremento di funzionalità
  • Potete provare voi stessi
    • funzionalità di spegnimento: turnOff()
    • modellazione del canale Channel e dello switch dei canali switch(Channel)

E ora?

Un nuovo strumento nella vostra “toolbox” di sviluppatori software

  • Consolideremo la vostra familiarità con lo unit testing già in laboratorio

Tanto altro …

  • Il tema del testing del software è molto ampio
  • Esistono figure professionali specializzate nel testing
    • cf. Google Test Engineer (TE)
  • Esistono tanti altri concetti importanti
    • test double, ispezione, …
  • Esistono tantissime tecniche di testing
    • property-based testing, mutation testing, …
  • Esistono tantissimi strumenti a supporto di varie tipologie di test
    • Cucumber, Mountebank, Akka TestKit
  • C’è molta letturatura sul testing
    • pattern per il testing, …