Salta al contenuto
Note per Studenti Software testing - progettazione dei casi di test e JUnit

Software testing - progettazione dei casi di test e JUnit

In questa pagina 9

Definizione (testing). Eseguire un programma con dati artificiali per due scopi: mostrare allo sviluppatore e al cliente che soddisfa i requisiti (validation testing) e scoprire situazioni in cui il comportamento è scorretto, indesiderato o non conforme alla specifica (defect testing). Il testing può rivelare la presenza di errori, non la loro assenza (Dijkstra).

Per un software su misura serve almeno un test per ogni requisito del documento dei requisiti (Ingegneria dei requisiti - requisiti funzionali e non funzionaliUn requisito è la descrizione di un servizio o di un vincolo del sistema, scritta ad astrazione diversa per chi decide (requisiti utente, in linguaggio naturale) e per chi realizza (requisiti di sistema, strutturati e dettagliati); si dividono in funzionali (servizi e comportamento), non funzionali (proprietà e vincoli: prodotto, organizzativi, esterni, da scrivere con una misura verificabile) e di dominio; il processo di ingegneria dei requisiti è iterativo (elicitazione con interviste, etnografia, storie e scenari; analisi e negoziazione; specifica; validazione; gestione del cambiamento con identificatori e tracciabilità) e produce il documento dei requisiti, che dice cosa fa il sistema e non come; i modelli UML (contesto, casi d'uso, sequenza, classi, stati) aiutano a chiarire i requisiti.Ingegneria dei requisiti - requisiti funzionali e non funzionali →); per un prodotto generico si provano tutte le funzioni e le loro combinazioni. Nel validation testing un test riuscito mostra che il sistema funziona come previsto; nel defect testing un test riuscito è quello che fa fallire il sistema e scopre un difetto, e può usare input volutamente strani. È la parte "validazione" delle attività fondamentali (Ingegneria del software - prodotto software e processo di sviluppoL'ingegneria del software (IdS) è la disciplina che copre tutti gli aspetti della produzione di software professionale, dalle prime specifiche alla manutenzione: il software sono programmi più documentazione, può essere generico (venduto sul mercato) o specifico (su misura per un cliente), e un buon prodotto ha quattro attributi (manutenibilità, affidabilità e sicurezza, efficienza, accettabilità); le attività fondamentali sono specifica, sviluppo, validazione ed evoluzione; i costi sono dominati dalla manutenzione e un difetto di requisiti scoperto dopo il rilascio costa fino a 100 volte uno scoperto subito; i tre problemi di fondo sono incertezza, irreversibilità e complessità; verifica ("costruiamo bene il prodotto?") e validazione ("costruiamo il prodotto giusto?") sono cose diverse.Ingegneria del software - prodotto software e processo di sviluppo →); il costo dei difetti scoperti tardi cresce fino a 100 volte. Il materiale su cui è basata la nota è il Sommerville cap. 8 (nella dispensa del corso) e le slide su JUnit 4; tutto il codice è compilato e i test sono stati eseguiti (vedi la nota alla fine sul modo di esecuzione).

Verifica, validazione, ispezioni

attività domanda come
verifica costruiamo il prodotto nel modo giusto? (conforme alla specifica) test, ispezioni
validazione costruiamo il prodotto giusto? (fa ciò che l'utente vuole) test con scenari reali, prove dell'utente
  • Ispezioni (verifica statica): si esamina il codice o qualunque altro documento (requisiti, progetto) senza eseguirlo; trovano errori anche in versioni incomplete, e un errore non ne maschera un altro; non verificano proprietà non funzionali (prestazioni, usabilità) né la conformità ai requisiti reali.
  • Test (verifica dinamica): si esegue il programma e si osserva il comportamento.
  • Le due tecniche sono complementari. L'affidamento che si cerca (che il sistema sia "adatto allo scopo") dipende dallo scopo (criticità), dalle aspettative degli utenti e dall'ambiente di mercato.

Fasi del testing

fase chi obiettivo
di sviluppo il team di sviluppo trovare difetti: unit, componenti, sistema
di rilascio (release testing) un team separato verificare che la versione soddisfi i requisiti e sia abbastanza buona per l'uso esterno
dell'utente utenti o clienti, nel proprio ambiente alpha, beta, accettazione

Test di sviluppo

livello che cosa si prova attenzione a
unit test funzioni e metodi, classi, componenti con interfaccia definita, in isolamento le funzionalità degli oggetti; tutte le operazioni, tutti gli attributi, tutti gli stati
test dei componenti componenti compositi (più oggetti) tramite la loro interfaccia errori di interfaccia
test di sistema componenti integrati, anche riusati o off-the-shelf le interazioni e il comportamento emergente

L'ereditarietà complica i test di classe perché le informazioni da provare non sono in un posto solo. Per una classe con stati si usa un modello a stati e si provano le sequenze di transizioni (per esempio spento →\to in funzione →\to spento).

Test unitari automatici

Quando possibile gli unit test sono automatici, con un framework (JUnit): si scrivono, si eseguono e si controllano senza intervento manuale. Un test ha tre parti:

  1. setup: si porta il sistema in uno stato noto e si preparano ingressi e uscite attese;
  2. chiamata: si invoca l'oggetto o il metodo sotto test;
  3. asserzione: si confronta il risultato con quello atteso; se vera il test passa, se falsa fallisce.

Progettazione dei casi di test

I casi di test devono (1) mostrare che il componente, usato come previsto, fa ciò che deve, e (2) rivelare i difetti: due tipi, quelli che riflettono il funzionamento normale e quelli basati sull'esperienza sui punti dove i problemi sono più frequenti, con ingressi anomali. Non si può provare tutto (test esaustivo impossibile): servono criteri.

Classi di equivalenza (partition testing)

Definizione. I dati d'ingresso (e le uscite) si dividono in classi di equivalenza: gruppi i cui membri il programma tratta allo stesso modo. Si sceglie almeno un caso di test da ogni classe: si presume che un errore, se c'è, compaia con tutti i membri della classe o con nessuno.

Procedura:

  1. elencare le condizioni d'ingresso (dalla specifica);
  2. per ogni condizione, dividere in classi valide e non valide;
  3. per l'insieme, scegliere i casi: i casi validi possono coprire più classi valide insieme, ma ogni classe non valida si prova da sola (altrimenti un errore ne maschera un altro);
  4. associare a ogni caso il risultato atteso ricavato dalla specifica, non dal codice.

Valori limite (boundary value analysis)

Gli errori si concentrano ai bordi delle classi (confronti sbagliati: < al posto di <=, scarto di uno). Per ogni bordo si prova il valore sul limite e quelli immediatamente accanto: per un intervallo valido [a,b][a, b] si provano a−1a-1, aa, a+1a+1, b−1b-1, bb, b+1b+1, più un valore interno. Le linee guida del corso aggiungono, per le sequenze: sequenze con un solo valore, di lunghezze diverse, accesso al primo, al medio e all'ultimo elemento, sequenze vuote; in generale: forzare tutti i messaggi di errore, input che fanno traboccare i buffer, ripetere lo stesso input molte volte, forzare risultati troppo grandi o troppo piccoli, uscite non valide. Per le interfacce: parametri agli estremi dei loro intervalli, puntatori nulli, test che provocano il fallimento del componente, stress test nei sistemi a scambio di messaggi, variare l'ordine di attivazione nei sistemi a memoria condivisa.

Esempio: la multa per ritardo

Specifica (R-17). La multa è 0 se il ritardo è 0 giorni; 0,30 euro per giorno dal 1° al 10° giorno; 3,00 euro più 0,60 euro per ogni giorno oltre il decimo dall'11° al 30°; 15,00 euro (tetto) da 30 giorni in su; metà importo per i soci; un ritardo negativo non è valido.

condizione classi di equivalenza valori limite
giorni di ritardo non valida: <0< 0; valida: 00; valida: da 11 a 1010; valida: da 1111 a 3030; valida: oltre 3030 −1-1 (non valido); 00; 11, 1010; 1111, 3030; 3131 e un valore enorme
socio sì; no (booleana: entrambi)

Casi: 1 caso non valido (−1-1, eccezione), poi i bordi 0, 1, 10, 11, 30, 31, i valori interni 5 e 20 e un caso enorme, e un caso per il socio in ogni zona (almeno nel mezzo e sul tetto). Il codice:

java
/**
 * Specifica (requisito R-17): la multa per un documento restituito in ritardo vale
 *  - 0 euro se il ritardo e' 0 giorni;
 *  - 0,30 euro per giorno, dal 1 al 10 giorno di ritardo;
 *  - 3,00 euro piu' 0,60 euro per ogni giorno oltre il decimo, dall'11 al 30 giorno;
 *  - 15,00 euro (tetto) da 30 giorni in su;
 *  - meta' importo per i soci;
 *  - un ritardo negativo non e' valido.
 */
public class Multa {
    public static double calcola(int giorniRitardo, boolean socio) {
        if (giorniRitardo < 0) throw new IllegalArgumentException("ritardo negativo: " + giorniRitardo);
        double importo;
        if (giorniRitardo == 0) importo = 0;
        else if (giorniRitardo <= 10) importo = 0.30 * giorniRitardo;
        else if (giorniRitardo <= 30) importo = 3.00 + 0.60 * (giorniRitardo - 10);
        else importo = 15.00;
        return socio ? importo / 2 : importo;
    }
}

I test (JUnit 4):

java
import static org.junit.Assert.assertEquals;

import org.junit.Test;

/** Casi di test ricavati da classi di equivalenza e valori limite (JUnit 4, come nel corso). */
public class MultaTest {
    private static final double DELTA = 0.0001;      // i double si confrontano con una tolleranza

    // classe "ritardo non valido" (< 0): eccezione attesa; il limite e' -1
    @Test(expected = IllegalArgumentException.class)
    public void ritardoNegativoNonValido() {
        Multa.calcola(-1, false);
    }

    // classe "nessun ritardo": 0 e' sia classe sia valore limite
    @Test
    public void ritardoZeroNessunaMulta() {
        assertEquals(0.0, Multa.calcola(0, false), DELTA);
    }

    // classe "1..10": limiti 1 e 10, un valore interno (5)
    @Test public void unGiorno()      { assertEquals(0.30, Multa.calcola(1, false), DELTA); }
    @Test public void cinqueGiorni()  { assertEquals(1.50, Multa.calcola(5, false), DELTA); }
    @Test public void dieciGiorni()   { assertEquals(3.00, Multa.calcola(10, false), DELTA); }

    // classe "11..30": limiti 11 e 30, un valore interno (20)
    @Test public void undiciGiorni()  { assertEquals(3.60, Multa.calcola(11, false), DELTA); }
    @Test public void ventiGiorni()   { assertEquals(9.00, Multa.calcola(20, false), DELTA); }
    @Test public void trentaGiorni()  { assertEquals(15.00, Multa.calcola(30, false), DELTA); }

    // classe "oltre il tetto": 31 (primo valore) e un valore molto grande
    @Test public void trentunoGiorni() { assertEquals(15.00, Multa.calcola(31, false), DELTA); }
    @Test public void ritardoEnorme()  { assertEquals(15.00, Multa.calcola(Integer.MAX_VALUE, false), DELTA); }

    // seconda variabile: socio, nel mezzo e sui limiti degli scaglioni
    @Test public void socioMetaImporto()      { assertEquals(0.75, Multa.calcola(5, true), DELTA); }
    @Test public void socioSulTetto()         { assertEquals(7.50, Multa.calcola(31, true), DELTA); }
    @Test public void socioSenzaRitardo()     { assertEquals(0.0, Multa.calcola(0, true), DELTA); }
}
MultaTest: eseguiti 13, falliti 0, ignorati 0

I valori limite scoprono ciò che i valori centrali non vedono. Una versione MultaBacata con un errore di confine (il primo scaglione arriva fino all'11° giorno, <= 11 invece di <= 10) passa tutti i test con valori centrali (1, 5, 10, 20) e fallisce solo quello sul bordo 11:

java
/** Versione con un errore di confine: il primo scaglione arriva fino a 11 invece che a 10. */
public class MultaBacata {
    public static double calcola(int giorniRitardo, boolean socio) {
        if (giorniRitardo < 0) throw new IllegalArgumentException("ritardo negativo: " + giorniRitardo);
        double importo;
        if (giorniRitardo == 0) importo = 0;
        else if (giorniRitardo <= 11) importo = 0.30 * giorniRitardo;                // BUG: l'undicesimo giorno cade nel primo scaglione
        else if (giorniRitardo <= 30) importo = 3.00 + 0.60 * (giorniRitardo - 10);
        else importo = 15.00;
        return socio ? importo / 2 : importo;
    }
}
java
import static org.junit.Assert.assertEquals;

import org.junit.Test;

/** Gli stessi test sulla versione con il bug: solo i test sui limiti lo scoprono. */
public class MultaBacataTest {
    private static final double DELTA = 0.0001;

    @Test public void cinqueGiorni()  { assertEquals(1.50, MultaBacata.calcola(5, false), DELTA); }   // passa
    @Test public void ventiGiorni()   { assertEquals(9.00, MultaBacata.calcola(20, false), DELTA); }  // passa
    @Test public void unGiorno()      { assertEquals(0.30, MultaBacata.calcola(1, false), DELTA); }   // passa
    @Test public void dieciGiorni()   { assertEquals(3.00, MultaBacata.calcola(10, false), DELTA); }  // passa
    @Test public void undiciGiorni()  { assertEquals(3.60, MultaBacata.calcola(11, false), DELTA); }  // FALLISCE: 3.30 invece di 3.60
}
MultaBacataTest: eseguiti 5, falliti 1, ignorati 0
  FALLITO undiciGiorni(MultaBacataTest): expected:<3.6> but was:<3.3>

Un test su 5 e 20 non lo avrebbe visto: la formula è continua a 10 (con < al posto di <= nel primo scaglione il risultato è identico e nessun test lo distingue) ma discontinua all'11°, quindi serve un test su entrambi i lati del bordo.

Test guidato dallo sviluppo (TDD) e di regressione

Definizione (TDD). Nel test-driven development i test si scrivono prima del codice, e passare i test è il fattore critico dello sviluppo. Si sviluppa in incrementi piccoli, e non si passa al successivo finché il codice non supera il test. Nato con i metodi agili (XP), si usa anche nei processi pianificati (Processi di sviluppo - modelli plan-based, metodi agili e ScrumUn processo software è un insieme strutturato di attività (specifica, progetto e realizzazione, validazione, evoluzione) e si descrive con attività, prodotti, ruoli e condizioni; i processi plan-driven pianificano tutto in anticipo e misurano il progresso sul piano (cascata, modello a V con i livelli di test corrispondenti, sviluppo incrementale pianificato, integrazione e configurazione di componenti, con prototipi e consegna incrementale per gestire il cambiamento, e miglioramento del processo con i livelli di maturità SEI), mentre i metodi agili pianificano in modo incrementale e accolgono il cambiamento (manifesto agile, XP con i suoi valori e le sue dodici pratiche, storie dell'utente e story point); Scrum organizza il lavoro in sprint con tre ruoli (Product Owner, Scrum Master, team), eventi (planning, daily scrum, review, retrospective) e artefatti (product backlog, sprint backlog, incremento), stimando con il planning poker; non c'è un processo giusto, ma priorità diverse (stabilità dei requisiti, criticità, dimensione e distribuzione del team).Processi di sviluppo - modelli plan-based, metodi agili e Scrum →).

Ciclo:

  1. identificare un incremento di funzionalità piccolo (poche righe);
  2. scrivere un test automatico per esso e eseguirlo: deve fallire (red: la funzione non c'è ancora; se passa, il test è inutile);
  3. implementare la funzionalità e rieseguire tutti i test (green);
  4. ripulire il codice (refactor) con i test come rete di sicurezza; poi la funzionalità successiva.

Benefici: copertura (ogni pezzo di codice ha almeno un test), suite di regressione costruita incrementalmente, debug semplificato (il difetto è nel codice appena scritto), i test sono documentazione di che cosa il codice deve fare. Nota: nello schema del corso tra write test e run test va inserita l'implementazione della funzione, altrimenti il ciclo non si chiude.

Esempio dal corso (Calculator). Primo ciclo: il test evaluatesExpression chiede evaluate("1+2+3") == 6; la prima versione del codice del corso termina con return 0; (volutamente sbagliata): il test fallisce (red). Si corregge in return sum; (green). Cicli successivi, ognuno con il suo test scritto prima: un solo numero ("7"), spazi ("1 + 2 + 3": serve trim()), ingresso non valido ("1+x": NumberFormatException attesa).

java
/** Terzo ciclo TDD: la versione "verde". (Nel corso la prima versione terminava con "return 0;" e il test falliva.) */
public class Calculator
{
  public int evaluate(String expression)
  {
    int sum = 0;
    for(String summand: expression.split("\\+"))
    {
      sum += Integer.valueOf(summand.trim());
    }
    return sum;
  }
}
java
import static org.junit.Assert.assertEquals;
import org.junit.Test;

public class CalculatorTest
{
  @Test
  public void evaluatesExpression()
  {
    Calculator calculator = new Calculator();
    int sum = calculator.evaluate("1+2+3");
    assertEquals(6, sum);
  }

  // secondo ciclo TDD: un caso limite (un solo addendo), scritto prima di toccare il codice
  @Test
  public void singleNumber()
  {
    assertEquals(7, new Calculator().evaluate("7"));
  }

  // terzo ciclo: spazi attorno agli addendi
  @Test
  public void ignoresSpaces()
  {
    assertEquals(6, new Calculator().evaluate("1 + 2 + 3"));
  }

  // quarto: input non valido -> eccezione (Integer.valueOf lancia NumberFormatException)
  @Test(expected = NumberFormatException.class)
  public void rejectsGarbage()
  {
    new Calculator().evaluate("1+x");
  }
}
CalculatorTest: eseguiti 4, falliti 0, ignorati 0

Test di regressione. Si riesegue tutta la suite dopo ogni modifica per controllare che non abbia "rotto" ciò che funzionava. Con i test automatici è semplice; a mano è costoso. Le modifiche si accettano solo con la suite verde.

JUnit 4

Il corso usa JUnit 4 (org.junit); JUnit 5 usa nomi diversi (tabella sotto). JUnit è un framework open source per scrivere ed eseguire test ripetibili, integrato negli IDE e in Maven (mvn test, vedi Strumenti di sviluppo - git, Maven e DockerTre strumenti rendono un progetto web riproducibile e condivisibile. Git tiene la storia del codice: tre aree (cartella di lavoro, area di staging, repository), il commit è un'istantanea con un hash, il branch è un puntatore mobile a un commit, HEAD indica dove si è; il merge fonde due rami (fast-forward se non c'è divergenza, altrimenti un commit di merge) e può produrre conflitti, che si risolvono a mano nei marcatori di conflitto, poi add e commit; con un remoto si usano clone, fetch, pull (fetch + merge) e push, e un push rifiutato (fetch first) richiede prima un pull; in gruppo si lavora su rami di funzionalità con commit piccoli e main sempre funzionante. Maven descrive il progetto nel pom.xml (coordinate groupId:artifactId:version, packaging war, dipendenze con scope compile, provided, runtime, test), impone la struttura src/main/java, src/main/webapp, src/test, e il ciclo di vita validate, compile, test, package, verify, install, deploy: ogni fase esegue prima tutte le precedenti, mvn clean package produce target/aule.war. Docker confeziona l'applicazione: un'immagine (strati di sola lettura, costruita da un Dockerfile) è il modello, un container è un'istanza in esecuzione; per l'esempio un Dockerfile a due fasi compila il war con Maven e lo copia in Tomcat, e docker compose avvia insieme Tomcat e PostgreSQL con un volume per i dati.Strumenti di sviluppo - git, Maven e Docker →); trova i test con la riflessione (Java - generics, reflection e passaggio dei parametriIn Java ogni dato ha una propria identità a tempo di esecuzione (il cast è controllato e può lanciare ClassCastException, a differenza del C) e i parametri si passano sempre per valore (di un riferimento, per gli oggetti: si può modificare l'oggetto ma non riassegnare il riferimento del chiamante); i generics (CodaG) spostano i controlli di tipo a tempo di compilazione ma sono cancellati dalla type erasure, quindi non sono i template del C++; la riflessione (Class, Constructor, Method, Field) permette di caricare classi per nome, creare oggetti, chiamare metodi e leggere o scrivere campi a tempo di esecuzione, con eccezioni specifiche (ClassNotFoundException, InvocationTargetException, IllegalAccessException) e il costo di aggirare il controllo statico.Java - generics, reflection e passaggio dei parametri →).

concetto significato
test case classe Java che contiene i metodi di test
test method metodo senza parametri annotato @Test, con logica e asserzioni
fixture lo stato iniziale noto dei test (campi della classe)
test suite insieme di test case
@Before / @After metodi eseguiti prima e dopo ogni test: creano e rilasciano il fixture (anche se il test fallisce)
@BeforeClass / @AfterClass metodi statici, eseguiti una volta prima e dopo tutti i test della classe
@Test(expected = X.class) il test passa solo se lancia X; fallisce se non lancia
@Test(timeout = ms) fallisce se supera il tempo
@Ignore il test è saltato (lento, dipende da un servizio remoto, fallisce per cause esterne)

Asserzioni (org.junit.Assert): assertEquals(atteso, effettivo) (con una tolleranza per i double: assertEquals(a, b, delta)), assertTrue, assertFalse, assertNull, assertNotNull, assertSame, assertNotSame, fail() se il controllo non dovrebbe arrivare a quella riga. Si può passare un messaggio come primo argomento. Ordine degli argomenti: assertEquals(messaggio, ATTESO, EFFETTIVO): il codice del corso (TestMyStackObjectAdapter) li passa scambiati (effettivo prima dell'atteso): il test passa lo stesso, ma in caso di errore il messaggio dice expected e but was al contrario.

Ciclo di vita: per ogni test JUnit 4 crea una nuova istanza della classe, quindi i campi d'istanza ripartono da zero (i campi static no). L'ordine dei test non è garantito: un test non deve dipendere da un altro.

java
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import java.util.ArrayList;
import java.util.List;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Ignore;
import org.junit.Test;

/** Ciclo di vita di JUnit 4: la classe e' istanziata di nuovo per OGNI test, quindi i campi d'istanza ripartono da zero. */
public class CicloDiVita {
    static int istanze = 0;
    private List<String> carrello;                       // il "fixture"
    private final int numero = ++istanze;

    @BeforeClass
    public static void unaVoltaPrima() { System.out.println("@BeforeClass (una volta sola)"); }

    @AfterClass
    public static void unaVoltaDopo() { System.out.println("@AfterClass (una volta sola)"); }

    @Before
    public void prima() {
        carrello = new ArrayList<>();                    // stato noto prima di ogni test
        System.out.println("  @Before  (istanza " + numero + ")");
    }

    @After
    public void dopo() { System.out.println("  @After   (istanza " + numero + ")"); }

    @Test
    public void aggiungere() {
        carrello.add("rosa");
        assertEquals(1, carrello.size());
    }

    @Test
    public void ilCarrelloEraVuoto() {
        assertTrue("il fixture non e' condiviso tra i test", carrello.isEmpty());
    }

    @Test(expected = IndexOutOfBoundsException.class)
    public void eccezioneAttesa() {
        carrello.get(0);
    }

    @Test(timeout = 500)
    public void entroIlTempoLimite() {
        for (int i = 0; i < 1000; i++) carrello.add("x");
    }

    @Ignore("in attesa del servizio remoto")
    @Test
    public void ignorato() {
        assertEquals(1, 2);
    }
}
@BeforeClass (una volta sola)
  @Before  (istanza 1)
  @After   (istanza 1)
  @Before  (istanza 2)
  @After   (istanza 2)
  @Before  (istanza 3)
  @After   (istanza 3)
  @Before  (istanza 4)
  @After   (istanza 4)
@AfterClass (una volta sola)
CicloDiVita: eseguiti 4, falliti 0, ignorati 1

Quattro istanze (una per test) e una sola esecuzione di @BeforeClass e @AfterClass; il test @Ignore non è eseguito (1 ignorato).

JUnit 4 JUnit 5 (org.junit.jupiter.api)
@Test (org.junit.Test) @Test
@Before, @After @BeforeEach, @AfterEach
@BeforeClass, @AfterClass @BeforeAll, @AfterAll
@Ignore @Disabled
@Test(expected = X.class) assertThrows(X.class, () -> ...)
@Test(timeout = ms) assertTimeout(Duration, ...)
Assert.assertEquals(messaggio, atteso, effettivo) Assertions.assertEquals(atteso, effettivo, messaggio) (il messaggio va per ultimo)
suite con @RunWith(Suite.class) @Suite / @SelectClasses

Esecuzione. Da IDE (clic destro, Run as JUnit Test), da Maven (mvn test: Tests run, Failures, Errors, Skipped) o da programma con JUnitCore.runClasses(Classe.class) che restituisce un Result (getRunCount, getFailures, wasSuccessful), come il TestRunner del corso. Un errore (error) è un'eccezione imprevista, un fallimento (failure) è un'asserzione falsa.

L'esempio del corso EsempioByte: un test che fallisce

La classe contiene un contatore byte inizializzato a (byte)128, che vale -128 (overflow: il massimo è 127), con inc(), dec() e una cronologia statica. I test chiedono, tra l'altro, che dec() dopo inc() riporti a 0 e che dec() dia counter-1. Risultato dell'esecuzione:

java
import org.junit.Test;
import org.junit.Before;
import org.junit.After;
import static org.junit.Assert.assertEquals;

/** Esempio del corso (EsempioByte): un contatore su byte che parte da (byte)128. */
public class EsempioByte {
    private byte counter = (byte)128;
    private static String history = "";

    public int inc()
    {
        history = history.concat("inc; ");
        return ++counter;
    }

    public int dec()
    {
        history = history.concat("dec; ");
        return --counter;
    }

    @Before
    public void beforeTest()
    {
        System.out.println("Before test counter is: " + counter + "; history is: " + history);
    }

    @After
    public void afterTest()
    {
        System.out.println("After test counter is: " + counter + "; history is: " + history);
        history = "";
    }

    @Test
    public void testInc()
    {
        assertEquals((long)(counter+1), (long)inc());
    }

    @Test
    public void testDec()
    {
        assertEquals((long)(counter-1),(long)dec());
    }

    @Test
    public void testDecInc()
    {
        dec();
        inc();
        assertEquals(0, counter);
    }

    @Test
    public void testIncDec()
    {
        inc();
        dec();
        assertEquals(0, counter);
    }

    @Test
    public void testHistory()
    {
        inc();
        inc();
        dec();
        dec();
        assertEquals("inc; inc; dec; dec; ", history);
    }
}
Before test counter is: -128; history is: 
After test counter is: 127; history is: dec; 
Before test counter is: -128; history is: 
After test counter is: -128; history is: dec; inc; 
Before test counter is: -128; history is: 
After test counter is: -128; history is: inc; inc; dec; dec; 
Before test counter is: -128; history is: 
After test counter is: -127; history is: inc; 
Before test counter is: -128; history is: 
After test counter is: -128; history is: inc; dec; 
EsempioByte: eseguiti 5, falliti 3, ignorati 0
  FALLITO testDec(EsempioByte): expected:<-129> but was:<127>
  FALLITO testDecInc(EsempioByte): expected:<0> but was:<-128>
  FALLITO testIncDec(EsempioByte): expected:<0> but was:<-128>
  • testDec: attesi counter-1 = -129, ma --counter su un byte a -128 avvolge a 127: fallisce;
  • testDecInc e testIncDec: dopo dec()/inc() o inc()/dec() il contatore torna a -128, non a 0 (parte da -128): falliscono;
  • testInc e testHistory passano; il @After azzera la cronologia statica (che altrimenti condividerebbero tra i test), e @Before/@After stampano lo stato.

I tre fallimenti dimostrano l'overflow dei tipi piccoli (aritmetica su byte e promozione a int) e mostrano come si legge il rapporto di JUnit: il difetto sta nel codice (o nei valori attesi), non nello strumento. La versione con int (Esempio2, Pippo) li passa tutti.

Test di componente, di sistema e di rilascio

  • Interfacce: errori di uso improprio (parametri nell'ordine sbagliato), di incomprensione (ipotesi errate sul comportamento del componente chiamato), di temporizzazione (componenti con velocità diverse, dati non aggiornati). Tipi di interfaccia: a parametri, a memoria condivisa, procedurali, a scambio di messaggi.
  • Test di sistema: si integrano i componenti e si provano le interazioni; i casi d'uso e i relativi diagrammi di sequenza sono una buona base per i casi di test (ogni caso d'uso attraversa più componenti). Si adottano politiche di test (per esempio: tutte le funzioni dei menu; le combinazioni di funzioni dello stesso menu; tutte le funzioni con input corretto e sbagliato).
  • Release testing: test di tipo black-box, derivati solo dalla specifica, da un team separato; verifica funzionalità, prestazioni e affidabilità. Test basato sui requisiti: per ogni requisito uno o più test (per il requisito "se un paziente è allergico a un farmaco, la prescrizione deve generare un avviso" si provano: nessuna allergia →\to nessun avviso; allergia nota →\to avviso; due allergie →\to avviso corretto per ciascuna; l'avviso ignorato richiede un motivo). Test di scenario: uno scenario d'uso tipico per ricavare casi di test. Test di prestazioni: carico crescente fino all'inaccettabile; stress test: sovraccarico voluto per vedere il comportamento al guasto.
  • Test dell'utente: l'ambiente dell'utente incide su affidabilità, prestazioni, usabilità e robustezza e non si replica in laboratorio. Alpha (utenti con gli sviluppatori, nella sede dello sviluppatore), beta (una versione agli utenti, che segnalano i problemi), accettazione (il cliente decide se accettare: criteri, piano, definizione, esecuzione, negoziazione dei risultati, rifiuto o accettazione). Nei metodi agili il cliente fa parte del team e i test di accettazione sono eseguiti automaticamente a ogni modifica.

Black box, white box, copertura

approccio si basa su tecniche
black box la specifica, senza guardare il codice classi di equivalenza, valori limite, casi d'uso
white box la struttura del codice copertura delle istruzioni, dei rami (ogni if vero e falso), dei cammini

La copertura misura quanta parte del codice i test eseguono, ma non dice se i test sono buoni: un test senza asserzioni la aumenta senza provare nulla. I test del corso TestMyStackObjectAdapter leggono campi interni dell'oggetto (msoa.adaptee.size): sono white box e si rompono quando cambia l'implementazione; è meglio provare il comportamento tramite l'interfaccia (vedi Esercizio - Adapter di classe e di oggetto per la pila (esempi del corso)). Per isolare un'unità dalle sue dipendenze (rete, database) si usano oggetti finti (stub, mock, fake) iniettati al posto dei reali: un'altra ragione per programmare verso le interfacce (Design pattern - introduzione e catalogo GoFUn design pattern è una soluzione generale, riusabile e collaudata a un problema ricorrente di progettazione in un certo contesto, con un nome (che arricchisce il vocabolario dei progettisti), un problema, una soluzione (struttura e partecipanti) e le conseguenze; il catalogo GoF (Gamma, Helm, Johnson, Vlissides) ha 23 pattern in tre famiglie: 5 creazionali (come si creano gli oggetti), 7 strutturali (come si compongono classi e oggetti), 11 comportamentali (come collaborano e si ripartiscono le responsabilità); dietro i pattern ci sono principi come programmare verso un'interfaccia, incapsulare ciò che varia, preferire la composizione all'ereditarietà, aperto/chiuso e inversione delle dipendenze; un pattern si sceglie dal problema, non dal nome, e usarlo senza bisogno complica il progetto.Design pattern - introduzione e catalogo GoF →).

Come documentare i test

La dispensa riporta i modelli degli strumenti di gestione dei test: un test suite ha sommario, progetto della suite, precondizioni e postcondizioni, risultati attesi, valutazione del rischio, i casi di test, i registri delle esecuzioni, allegati, variabili di esecuzione; un test case ha sommario, ambito, progetto del caso (setup e topologia), documenti normativi, revisione formale, oggetti di sviluppo, requisiti collegati (tracciabilità), valutazione del rischio, preparazione e passi con risultati attesi. Per un progetto d'esame conviene una tabella per caso di test: identificatore, requisito collegato, precondizione, ingresso, risultato atteso, classe di equivalenza o limite coperti.

Nota sul modo di esecuzione. In questo ambiente JUnit non è installato: i test della nota sono stati compilati contro una piccola copia delle sole firme delle annotazioni e delle asserzioni di JUnit 4 e lanciati con un runner minimo basato sulla riflessione, che esegue @BeforeClass, un'istanza nuova per ogni test, @Before, il test, @After, @AfterClass, e gestisce expected, timeout e @Ignore. Le uscite mostrate sono quelle di quel runner, che segue la semantica di JUnit 4 per questi casi (con JUnit vero i messaggi e l'ordine dei test possono differire).

Domande d'esame

1. Progettare i casi di test per una funzione sconto(età) che dà 0% sotto i 18 anni, 10% da 18 a 64, 20% da 65 in su, e rifiuta le età fuori da 0 e 120. Traccia. Classi: <0< 0 (non valida), 00-1717, 1818-6464, 6565-120120, >120> 120 (non valida). Valori limite: −1-1, 00, 1717, 1818, 6464, 6565, 120120, 121121; qualche valore interno (10, 40, 90). Le classi non valide si provano da sole ed è attesa un'eccezione; per ogni caso si scrive il risultato atteso dalla specifica.

2. Qual è la differenza tra @Before e @BeforeClass in JUnit 4? E che cosa cambia in JUnit 5? Traccia. @Before gira prima di ogni test (il fixture nuovo, perché per ogni test c'è una nuova istanza); @BeforeClass è statico e gira una volta prima di tutti. In JUnit 5 sono @BeforeEach e @BeforeAll, @After... analoghi; @Ignore diventa @Disabled, l'eccezione attesa si prova con assertThrows.

3. Descrivere il ciclo del TDD e dire perché il test deve prima fallire. Traccia. Scrivere un piccolo test, eseguirlo (fallisce: red), implementare il minimo, rieseguire tutti i test (green), ripulire (refactor). Se il test non fallisce prima, non si sa se prova qualcosa; i test accumulati formano la suite di regressione e la documentazione del comportamento.

Versione ripasso

  • Testing: eseguire con dati artificiali per validare (soddisfa i requisiti: un test riuscito mostra il funzionamento atteso) e per scoprire difetti (un test riuscito fa fallire il sistema). Mostra la presenza di errori, non l'assenza. Almeno un test per ogni requisito.
  • Verifica (nel modo giusto, conforme alla specifica) e validazione (il prodotto giusto). Ispezioni = verifica statica (anche su codice incompleto, un errore non ne maschera un altro, non provano le proprietà non funzionali); test = dinamica; sono complementari.
  • Fasi: di sviluppo (team di sviluppo: unit, componenti, sistema), di rilascio (team separato, black box), dell'utente (alpha, beta, accettazione).
  • Unit test: funzioni, classi, componenti, in isolamento; provare tutte le operazioni, gli attributi e gli stati. Componenti: interfacce (uso improprio, incomprensione, temporizzazione). Sistema: interazioni; casi d'uso come base; politiche di test.
  • Test automatico, tre parti: setup, chiamata, asserzione.
  • Classi di equivalenza: una classe = ingressi trattati allo stesso modo; un caso per classe; le classi non valide una alla volta. Valori limite: sul bordo e accanto (a−1a-1, aa, a+1a+1, b−1b-1, bb, b+1b+1) più un valore interno; scarto di uno, < contro <=. Sequenze: un elemento, lunghezze diverse, primo, medio, ultimo, vuota. Input anomali: tutti i messaggi di errore, buffer overflow, risultati enormi o minuscoli.
ritardo (giorni) classe casi
<0< 0 non valida −1-1 (eccezione)
00 valida 00
da 11 a 1010 valida 11, 55, 1010
da 1111 a 3030 valida 1111, 2020, 3030
oltre 3030 valida 3131, Integer.MAX_VALUE

Valori attesi: 0,00; 0,30; 1,50; 3,00; 3,60; 9,00; 15,00; 15,00; socio = metà (5 giorni: 0,75; 31 giorni: 7,50).

java
public class Multa {                                        // specifica R-17
    public static double calcola(int giorniRitardo, boolean socio) {
        if (giorniRitardo < 0) throw new IllegalArgumentException("ritardo negativo: " + giorniRitardo);
        double importo;
        if (giorniRitardo == 0) importo = 0;
        else if (giorniRitardo <= 10) importo = 0.30 * giorniRitardo;
        else if (giorniRitardo <= 30) importo = 3.00 + 0.60 * (giorniRitardo - 10);
        else importo = 15.00;                               // tetto
        return socio ? importo / 2 : importo;
    }
}

Esito: 13 test eseguiti, 0 falliti (una classe non valida, i bordi 0, 1, 10, 11, 30, 31, i valori interni 5 e 20, il caso enorme, tre casi per il socio).

  • Il bordo scopre l'errore: MultaBacata (primo scaglione fino a 11 invece di 10) passa 1, 5, 10, 20 e fallisce solo sull'11: expected:<3.6> but was:<3.3>.
java
@Test(expected = IllegalArgumentException.class)
public void ritardoNegativoNonValido() { Multa.calcola(-1, false); }
@Test public void dieciGiorni()  { assertEquals(3.00, Multa.calcola(10, false), DELTA); }   // limite
@Test public void undiciGiorni() { assertEquals(3.60, Multa.calcola(11, false), DELTA); }   // limite
  • TDD: test scritto prima, deve fallire (red), implementazione minima (green), refactor; non si procede senza test verde. Benefici: copertura, regressione, debug semplice, documentazione. Calculator del corso: prima return 0; (test rosso), poi return sum;; cicli successivi: "7", "1 + 2 + 3" (trim()), "1+x" (NumberFormatException). Regressione: si rieseguono tutti i test a ogni modifica.
java
@Test public void evaluatesExpression() { assertEquals(6, new Calculator().evaluate("1+2+3")); }   // red con "return 0"
@Test public void singleNumber()        { assertEquals(7, new Calculator().evaluate("7")); }
@Test public void ignoresSpaces()       { assertEquals(6, new Calculator().evaluate("1 + 2 + 3")); }   // serve trim()
@Test(expected = NumberFormatException.class)
public void rejectsGarbage()            { new Calculator().evaluate("1+x"); }

Esito: CalculatorTest: eseguiti 4, falliti 0, ignorati 0 (con la versione corretta).

  • Test di scenario e di accettazione: si inventa uno scenario d'uso tipico e se ne ricavano i casi (per esempio l'infermiere che scarica le cartelle, le cifra, le modifica, le ricarica); l'accettazione si decide con il cliente su criteri definiti in anticipo.
  • JUnit 4: @Test, @Before/@After (per ogni test), @BeforeClass/@AfterClass (statici, una volta), @Test(expected = X.class), @Test(timeout = ms), @Ignore. Asserzioni: assertEquals(atteso, effettivo) (atteso per primo; con delta per i double), assertTrue/False, assertNull/NotNull, assertSame, fail(). Una nuova istanza per ogni test, ordine non garantito, i campi static persistono. Suite con @RunWith(Suite.class); JUnitCore.runClasses; mvn test. Il corso passa assertEquals(msg, effettivo, atteso) scambiati: passa ma il messaggio è al contrario.
JUnit 4 JUnit 5
@Before, @After @BeforeEach, @AfterEach
@BeforeClass, @AfterClass @BeforeAll, @AfterAll
@Ignore @Disabled
expected = X.class assertThrows
messaggio per primo messaggio per ultimo
java
public class CicloDiVita {
    @BeforeClass public static void unaVoltaPrima() { }     // una volta sola
    @Before public void prima() { carrello = new ArrayList<>(); }   // fixture nuovo per ogni test
    @After  public void dopo()  { }
    @Test public void aggiungere() { carrello.add("rosa"); assertEquals(1, carrello.size()); }
    @Test(expected = IndexOutOfBoundsException.class)
    public void eccezioneAttesa() { carrello.get(0); }
    @Test(timeout = 500) public void entroIlTempoLimite() { /* ... */ }
    @Ignore("in attesa del servizio remoto") @Test public void ignorato() { }
}
  • Esito di CicloDiVita: @BeforeClass; poi @Before (istanza 1), @After (istanza 1)... fino alla 4; @AfterClass; eseguiti 4, falliti 0, ignorati 1.
  • Esito di EsempioByte: eseguiti 5, falliti 3: testDec (expected:<-129> but was:<127>), testDecInc e testIncDec (expected:<0> but was:<-128>).
  • EsempioByte: contatore byte iniziale (byte)128 = -128; --counter avvolge a 127: testDec (atteso -129), testDecInc e testIncDec (tornano a -128, non a 0) falliscono; testInc e testHistory passano. Con int passano tutti.
  • Ciclo di vita (4 test): @BeforeClass, poi per ognuno @Before (istanza 1..4) e @After, infine @AfterClass; 1 test @Ignore saltato.
  • Release test: black box sui requisiti (un test per requisito, scenari, prestazioni con carico crescente, stress test). Utente: alpha, beta, accettazione (criteri, piano, esecuzione, negoziazione, accettazione o rifiuto); in agile il cliente è nel team.
  • Black box (specifica) e white box (copertura di istruzioni e rami); la copertura non dice che i test sono buoni; test su campi interni si rompono se cambia l'implementazione (Esercizio - Adapter di classe e di oggetto per la pila (esempi del corso)); stub/mock/fake per isolare le dipendenze.
  • Documentare: tabella per caso: identificatore, requisito collegato (tracciabilità), precondizione, ingresso, risultato atteso, classe o limite coperti.
  • Errori tipici: un test per classe ma nessuno sui bordi; più classi non valide nello stesso caso; atteso ricavato dal codice e non dalla specifica; test che dipendono dall'ordine o da campi static; scambiare atteso ed effettivo; test senza asserzioni (copertura finta); TDD senza far fallire prima il test.

Esercizi su questo argomento

Teoria collegata