Test con l'esempio
Pubblicato: 2022-02-15Continuiamo con la nostra serie di blog su tutto ciò che riguarda i test. In questo blog, ci concentriamo su esempi reali.
Sebbene gli esempi in questo post siano scritti utilizzando JUnit 5 e AssertJ, le lezioni sono applicabili a qualsiasi altro framework di unit test.
JUnit è il framework di test più popolare per Java. AssertJ è una libreria Java che aiuta gli sviluppatori a scrivere test più espressivi.
Struttura di prova di base
Il primo esempio di test che esamineremo è una semplice calcolatrice per sommare 2 numeri.
calcolatrice di classeDovrei { @Test // 1 somma nulla() { Calcolatrice calcolatrice = new Calcolatrice(); // 2 int risultato = calcolatrice.sum(1, 2); // 3 assertThat(risultato).isEqualTo(3); // 4 } }
Preferisco usare la convenzione di denominazione ClassShould
durante la scrittura di test per evitare di ripetere should
o test
in ogni nome di metodo. Puoi leggere di più a riguardo qui.
A cosa serve il test di cui sopra?
Rompiamo il test riga per riga:
- L'annotazione
@Test
consente al framework JUnit di sapere quali metodi devono essere eseguiti come test. È perfettamente normale avere metodiprivate
nella classe test che non sono test. - Questa è la fase di organizzazione del nostro test, in cui prepariamo l'ambiente di test. Tutto ciò di cui abbiamo bisogno per questo test è avere un'istanza di
Calculator
. - Questa è la fase dell'atto in cui attiviamo il comportamento che vogliamo testare.
- Questa è la fase di asserzione in cui ispezioniamo cosa è successo e se tutto si è risolto come previsto.
assertThat(result)
fa parte della libreria AssertJ e ha più overload.
Ogni sovraccarico restituisce un oggetto Assert
specializzato. L'oggetto restituito ha metodi che hanno senso per l'oggetto che abbiamo passato al metodo assertThat
. Nel nostro caso, quell'oggetto è AbstractIntegerAssert
con metodi per testare gli interi. isEqualTo(3)
verificherà se result == 3
. Se lo è, il test passerà e fallirà altrimenti.
Non ci concentreremo su nessuna implementazione in questo post del blog.
Un altro modo di pensare ad Arrange , Act , Assert è Dato , Quando , Allora .
Dopo aver scritto la nostra implementazione sum
, possiamo farci alcune domande:
- Come posso migliorare in questo test?
- Ci sono più casi di test che dovrei coprire?
- Cosa succede se aggiungo un numero positivo e uno negativo? Due numeri negativi? Uno positivo e uno negativo?
- Cosa succede se travaso il valore intero?
Aggiungiamo questi casi e miglioriamo un po' il nome del test esistente.
Non consentiremo overflow nella nostra implementazione. Se la sum
va in overflow, lanceremo invece ArithmeticException
.
calcolatrice di classeDovrei { calcolatrice privata = new Calcolatrice(); @Test void sumPositiveNumbers() { int sum = calcolatrice.sum(1, 2); assertThat(sum).isEqualTo(3); } @Test void sumNegativeNumbers() { somma int = calcolatrice.sum(-1, -1); assertThat(sum).isEqualTo(-2); } @Test void sumPositiveAndNegativeNumbers() { int sum = calcolatrice.sum(1, -2); assertThat(sum).isEqualTo(-1); } @Test void failWithArithmeticExceptionWhenOverflown() { assertThatThrownBy(() -> calcolatrice.sum(Integer.MAX_VALUE, 1)) .isInstanceOf(ArithmeticException.class); } }
JUnit creerà una nuova istanza di CalculatorShould
prima di eseguire ogni metodo @Test
. Ciò significa che ogni CalculatorShould
dovrebbe avere una calculator
diversa, quindi non dobbiamo citarla in ogni test.
shouldFailWithArithmeticExceptionWhenOverflown
test usa un diverso tipo di assert
. Verifica che un pezzo di codice non sia riuscito. Il metodo assertThatThrownBy
eseguirà il lambda fornito e si assicurerà che non sia riuscito. Come già sappiamo, tutti i metodi assertThat
restituiscono un Assert
specializzato che ci consente di verificare quale tipo di eccezione si è verificata.
Questo è un esempio di come possiamo verificare che il nostro codice non riesce quando ce lo aspettiamo. Se in qualsiasi momento eseguiamo il refactoring di Calculator
e non genera ArithmeticException
in caso di overflow, il nostro test fallirà.
Modello di progettazione ObjectMother
L'esempio successivo è una classe validator per garantire che un'istanza Person sia valida.
class PersonValidatorDovresti { validatore PersonValidator privato = new PersonValidator(); @Test void failWhenNameIsNull() { Persona persona = nuova Persona(null, 20, nuovo Indirizzo(...), ...); assertThatThrownBy(() -> validator.validate(persona)) .isInstanceOf(InvalidPersonException.class); } @Test void failWhenAgeIsNegative() { Persona persona = nuova Persona("Giovanni", -5, nuovo Indirizzo(...), ...); assertThatThrownBy(() -> validator.validate(persona)) .isInstanceOf(InvalidPersonException.class); } }
Il design pattern ObjectMother viene spesso utilizzato nei test che creano oggetti complessi per nascondere i dettagli dell'istanza dal test. Più test potrebbero anche creare lo stesso oggetto ma testare cose diverse su di esso.
Il test n. 1 è molto simile al test n. 2. È possibile eseguire il refactoring di PersonValidatorShould
estraendo la convalida come metodo privato, quindi trasmettendogli istanze di Person
illegali aspettandosi che falliscano tutte allo stesso modo.
class PersonValidatorDovresti { validatore PersonValidator privato = new PersonValidator(); @Test void failWhenNameIsNull() { shouldFailValidation(PersonObjectMother.createPersonWithoutName()); } @Test void failWhenAgeIsNegative() { shouldFailValidation(PersonObjectMother.createPersonWithNegativeAge()); } private void shouldFailValidation(Persona non validaPersona) { assertThatThrownBy(() -> validator.validate(invalidPerson)) .isInstanceOf(InvalidPersonException.class); } }
Testare la casualità
Come dovremmo testare la casualità nel nostro codice?
Supponiamo di avere un PersonGenerator
che ha generateRandom
per generare istanze Person
casuali.
Iniziamo scrivendo quanto segue:
class PersonGeneratorShould { generatore di PersonGenerator privato = new PersonGenerator(); @Test void generateValidPerson() { Persona persona = generator.generateRandom(); assertThat(persona). } }
E allora dovremmo chiederci:
- Cosa sto cercando di dimostrare qui? Cosa deve fare questa funzionalità?
- Devo semplicemente verificare che la persona generata sia un'istanza non nulla?
- Devo dimostrare che è casuale?
- L'istanza generata deve seguire alcune regole aziendali?
Possiamo semplificare il nostro test usando Dependency Injection.
interfaccia pubblica Generatore casuale { Stringa generaRandomString(); int generateRandomInteger(); }
PersonGenerator
ora ha un altro costruttore che accetta anche un'istanza di quell'interfaccia. Per impostazione predefinita, utilizza l'implementazione JavaRandomGenerator
che genera valori casuali utilizzando java.Random
.
Tuttavia, nel test, possiamo scrivere un'altra implementazione più prevedibile.
@Test void generateValidPerson() { RandomGenerator randomGenerator = new PredictableGenerator("John Doe", 20); Generatore PersonGenerator = new PersonGenerator(randomGenerator); Persona persona = generator.generateRandom(); assertThat(person).isEqualTo(new Person("John Doe", 20)); }
Questo test dimostra che PersonGenerator
genera istanze casuali come specificato da RandomGenerator
senza entrare in alcun dettaglio di RandomGenerator
.
Il test di JavaRandomGenerator
non aggiunge alcun valore poiché è un semplice wrapper attorno a java.Random
. Testandolo, proveresti essenzialmente java.Random
dalla libreria standard Java. Scrivere test ovvi porterà solo a una manutenzione aggiuntiva con benefici minimi o nulli.
Per evitare di scrivere implementazioni a scopo di test, come PredictableGenerator
, dovresti usare una libreria fittizia come Mockito.
Quando abbiamo scritto PredictableGenerator
, abbiamo effettivamente stubato manualmente la classe RandomGenerator
. Avresti potuto anche bloccarlo usando Mockito:
@Test void generateValidPerson() { RandomGenerator randomGenerator = mock(RandomGenerator.class); quando(randomGenerator.generateRandomString()).thenReturn("John Doe"); quando(randomGenerator.generateRandomInteger()).thenReturn(20); Generatore PersonGenerator = new PersonGenerator(randomGenerator); Persona persona = generator.generateRandom(); assertThat(person).isEqualTo(new Person("John Doe", 20)); }
Questo modo di scrivere i test è più espressivo e porta a meno implementazioni per test specifici.
Mockito è una libreria Java per scrivere mock e stub. È molto utile durante il test del codice che dipende da librerie esterne che non è possibile creare facilmente un'istanza. Ti consente di scrivere il comportamento per queste classi senza implementarle direttamente.
Mockito consente anche un'altra sintassi per creare e iniettare mock per ridurre il boilerplate quando abbiamo più di un test simile a quello a cui siamo abituati:
@ExtendWith(MockitoExtension.class) // 1 class PersonGeneratorShould { @Mock // 2 Generatore casuale Generatore casuale; @InjectMocks // 3 generatore privato di PersonGenerator; @Test void generateValidPerson() { quando(randomGenerator.generateRandomString()).thenReturn("John Doe"); quando(randomGenerator.generateRandomInteger()).thenReturn(20); Persona persona = generator.generateRandom(); assertThat(person).isEqualTo(new Person("John Doe", 20)); } }
1. JUnit 5 può utilizzare "estensioni" per estendere le sue capacità. Questa annotazione gli consente di riconoscere i mock attraverso le annotazioni e di iniettarli correttamente.
2. L'annotazione @Mock
crea un'istanza simulata del campo. È come scrivere mock(RandomGenerator.class)
nel corpo del nostro metodo di test.
3. L'annotazione @InjectMocks
creerà una nuova istanza di PersonGenerator
e inietterà mock nell'istanza del generator
.
Per maggiori dettagli sulle estensioni di JUnit 5, vedere qui.
Per maggiori dettagli sull'iniezione di Mockito, vedere qui.
C'è una trappola nell'usare @InjectMocks
. Potrebbe eliminare la necessità di dichiarare manualmente un'istanza dell'oggetto, ma perdiamo la sicurezza in fase di compilazione del costruttore. Se in qualsiasi momento qualcuno aggiunge un'altra dipendenza al costruttore, non otterremmo l'errore in fase di compilazione qui. Ciò potrebbe portare a test falliti che non sono facili da rilevare. Preferisco usare @BeforeEach
per configurare manualmente l'istanza:
@ExtendWith(MockitoExtension.class) class PersonGeneratorShould { @Deridere Generatore casuale Generatore casuale; generatore privato di PersonGenerator; @BeforeEach impostazione vuota() { generatore = new PersonGenerator(randomGenerator); } @Test void generateValidPerson() { quando(randomGenerator.generateRandomString()).thenReturn("John Doe"); quando(randomGenerator.generateRandomInteger()).thenReturn(20); Persona persona = generator.generateRandom(); assertThat(person).isEqualTo(new Person("John Doe", 20)); } }
Testare processi sensibili al fattore tempo
Un pezzo di codice dipende spesso dai timestamp e tendiamo a utilizzare metodi come System.currentTimeMillis()
per ottenere il timestamp di epoch corrente.
Anche se questo sembra a posto, è difficile testare e dimostrare se il nostro codice funziona correttamente quando la classe prende le decisioni per noi internamente. Un esempio di tale decisione sarebbe determinare quale sia il giorno corrente.
class IndexerDovrei { indicizzatore privato dell'indicizzatore = nuovo indicizzatore(); @Test void generateIndexNameForTomorrow() { String indexName = indexer.tomorrow("mio-indice"); // questo test funzionerebbe oggi, ma domani? assertThat(nomeindice) .isEqualTo("il mio-indice.2022-02-02"); } }
Dovremmo usare nuovamente Dependency Injection per essere in grado di "controllare" qual è il giorno durante la generazione del nome dell'indice.
Java ha una classe Clock
per gestire casi d'uso come questo. Possiamo passare un'istanza di un Clock
al nostro Indexer
per controllare l'ora. Il costruttore predefinito potrebbe utilizzare Clock.systemUTC()
per la compatibilità con le versioni precedenti. Ora possiamo sostituire le chiamate System.currentTimeMillis()
con clock.millis()
.
Iniettando un Clock
possiamo imporre un tempo prevedibile nelle nostre classi e scrivere test migliori.
Testare i metodi di produzione di file
- Come dovremmo testare le classi che scrivono il loro output su file?
- Dove dovremmo archiviare questi file affinché funzionino su qualsiasi sistema operativo?
- Come possiamo assicurarci che il file non esista già?
Quando si ha a che fare con i file, può essere difficile scrivere dei test se proviamo ad affrontare questi problemi noi stessi, come vedremo nell'esempio seguente. Il test che segue è un vecchio test di dubbia qualità. Dovrebbe verificare se un DogToCsvWriter
serializza e scrive i cani in un file CSV:
class DogToCsvWriterShould { Private DogToCsvWriter writer = new DogToCsvWriter("/tmp/dogs.csv"); @Test void convertToCsv() { writer.appendAsCsv(new Dog(Breed.CORGI, Color.BROWN, "Monty")); writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe")); Stringa csv = Files.readString("/tmp/dogs.csv"); assertThat(csv).isEqualTo("Monty,corgi,marrone\nZoe,maltese,bianco"); } }
Il processo di serializzazione dovrebbe essere separato dal processo di scrittura, ma concentriamoci sulla correzione del test.
Il primo problema con il test sopra è che non funzionerà su Windows poiché gli utenti Windows non saranno in grado di risolvere il percorso /tmp/dogs.csv
. Un altro problema è che non funzionerà se il file esiste già poiché non viene eliminato quando viene eseguito il test sopra. Potrebbe funzionare correttamente in una pipeline CI/CD, ma non localmente se eseguito più volte.
JUnit 5 ha un'annotazione che puoi usare per ottenere un riferimento a una directory temporanea che viene creata ed eliminata dal framework per te. Sebbene il meccanismo di creazione ed eliminazione di file temporanei vari da framework a framework, le idee rimangono le stesse.
class DogToCsvWriterShould { @Test void convertToCsv(@TempDir Percorso tempDir) { Percorso dogsCsv = tempDir.resolve("dogs.csv"); Scrittore DogToCsvWriter = new DogToCsvWriter(dogsCsv); writer.appendAsCsv(new Dog(Breed.CORGI, Color.BROWN, "Monty")); writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe")); Stringa csv = Files.readString(dogsCsv); assertThat(csv).isEqualTo("Monty,corgi,marrone\nZoe,maltese,bianco"); } }
Con questa piccola modifica, ora siamo sicuri che il test di cui sopra funzionerà su Windows, macOS e Linux senza doversi preoccupare dei percorsi assoluti. Eliminerà anche i file creati dopo il test in modo che ora possiamo eseguirlo più volte e ottenere risultati prevedibili ogni volta.
Comando vs test di query
Qual è la differenza tra un comando e una query?
- Comando : indichiamo a un oggetto di eseguire un'azione che produce un effetto senza restituire un valore (metodi void)
- Query : chiediamo a un oggetto di eseguire un'azione e di restituire un risultato o un'eccezione
Finora, abbiamo testato principalmente query in cui abbiamo chiamato un metodo che ha restituito un valore o ha generato un'eccezione nella fase di atto. Come possiamo testare i metodi void
e vedere se interagiscono correttamente con altre classi? I framework forniscono un insieme diverso di metodi per scrivere questo tipo di test.
Le asserzioni che abbiamo scritto finora per le query iniziavano con assertThat
. Quando scriviamo i test dei comandi, utilizziamo un insieme diverso di metodi perché non stiamo più ispezionando i risultati diretti dei metodi come abbiamo fatto con le query. Vogliamo "verificare" le interazioni che il nostro metodo ha avuto con altre parti del nostro sistema.
@ExtendWith(MockitoExtension.class) class FeedMentionServiceShould { @Deridere repository FeedRepository privato; @Deridere emettitore privato FeedMentionEventEmitter; servizio privato FeedMentionService; @BeforeEach impostazione vuota() { servizio = new FeedMentionService(repository, emitter); } @Test void insertMentionToFeed() { feedId lungo = 1L; Menzione menzione = ...; quando(repository.upsertMention(feedId, menzione)) .thenReturn(UpsertResult.success(feedId, menzione)); Evento FeedInsertionEvent = nuovo FeedInsertionEvent(feedId, menzione); menzioneService.insertMentionToFeed(evento); verificare(emettitore).mentionInsertedToFeed(feedId, menzione); verificareNoMoreInteractions(emettitore); } }
In questo test, abbiamo prima preso in giro il nostro repository per rispondere con un UpsertResult.success
quando ci è stato chiesto di inserire una menzione nel nostro feed. Non ci interessa testare il repository qui. I metodi del repository dovrebbero essere testati in FeedRepositoryShould
. Deridendo questo comportamento, in realtà non abbiamo chiamato il metodo repository. Gli abbiamo semplicemente detto come rispondere la prossima volta che viene chiamato.
Abbiamo quindi detto al nostro servizio di mentionService
di inserire questa menzione nel nostro feed. Sappiamo che dovrebbe emettere il risultato solo se ha inserito correttamente la menzione nel feed. Usando il metodo di verify
possiamo assicurarci che il metodo mentionInsertedToFeed
sia stato chiamato con la nostra menzione e feed e non sia stato chiamato di nuovo usando verifyNoMoreInteractions
.
Pensieri finali
Scrivere test di qualità deriva dall'esperienza e il modo migliore per imparare è farlo. I suggerimenti scritti in questo blog provengono dalla pratica. È difficile vedere alcune delle insidie se non le hai mai incontrate e, si spera, questi suggerimenti dovrebbero rendere più solida la progettazione del tuo codice. Avere test affidabili aumenterà la tua sicurezza per cambiare le cose senza sudare ogni volta che devi distribuire il tuo codice.
Ti interessa entrare a far parte del team di Mediatoolkit?
Dai un'occhiata alla nostra posizione aperta per Senior Frontend Developer !