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

Progettazione e Sviluppo del Software

C.D.L. Tecnologie dei Sistemi Informatici

Danilo Pianini — danilo.pianini@unibo.it

Gianluca Aguzzi — gianluca.aguzzi@unibo.it

Angelo Filaseta — angelo.filaseta@unibo.it

Compiled on: 2025-12-05 — versione stampabile

back

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 6
  • 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 {
    private int smallest = Integer.MAX_VALUE;
    private 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;
        }
    }

    public int getSmallest() {
        return this.smallest;
    }

    public int getLargest() {
        return this.largest;
    }
}
  • 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.getSmallest());
        System.out.println("Largest: " + nf.getLargest());
    }
}
  • 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.getSmallest() +
            " - largest: " + numFinder.getLargest());
        // 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.getSmallest() +
            " - largest: " + numFinder.getLargest());
        // 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.getSmallest() +
            " - largest: " + numFinder.getLargest());
    }
}
  • 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.getSmallest() == 4 && numFinder.getLargest() == 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.getSmallest() == 10 && numFinder.getLargest() == 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.getSmallest() == 1 && numFinder.getLargest() == 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…)

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

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 6

  • Scriviamo un test per la classe BuggyNumFinder
import org.junit.jupiter.api.Disabled;
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.getSmallest());
        Assertions.assertEquals(25, numFinder.getLargest());
    }

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

    @Disabled // fails otherwise
    @Test
    public void positiveMonotonicallyDecreasingSequence() {
        int[] input1 = new int[]{ 30, 20, 10 };
        numFinder.find(input1);
        Assertions.assertEquals(10, numFinder.getSmallest());
        Assertions.assertEquals(30, numFinder.getLargest());
    }
}
  • La classe è una test suite che raccoglie test case associati all’unit under test (UUT) BuggyNumFinder
  • La classe usa l’API della libreria JUnit 6
    • 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 6 e Gradle

  • build.gradle.kts: supporto per i test
plugins {
    id("java")
}

repositories { // Where to search for dependencies
    mavenCentral()
}
dependencies {
    // JUnit API and testing engine
    testImplementation(platform("org.junit:junit-bom:6.0.1"))
    testImplementation("org.junit.jupiter:junit-jupiter-api")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
}

tasks.test {
    useJUnitPlatform()
}
$ ./gradlew test 
$ ./gradlew test --tests it.unibo.*.Buggy*Test # filtra i test da eseguire

JUnit 6: 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 6):
      • org.junit.jupiter:junit-jupiter-api:_ API per scrivere test
      • org.junit.jupiter:junit-jupiter-engine:_ engine corrispondente

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, …

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

Progettazione e Sviluppo del Software

C.D.L. Tecnologie dei Sistemi Informatici

Danilo Pianini — danilo.pianini@unibo.it

Gianluca Aguzzi — gianluca.aguzzi@unibo.it

Angelo Filaseta — angelo.filaseta@unibo.it

Compiled on: 2025-12-05 — versione stampabile

back