Una guida per test di unità e di integrazione robusti con JUnit
Pubblicato: 2022-03-11I test software automatizzati sono di fondamentale importanza per la qualità a lungo termine, la manutenibilità e l'estendibilità dei progetti software e, per Java, JUnit è il percorso verso l'automazione.
Sebbene la maggior parte di questo articolo si concentrerà sulla scrittura di unit test robusti e sull'utilizzo di stubbing, mocking e iniezione di dipendenze, discuteremo anche di JUnit e dei test di integrazione.
Il framework di test JUnit è uno strumento comune, gratuito e open source per testare progetti basati su Java.
Al momento della stesura di questo articolo, JUnit 4 è l'attuale versione principale, essendo stata rilasciata più di 10 anni fa, con l'ultimo aggiornamento più di due anni fa.
JUnit 5 (con i modelli di programmazione ed estensione Jupiter) è in fase di sviluppo attivo. Supporta meglio le funzionalità del linguaggio introdotte in Java 8 e include altre nuove e interessanti funzionalità. Alcuni team potrebbero trovare JUnit 5 pronto per l'uso, mentre altri potrebbero continuare a utilizzare JUnit 4 fino al rilascio ufficiale di 5. Vedremo esempi da entrambi.
Esecuzione di JUnit
I test JUnit possono essere eseguiti direttamente in IntelliJ, ma possono anche essere eseguiti in altri IDE come Eclipse, NetBeans o persino dalla riga di comando.
I test dovrebbero sempre essere eseguiti in fase di compilazione, in particolare gli unit test. Una build con eventuali test non riusciti dovrebbe essere considerata non riuscita, indipendentemente dal fatto che il problema sia nella produzione o nel codice di test: ciò richiede disciplina da parte del team e la volontà di dare la massima priorità alla risoluzione dei test non riusciti, ma è necessario aderire al spirito di automazione.
I test JUnit possono anche essere eseguiti e riportati da sistemi di integrazione continua come Jenkins. I progetti che utilizzano strumenti come Gradle, Maven o Ant hanno l'ulteriore vantaggio di poter eseguire test come parte del processo di compilazione.
Grad
Come esempio di progetto Gradle per JUnit 5, vedere la sezione Gradle della guida per l'utente di JUnit e il repository junit5-samples.git. Si noti che può anche eseguire test che utilizzano l'API JUnit 4 (denominata "vintage" ).
Il progetto può essere creato in IntelliJ tramite l'opzione di menu File > Apri... > vai alla junit-gradle-consumer sub-directory
> OK > Apri come progetto > OK per importare il progetto da Gradle.
Per Eclipse, il plug-in Buildship Gradle può essere installato da Guida > Eclipse Marketplace... Il progetto può quindi essere importato con File > Importa... > Gradle > Progetto Gradle > Avanti > Avanti > Passare alla junit-gradle-consumer
> Avanti > Avanti > Fine.
Dopo aver impostato il progetto Gradle in IntelliJ o Eclipse, l'esecuzione dell'attività di build
Gradle includerà l'esecuzione di tutti i test JUnit con l'attività di test
. Si noti che i test potrebbero essere saltati nelle successive esecuzioni di build
se non sono state apportate modifiche al codice.
Per JUnit 4, vedere l'uso di JUnit con il wiki di Gradle.
Esperto di
Per JUnit 5, fare riferimento alla sezione Maven della guida per l'utente e al repository junit5-samples.git per un esempio di un progetto Maven. Questo può anche eseguire test vintage (quelli che utilizzano l'API JUnit 4).
In IntelliJ, usa File > Apri... > vai a junit-maven-consumer/pom.xml
> OK > Apri come progetto. I test possono quindi essere eseguiti da Maven Projects > junit5-maven-consumer > Lifecycle > Test.
In Eclipse, usa File > Importa... > Maven > Progetti Maven esistenti > Avanti > Passa alla junit-maven-consumer
> Con pom.xml
selezionato > Fine.
I test possono essere eseguiti eseguendo il progetto come build Maven... > specifica l'obiettivo del test
> Esegui.
Per JUnit 4, vedere JUnit nel repository Maven.
Ambienti di sviluppo
Oltre a eseguire test tramite strumenti di compilazione come Gradle o Maven, molti IDE possono eseguire direttamente test JUnit.
IntelliJ IDEA
Per i test JUnit 5 è richiesto IntelliJ IDEA 2016.2 o versioni successive, mentre i test JUnit 4 dovrebbero funzionare nelle versioni precedenti di IntelliJ.
Ai fini di questo articolo, potresti voler creare un nuovo progetto in IntelliJ da uno dei miei repository GitHub ( JUnit5IntelliJ.git o JUnit4IntelliJ.git), che include tutti i file nell'esempio di classe Person
semplice e usa il built-in biblioteche JUnit. Il test può essere eseguito con Esegui > Esegui 'Tutti i test'. Il test può essere eseguito anche in IntelliJ dalla classe PersonTest
.
Questi repository sono stati creati con nuovi progetti IntelliJ Java e costruiscono le strutture di directory src/main/java/com/example
e src/test/java/com/example
. La directory src/main/java
è stata specificata come cartella di origine mentre src/test/java
è stata specificata come cartella di origine del test. Dopo aver creato la classe PersonTest
con un metodo di test annotato con @Test
, potrebbe non riuscire a compilare, nel qual caso IntelliJ offre il suggerimento di aggiungere JUnit 4 o JUnit 5 al percorso della classe che può essere caricato dalla distribuzione IntelliJ IDEA (vedi questi risposte su Stack Overflow per maggiori dettagli). Infine, è stata aggiunta una configurazione di esecuzione JUnit per tutti i test.
Vedere anche le linee guida pratiche sui test IntelliJ.
Eclisse
Un progetto Java vuoto in Eclipse non avrà una directory radice di prova. Questo è stato aggiunto da Proprietà del progetto > Percorso build Java > Aggiungi cartella... > Crea nuova cartella... > specifica il nome della cartella > Fine. La nuova directory verrà selezionata come cartella di origine. Fare clic su OK in entrambe le restanti finestre di dialogo.
I test JUnit 4 possono essere creati con File > Nuovo > Caso di test JUnit. Selezionare "Nuovo test JUnit 4" e la cartella di origine appena creata per i test. Specifica una "classe sottoposta a test" e un "pacchetto", assicurandoti che il pacchetto corrisponda alla classe sottoposta a test. Quindi, specifica un nome per la classe di test. Al termine della procedura guidata, se richiesto, scegliere "Aggiungi libreria JUnit 4" al percorso di creazione. Il progetto o la singola classe di test possono quindi essere eseguiti come JUnit Test. Vedi anche Eclipse Scrittura ed esecuzione di test JUnit.
NetBean
NetBeans supporta solo i test JUnit 4. Le classi di test possono essere create in un progetto Java NetBeans con File > Nuovo file... > Unit Tests > JUnit Test o Test per la classe esistente. Per impostazione predefinita, la directory radice del test è denominata test
nella directory del progetto.
Classe di produzione semplice e il suo caso di prova JUnit
Diamo un'occhiata a un semplice esempio di codice di produzione e al codice di unit test corrispondente per una classe Person
molto semplice. Puoi scaricare il codice di esempio dal mio progetto github e aprirlo tramite IntelliJ.
src/main/java/com/example/Person.java
package com.example; class Person { private final String givenName; private final String surname; Person(String givenName, String surname) { this.givenName = givenName; this.surname = surname; } String getDisplayName() { return surname + ", " + givenName; } }
La classe Person
immutabile ha un costruttore e un metodo getDisplayName()
. Vogliamo verificare che getDisplayName()
restituisca il nome formattato come previsto. Ecco il codice di test per un test unitario singolo (JUnit 5):
src/test/java/com/example/PersonTest.java
package com.example; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class PersonTest { @Test void testGetDisplayName() { Person person = new Person("Josh", "Hayden"); String displayName = person.getDisplayName(); assertEquals("Hayden, Josh", displayName); } }
PersonTest
utilizza @Test
e asserzione di JUnit 5. Per JUnit 4, la classe e il metodo PersonTest
devono essere pubblici e devono essere utilizzate diverse importazioni. Ecco l'esempio di JUnit 4 Gist.
Dopo aver eseguito la classe PersonTest
in IntelliJ, il test ha esito positivo e gli indicatori dell'interfaccia utente sono verdi.
Convenzioni comuni di JUnit
Denominazione
Sebbene non sia richiesto, utilizziamo convenzioni comuni per nominare la classe di test; in particolare, iniziamo con il nome della classe da testare ( Person
) e aggiungiamo "Test" ad essa ( PersonTest
). La denominazione del metodo di test è simile, a partire dal metodo in fase di test ( getDisplayName()
) e anteponendo "test" ad esso ( testGetDisplayName()
). Sebbene esistano molte altre convenzioni perfettamente accettabili per la denominazione dei metodi di test, è importante essere coerenti all'interno del team e del progetto.
Nome in produzione | Nome in prova |
---|---|
Persona | Prova della persona |
getDisplayName() | testDisplayName() |
Pacchi
Utilizziamo anche la convenzione di creare la classe PersonTest
del codice di test nello stesso pacchetto ( com.example
) della classe Person
del codice di produzione. Se usiamo un pacchetto diverso per i test, dovremmo usare il modificatore di accesso pubblico nelle classi di codice di produzione, nei costruttori e nei metodi a cui fanno riferimento gli unit test, anche dove non è appropriato, quindi è meglio mantenerli nello stesso pacchetto . Tuttavia, utilizziamo directory di origine separate ( src/main/java
e src/test/java
) poiché generalmente non vogliamo includere codice di test nelle build di produzione rilasciate.
Struttura e annotazione
L'annotazione @Test
(JUnit 4/5) dice a JUnit di eseguire il metodo testGetDisplayName()
come metodo di test e di segnalare se ha esito positivo o negativo. Finché tutte le asserzioni (se presenti) vengono superate e non vengono generate eccezioni, il test viene considerato superato.
Il nostro codice di test segue il modello di struttura di Arrange-Act-Assert (AAA). Altri modelli comuni includono Given-When-Then e Setup-Exercise-Verify-Teardown (Teardown in genere non è esplicitamente necessario per i test unitari), ma in questo articolo utilizziamo AAA.
Diamo un'occhiata a come il nostro esempio di test segue AAA. La prima riga, "arrange" crea un oggetto Person
che verrà testato:
Person person = new Person("Josh", "Hayden");
La seconda riga, "act", esercita il metodo Person.getDisplayName()
del codice di produzione:
String displayName = person.getDisplayName();
La terza riga, "assert", verifica che il risultato sia quello previsto.
assertEquals("Hayden, Josh", displayName);
Internamente, la chiamata assertEquals()
utilizza il metodo equals dell'oggetto String "Hayden, Josh" per verificare che il valore effettivo restituito dal codice di produzione ( displayName
) corrisponda. Se non corrispondeva, il test sarebbe stato contrassegnato come non riuscito.
Si noti che i test hanno spesso più di una riga per ciascuna di queste fasi AAA.
Unit test e codice di produzione
Ora che abbiamo trattato alcune convenzioni di test, rivolgiamo la nostra attenzione a rendere testabile il codice di produzione.
Torniamo alla nostra classe Person
, dove ho implementato un metodo per restituire l'età di una persona in base alla sua data di nascita. Gli esempi di codice richiedono Java 8 per sfruttare la nuova data e le API funzionali. Ecco come appare la nuova classe Person.java
:
Persona.java
// ... class Person { // ... private final LocalDate dateOfBirth; Person(String givenName, String surname, LocalDate dateOfBirth) { // ... this.dateOfBirth = dateOfBirth; } // ... long getAge() { return ChronoUnit.YEARS.between(dateOfBirth, LocalDate.now()); } public static void main(String... args) { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); System.out.println(person.getDisplayName() + ": " + person.getAge() + " years"); // Doe, Joey: 4 years } }
L'esecuzione di questa classe (al momento in cui scrivo) annuncia che Joey ha 4 anni. Aggiungiamo un metodo di prova:
PersonaTest.java
// ... class PersonTest { // ... @Test void testGetAge() { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); long age = person.getAge(); assertEquals(4, age); } }
Passa oggi, ma che dire di quando lo eseguiremo tra un anno? Questo test non è deterministico e fragile poiché il risultato atteso dipende dalla data corrente del sistema che esegue il test.
Stubbing e iniezione di un fornitore di valore
Durante l'esecuzione in produzione, vogliamo utilizzare la data corrente, LocalDate.now()
, per calcolare l'età della persona, ma per eseguire un test deterministico anche tra un anno, i test devono fornire i propri valori currentDate
.
Questo è noto come iniezione di dipendenza. Non vogliamo che il nostro oggetto Person
determini la data corrente stessa, ma invece vogliamo passare in questa logica come dipendenza. Gli unit test utilizzeranno un valore noto e stub e il codice di produzione consentirà al sistema di fornire il valore effettivo in fase di esecuzione.
Aggiungiamo un fornitore LocalDate
a Person.java
:
Persona.java
// ... class Person { // ... private final LocalDate dateOfBirth; private final Supplier<LocalDate> currentDateSupplier; Person(String givenName, String surname, LocalDate dateOfBirth) { this(givenName, surname, dateOfBirth, LocalDate::now); } // Visible for testing Person(String givenName, String surname, LocalDate dateOfBirth, Supplier<LocalDate> currentDateSupplier) { // ... this.dateOfBirth = dateOfBirth; this.currentDateSupplier = currentDateSupplier; } // ... long getAge() { return ChronoUnit.YEARS.between(dateOfBirth, currentDateSupplier.get()); } public static void main(String... args) { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); System.out.println(person.getDisplayName() + ": " + person.getAge() + " years"); // Doe, Joey: 4 years } }
Per semplificare il test del metodo getAge()
, lo abbiamo modificato per utilizzare currentDateSupplier
, un fornitore LocalDate
, per recuperare la data corrente. Se non sai cos'è un fornitore, ti consiglio di leggere le interfacce funzionali integrate di Lambda.
Abbiamo anche aggiunto un'iniezione di dipendenza: il nuovo costruttore di test consente ai test di fornire i propri valori di data correnti. Il costruttore originale chiama questo nuovo costruttore, passando un riferimento al metodo statico di LocalDate::now
, che fornisce un oggetto LocalDate
, quindi il nostro metodo principale funziona ancora come prima. E il nostro metodo di prova? Aggiorniamo PersonTest.java
:
PersonaTest.java
// ... class PersonTest { // ... @Test void testGetAge() { LocalDate dateOfBirth = LocalDate.parse("2013-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-17"); Person person = new Person("Joey", "Doe", dateOfBirth, ()->currentDate); long age = person.getAge(); assertEquals(4, age); } }
Il test ora inserisce il proprio valore currentDate
, quindi il nostro test verrà comunque superato quando verrà eseguito l'anno prossimo o durante qualsiasi anno. Questo è comunemente indicato come stubbing o fornitura di un valore noto da restituire, ma prima abbiamo dovuto cambiare Person
per consentire l'iniezione di questa dipendenza.
Nota la sintassi lambda ( ()->currentDate
) quando costruisci l'oggetto Person
. Questo viene considerato come un fornitore di LocalDate
, come richiesto dal nuovo costruttore.
Deridere e stubbing un servizio web
Siamo pronti per il nostro oggetto Person
, la cui intera esistenza è stata nella memoria JVM, per comunicare con il mondo esterno. Vogliamo aggiungere due metodi: il metodo publishAge()
, che pubblicherà l'età attuale della persona, e il metodo getThoseInCommon()
, che restituirà i nomi di personaggi famosi che condividono lo stesso compleanno o hanno la stessa età del nostro Person
. Supponiamo che ci sia un servizio RESTful con cui possiamo interagire chiamato "Compleanni delle persone". Abbiamo un client Java per esso che consiste nella singola classe, BirthdaysClient
.
com.example.birthdays.BirthdaysClient
package com.example.birthdays; import java.io.IOException; import java.util.Arrays; import java.util.Collection; public class BirthdaysClient { public void publishRegularPersonAge(String name, long age) throws IOException { System.out.println("publishing " + name + "'s age: " + age); // HTTP POST with name and age and possibly throw an exception } public Collection<String> findFamousNamesOfAge(long age) throws IOException { System.out.println("finding famous names of age " + age); return Arrays.asList(/* HTTP GET with age and possibly throw an exception */); } public Collection<String> findFamousNamesBornOn(int month, int dayOfMonth) throws IOException { System.out.println("finding famous names born on day " + dayOfMonth + " of month " + month); return Arrays.asList(/* HTTP GET with month and day and possibly throw an exception */); } }
Miglioriamo la nostra classe Person
. Iniziamo aggiungendo un nuovo metodo di test per il comportamento desiderato di publishAge()
. Perché iniziare con il test, piuttosto che con la funzionalità? Stiamo seguendo i principi dello sviluppo basato su test (noto anche come TDD), in cui scriviamo prima il test e poi il codice per farlo passare.
PersonaTest.java
// … class PersonTest { // … @Test void testPublishAge() { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate); person.publishAge(); } }
A questo punto, il codice di test non viene compilato perché non abbiamo creato il metodo publishAge()
che sta chiamando. Una volta creato un metodo Person.publishAge()
vuoto, tutto passa. Ora siamo pronti per il test per verificare che l'età della persona venga effettivamente pubblicata su BirthdaysClient
.

Aggiunta di un oggetto deriso
Poiché si tratta di un test unitario, dovrebbe essere eseguito velocemente e in memoria, quindi il test costruirà il nostro oggetto Person
con un finto BirthdaysClient
in modo che in realtà non effettui una richiesta Web. Il test utilizzerà quindi questo oggetto fittizio per verificare che sia stato chiamato come previsto. Per fare ciò, aggiungeremo una dipendenza dal framework Mockito (licenza MIT) per la creazione di oggetti fittizi, quindi creeremo un oggetto BirthdaysClient
simulato:
PersonaTest.java
// ... import com.example.birthdays.BirthdaysClient; // ... import static org.mockito.Mockito.mock; class PersonTest { private BirthdaysClient birthdaysClient = mock(BirthdaysClient.class); // ... @Test void testPublishAge() { // ... Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); // ... } }
Abbiamo inoltre aumentato la firma del costruttore Person
per prendere un oggetto BirthdaysClient
e modificato il test per inserire l'oggetto BirthdaysClient
simulato.
Aggiunta di una falsa aspettativa
Successivamente, aggiungiamo alla fine del nostro testPublishAge
un'aspettativa che viene chiamato BirthdaysClient
. Person.publishAge()
dovrebbe chiamarlo, come mostrato nel nostro nuovo PersonTest.java
:
PersonaTest.java
// ... class PersonTest { // ... @Test void testPublishAge() throws IOException { // ... Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); verifyZeroInteractions(birthdaysClient); person.publishAge(); verify(birthdaysClient).publishRegularPersonAge("Joe Sixteen", 16); } }
Il nostro BirthdaysClient
potenziato da Mockito tiene traccia di tutte le chiamate che sono state effettuate ai suoi metodi, ed è così che verifichiamo che non siano state effettuate chiamate a BirthdaysClient
con il metodo verifyZeroInteractions()
prima di chiamare publishAge()
. Anche se probabilmente non necessario, in questo modo ci assicuriamo che il costruttore non stia effettuando chiamate non autorizzate. Sulla riga di verify()
, specifichiamo come ci aspettiamo che la chiamata a BirthdaysClient
appaia.
Nota che, poiché publishRegularPersonAge ha l'IOException nella sua firma, la aggiungiamo anche alla nostra firma del metodo di test.
A questo punto il test fallisce:
Wanted but not invoked: birthdaysClient.publishRegularPersonAge( "Joe Sixteen", 16L ); -> at com.example.PersonTest.testPublishAge(PersonTest.java:40)
Questo è previsto, dato che non abbiamo ancora implementato le modifiche richieste a Person.java
, poiché stiamo seguendo lo sviluppo basato su test. Ora faremo passare questo test apportando le modifiche necessarie:
Persona.java
// ... class Person { // ... private final BirthdaysClient birthdaysClient; Person(String givenName, String surname, LocalDate dateOfBirth) { this(givenName, surname, dateOfBirth, LocalDate::now, new BirthdaysClient()); } // Visible for testing Person(String givenName, String surname, LocalDate dateOfBirth, Supplier<LocalDate> currentDateSupplier, BirthdaysClient birthdaysClient) { // ... this.birthdaysClient = birthdaysClient; } // ... void publishAge() { String nameToPublish = givenName + " " + surname; long age = getAge(); try { birthdaysClient.publishRegularPersonAge(nameToPublish, age); } catch (IOException e) { // TODO handle this! e.printStackTrace(); } } }
Test per le eccezioni
Abbiamo fatto in modo che il costruttore del codice di produzione istanziasse un nuovo BirthdaysClient
e publishAge()
ora chiama birthdaysClient
. Tutti i test passano; tutto è verde. Grande! Ma nota che publishAge()
sta ingoiando IOException. Invece di lasciarlo fuoriuscire, vogliamo avvolgerlo con la nostra PersonException in un nuovo file chiamato PersonException.java
:
PersonException.java
package com.example; public class PersonException extends Exception { public PersonException(String message, Throwable cause) { super(message, cause); } }
Implementiamo questo scenario come nuovo metodo di test in PersonTest.java
:
PersonaTest.java
// ... class PersonTest { // ... @Test void testPublishAge_IOException() throws IOException { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); IOException ioException = new IOException(); doThrow(ioException).when(birthdaysClient).publishRegularPersonAge("Joe Sixteen", 16); try { person.publishAge(); fail("expected exception not thrown"); } catch (PersonException e) { assertSame(ioException, e.getCause()); assertEquals("Failed to publish Joe Sixteen age 16", e.getMessage()); } } }
La chiamata Mockito doThrow()
stub birthdaysClient
per generare un'eccezione quando viene chiamato il metodo publishRegularPersonAge()
. Se la PersonException
non viene generata, falliamo il test. Altrimenti affermiamo che l'eccezione è stata correttamente concatenata con l'IOException e verifichiamo che il messaggio di eccezione sia come previsto. Al momento, poiché non abbiamo implementato alcuna gestione nel nostro codice di produzione, il nostro test ha esito negativo perché l'eccezione prevista non è stata generata. Ecco cosa dobbiamo cambiare in Person.java
per far passare il test:
Persona.java
// ... class Person { // ... void publishAge() throws PersonException { // ... try { // ... } catch (IOException e) { throw new PersonException("Failed to publish " + nameToPublish + " age " + age, e); } } }
Stub: quando e asserzioni
Ora implementiamo il metodo Person.getThoseInCommon()
, rendendo la nostra classe Person.Java
simile a questa.
Il nostro testGetThoseInCommon()
, a differenza testPublishAge()
, non verifica che siano state effettuate chiamate particolari ai metodi birthdaysClient
. Invece usa when
le chiamate per stub restituiscono valori per le chiamate a findFamousNamesOfAge()
e findFamousNamesBornOn()
che getThoseInCommon()
dovrà effettuare. Affermiamo quindi che tutti e tre i nomi stub che abbiamo fornito vengono restituiti.
Il wrapping di più asserzioni con il assertAll()
JUnit 5 consente di controllare tutte le asserzioni nel loro insieme, anziché interrompersi dopo la prima asserzione non riuscita. Includiamo anche un messaggio con assertTrue()
per identificare nomi particolari che non sono inclusi. Ecco come appare il nostro metodo di test "percorso felice" (uno scenario ideale) (nota, questo non è un insieme robusto di test per natura di "percorso felice", ma parleremo del perché più avanti.
PersonaTest.java
// ... class PersonTest { // ... @Test void testGetThoseInCommon() throws IOException, PersonException { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); when(birthdaysClient.findFamousNamesOfAge(16)).thenReturn(Arrays.asList("JoeFamous Sixteen", "Another Person")); when(birthdaysClient.findFamousNamesBornOn(1, 2)).thenReturn(Arrays.asList("Jan TwoKnown")); Set<String> thoseInCommon = person.getThoseInCommon(); assertAll( setContains(thoseInCommon, "Another Person"), setContains(thoseInCommon, "Jan TwoKnown"), setContains(thoseInCommon, "JoeFamous Sixteen"), ()-> assertEquals(3, thoseInCommon.size()) ); } private <T> Executable setContains(Set<T> set, T expected) { return () -> assertTrue(set.contains(expected), "Should contain " + expected); } // ... }
Mantieni pulito il codice di prova
Sebbene spesso trascurato, è altrettanto importante mantenere il codice di test libero da duplicazioni suppuranti. Codice pulito e principi come "non ripetere te stesso" sono molto importanti per mantenere una base di codice di alta qualità, sia per la produzione che per il codice di test. Si noti che il PersonTest.java più recente presenta alcune duplicazioni ora che abbiamo diversi metodi di test.
Per risolvere questo problema, possiamo fare una manciata di cose:
Estrarre l'oggetto IOException in un campo finale privato.
Estrarre la creazione dell'oggetto
Person
nel proprio metodo (createJoeSixteenJan2()
, in questo caso) poiché la maggior parte degli oggetti Person vengono creati con gli stessi parametri.Crea un
assertCauseAndMessage()
per i vari test che verificanoPersonExceptions
.
I risultati del codice pulito possono essere visualizzati in questa rappresentazione del file PersonTest.java.
Prova più del sentiero felice
Cosa dobbiamo fare quando un oggetto Person
ha una data di nascita successiva alla data corrente? I difetti nelle applicazioni sono spesso dovuti a input imprevisti o alla mancanza di previsione in casi d'angolo, bordo o limite. È importante cercare di anticipare queste situazioni nel miglior modo possibile e gli unit test sono spesso un luogo appropriato per farlo. Nella creazione del nostro Person
and PersonTest
, abbiamo incluso alcuni test per le eccezioni previste, ma non era affatto completo. Ad esempio, utilizziamo LocalDate
che non rappresenta né memorizza i dati del fuso orario. Le nostre chiamate a LocalDate.now()
, tuttavia, restituiscono un LocalDate
basato sul fuso orario predefinito del sistema, che potrebbe essere un giorno prima o dopo quello dell'utente di un sistema. Questi fattori dovrebbero essere considerati con test e comportamenti appropriati implementati.
Anche i confini dovrebbero essere testati. Considera un oggetto Person
con un metodo getDaysUntilBirthday()
. Il test dovrebbe includere se il compleanno della persona è già trascorso o meno nell'anno in corso, se il compleanno della persona è oggi e in che modo un anno bisestile influisce sul numero di giorni. Questi scenari possono essere coperti controllando un giorno prima del compleanno della persona, il giorno e un giorno dopo il compleanno della persona in cui l'anno successivo è bisestile. Ecco il codice di prova pertinente:
PersonaTest.java
// ... class PersonTest { private final Supplier<LocalDate> currentDateSupplier = ()-> LocalDate.parse("2015-05-02"); private final LocalDate ageJustOver5 = LocalDate.parse("2010-05-01"); private final LocalDate ageExactly5 = LocalDate.parse("2010-05-02"); private final LocalDate ageAlmost5 = LocalDate.parse("2010-05-03"); // ... @Test void testGetDaysUntilBirthday() { assertAll( createPersonAndAssertValue(ageAlmost5, 1, Person::getDaysUntilBirthday), createPersonAndAssertValue(ageExactly5, 0, Person::getDaysUntilBirthday), createPersonAndAssertValue(ageJustOver5, 365, Person::getDaysUntilBirthday) ); } private Executable createPersonAndAssertValue(LocalDate dateOfBirth, long expectedValue, Function<Person, Long> personLongFunction) { Person person = new Person("Given", "Sur", dateOfBirth, currentDateSupplier); long actualValue = personLongFunction.apply(person); return () -> assertEquals(expectedValue, actualValue); } }
Test di integrazione
Ci siamo concentrati principalmente sui test unitari, ma JUnit può essere utilizzato anche per test di integrazione, accettazione, funzionali e di sistema. Tali test spesso richiedono più codice di configurazione, ad esempio l'avvio di server, il caricamento di database con dati noti, ecc. Anche se spesso possiamo eseguire migliaia di test unitari in pochi secondi, l'esecuzione di suite di test di integrazioni di grandi dimensioni potrebbe richiedere minuti o addirittura ore. I test di integrazione in genere non dovrebbero essere utilizzati per cercare di coprire ogni permutazione o percorso attraverso il codice; i test unitari sono più appropriati per questo.
La creazione di test per le applicazioni Web che guidano i browser Web nella compilazione di moduli, facendo clic sui pulsanti, in attesa del caricamento del contenuto, ecc., viene comunemente eseguita utilizzando Selenium WebDriver (licenza Apache 2.0) insieme al 'Page Object Pattern' (vedi il wiki di SeleniumHQ github e l'articolo di Martin Fowler su Page Objects).
JUnit è efficace per testare le API RESTful con l'uso di un client HTTP come Apache HTTP Client o Spring Rest Template (HowToDoInJava.com fornisce un buon esempio).
Nel nostro caso con l'oggetto Person
, un test di integrazione potrebbe comportare l'utilizzo del vero BirthdaysClient
piuttosto che di uno fittizio, con una configurazione che specifica l'URL di base del servizio People Birthdays. Un test di integrazione utilizzerà quindi un'istanza di test di tale servizio, verificherà che i compleanni siano stati pubblicati su di esso e creerà personaggi famosi nel servizio che verrebbero restituiti.
Altre caratteristiche di JUnit
JUnit ha molte funzionalità aggiuntive che non abbiamo ancora esplorato negli esempi. Ne descriveremo alcuni e forniremo riferimenti per altri.
Dispositivi di prova
Va notato che JUnit crea una nuova istanza della classe test per l'esecuzione di ogni metodo @Test
. JUnit fornisce anche hook di annotazione per eseguire metodi particolari prima o dopo tutti o ciascuno dei metodi @Test
. Questi hook vengono spesso utilizzati per impostare o ripulire database o oggetti fittizi e differiscono tra JUnit 4 e 5.
Giugno 4 | giugno 5 | Per un metodo statico? |
---|---|---|
@BeforeClass | @BeforeAll | sì |
@AfterClass | @AfterAll | sì |
@Before | @BeforeEach | No |
@After | @AfterEach | No |
Nel nostro esempio PersonTest
, abbiamo scelto di configurare l'oggetto mock BirthdaysClient
nei metodi @Test
stessi, ma a volte è necessario creare strutture fittizie più complesse che coinvolgono più oggetti. @BeforeEach
(in JUnit 5) e @Before
(in JUnit 4) sono spesso appropriati per questo.
Le annotazioni @After*
sono più comuni con i test di integrazione rispetto agli unit test poiché la Garbage Collection JVM gestisce la maggior parte degli oggetti creati per gli unit test. Le annotazioni @BeforeClass
e @BeforeAll
sono più comunemente utilizzate per i test di integrazione che devono eseguire costose azioni di configurazione e smontaggio una volta, anziché per ciascun metodo di test.
Per JUnit 4, fare riferimento alla guida ai dispositivi di prova (i concetti generali si applicano ancora a JUnit 5).
Suite di prova
A volte si desidera eseguire più test correlati, ma non tutti i test. In questo caso, i raggruppamenti di test possono essere composti in test suite. Per come farlo in JUnit 5, dai un'occhiata all'articolo JUnit 5 di HowToProgram.xyz e nella documentazione del team JUnit per JUnit 4.
@Nested e @DisplayName di JUnit 5
JUnit 5 aggiunge la possibilità di utilizzare classi interne nidificate non statiche per mostrare meglio la relazione tra i test. Questo dovrebbe essere molto familiare a coloro che hanno lavorato con le descrizioni nidificate in framework di test come Jasmine per JavaScript. Le classi interne sono annotate con @Nested
per usarlo.
Anche l'annotazione @DisplayName
è nuova per JUnit 5, consentendo di descrivere il test per la creazione di report in formato stringa, da mostrare oltre all'identificatore del metodo di test.
Sebbene @Nested
e @DisplayName
possano essere utilizzati indipendentemente l'uno dall'altro, insieme possono fornire risultati di test più chiari che descrivono il comportamento del sistema.
Matchers Hamcrest
Il framework Hamcrest, sebbene di per sé non faccia parte della base di codice JUnit, fornisce un'alternativa all'utilizzo dei tradizionali metodi di asserzione nei test, consentendo un codice di test più espressivo e leggibile. Vedere la seguente verifica utilizzando sia un assertEquals tradizionale che un assertThat Hamcrest:
//Traditional assert assertEquals("Hayden, Josh", displayName); //Hamcrest assert assertThat(displayName, equalTo("Hayden, Josh"));
Hamcrest può essere utilizzato sia con JUnit 4 che con 5. Il tutorial di Vogella.com su Hamcrest è abbastanza completo.
Risorse addizionali
L'articolo Test unitari, Come scrivere codice verificabile e Perché è importante tratta esempi più specifici di scrittura di codice pulito e verificabile.
Build with Confidence: A Guide to JUnit Tests esamina diversi approcci ai test unitari e di integrazione e perché è meglio sceglierne uno e attenersi ad esso
Il Wiki di JUnit 4 e la Guida per l'utente di JUnit 5 sono sempre un ottimo punto di riferimento.
La documentazione di Mockito fornisce informazioni su funzionalità ed esempi aggiuntivi.
JUnit è il percorso verso l'automazione
Abbiamo esplorato molti aspetti dei test nel mondo Java con JUnit. Abbiamo esaminato i test unitari e di integrazione utilizzando il framework JUnit per le basi di codice Java, integrando JUnit negli ambienti di sviluppo e build, come utilizzare mock e stub con fornitori e Mockito, convenzioni comuni e migliori pratiche di codice, cosa testare e alcuni dei altre fantastiche funzionalità di JUnit.
Ora è il turno del lettore di crescere nell'applicare, mantenere e raccogliere abilmente i vantaggi dei test automatizzati utilizzando il framework JUnit.