Test con l'esempio

Pubblicato: 2022-02-15

Continuiamo 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:

  1. L'annotazione @Test consente al framework JUnit di sapere quali metodi devono essere eseguiti come test. È perfettamente normale avere metodi private ​​nella classe test che non sono test.
  2. 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 .
  3. Questa è la fase dell'atto in cui attiviamo il comportamento che vogliamo testare.
  4. 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 !