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 in funzione 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:
- setup: si porta il sistema in uno stato noto e si preparano ingressi e uscite attese;
- chiamata: si invoca l'oggetto o il metodo sotto test;
- 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:
- elencare le condizioni d'ingresso (dalla specifica);
- per ogni condizione, dividere in classi valide e non valide;
- 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);
- 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 si provano , , , , , , 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: ; valida: ; valida: da a ; valida: da a ; valida: oltre | (non valido); ; , ; , ; e un valore enorme |
| socio | sì; no | (booleana: entrambi) |
Casi: 1 caso non valido (, 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:
/**
* 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):
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 0I 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:
/** 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;
}
}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:
- identificare un incremento di funzionalità piccolo (poche righe);
- scrivere un test automatico per esso e eseguirlo: deve fallire (red: la funzione non c'è ancora; se passa, il test è inutile);
- implementare la funzionalità e rieseguire tutti i test (green);
- 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 testevaluatesExpressionchiedeevaluate("1+2+3") == 6; la prima versione del codice del corso termina conreturn 0;(volutamente sbagliata): il test fallisce (red). Si corregge inreturn sum;(green). Cicli successivi, ognuno con il suo test scritto prima: un solo numero ("7"), spazi ("1 + 2 + 3": servetrim()), ingresso non valido ("1+x":NumberFormatExceptionattesa).
/** 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;
}
}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 0Test 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
| 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.
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 1Quattro 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:
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: attesicounter-1 = -129, ma--countersu unbytea -128 avvolge a 127: fallisce;testDecIncetestIncDec: dopodec()/inc()oinc()/dec()il contatore torna a -128, non a 0 (parte da -128): falliscono;testIncetestHistorypassano; il@Afterazzera la cronologia statica (che altrimenti condividerebbero tra i test), e@Before/@Afterstampano 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 nessun avviso; allergia nota avviso; due allergie 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 gestisceexpected,timeoute@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: (non valida), -, -, -, (non valida). Valori limite: , , , , , , , ; 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 (, , , , , ) 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 |
|---|---|---|
| non valida | (eccezione) | |
| valida | ||
| da a | valida | , , |
| da a | valida | , , |
| oltre | valida | , 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).
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>.
@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.
Calculatordel corso: primareturn 0;(test rosso), poireturn sum;; cicli successivi:"7","1 + 2 + 3"(trim()),"1+x"(NumberFormatException). Regressione: si rieseguono tutti i test a ogni modifica.
@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; condeltaper idouble),assertTrue/False,assertNull/NotNull,assertSame,fail(). Una nuova istanza per ogni test, ordine non garantito, i campistaticpersistono. Suite con@RunWith(Suite.class);JUnitCore.runClasses;mvn test. Il corso passaassertEquals(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 |
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>),testDecIncetestIncDec(expected:<0> but was:<-128>). EsempioByte: contatorebyteiniziale(byte)128= -128;--counteravvolge a 127:testDec(atteso -129),testDecIncetestIncDec(tornano a -128, non a 0) falliscono;testIncetestHistorypassano. Conintpassano tutti.- Ciclo di vita (4 test):
@BeforeClass, poi per ognuno@Before(istanza 1..4) e@After, infine@AfterClass; 1 test@Ignoresaltato. - 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
- Ingegneria dei requisiti - requisiti funzionali e non funzionali
- Ingegneria del software - prodotto software e processo di sviluppo
- Java - generics, reflection e passaggio dei parametri
- Pattern comportamentali - Observer, Strategy, Command, Iterator e Template Method
- Processi di sviluppo - modelli plan-based, metodi agili e Scrum