Testarea prin exemplu

Publicat: 2022-02-15

Continuăm cu seria noastră de bloguri despre tot ce ține de testare. În acest blog, ne concentrăm pe exemple reale.

În timp ce exemplele din această postare sunt scrise folosind JUnit 5 și AssertJ, lecțiile sunt aplicabile oricărui alt cadru de testare unitară.

JUnit este cel mai popular cadru de testare pentru Java. AssertJ este o bibliotecă Java care ajută dezvoltatorii să scrie teste mai expresive.

Structura de bază a testului

Primul exemplu de test pe care îl vom analiza este un calculator simplu pentru adăugarea a 2 numere.

 clasa CalculatorShould {

     @Test // 1
     sumă nulă() {
         Calculator calculator = nou Calculator(); // 2
         int rezultat = calculator.sum(1, 2); // 3
         assertThat(rezultat).isEqualTo(3); // 4
     }
}

Prefer să folosesc convenția de denumire ClassShould atunci când scriu teste pentru a evita repetarea should sau test în fiecare nume de metodă. Puteți citi mai multe despre el aici.

Ce face testul de mai sus?

Să rupem linie cu linie de test:

  1. Adnotarea @Test permite cadrului JUnit să știe ce metode sunt menite să fie rulate ca teste. Este perfect normal să existe metode private în clasa de testare care nu sunt teste.
  2. Aceasta este faza de aranjare a testului nostru, în care pregătim mediul de testare. Tot ce ne trebuie pentru acest test este să avem o instanță Calculator .
  3. Aceasta este faza actului în care declanșăm comportamentul pe care vrem să-l testăm.
  4. Aceasta este faza de afirmare în care inspectăm ce s-a întâmplat și dacă totul s-a rezolvat conform așteptărilor. assertThat(result) face parte din biblioteca AssertJ și are mai multe supraîncărcări.

Fiecare supraîncărcare returnează un obiect Assert specializat. Obiectul returnat are metode care au sens pentru obiectul pe care l-am transmis metodei assertThat . În cazul nostru, acel obiect este AbstractIntegerAssert cu metode de testare a numerelor întregi. isEqualTo(3) va verifica dacă result == 3 . Dacă este, testul va trece și va eșua în caz contrar.

Nu ne vom concentra pe nicio implementare în această postare de blog.

Un alt mod de a gândi despre Aranjare , Acționare , Afirmare este Dat , Când , Atunci .

După ce sum implementarea noastră, ne putem pune câteva întrebări:

  • Cum pot îmbunătăți acest test?
  • Există mai multe cazuri de testare pe care ar trebui să le acopăr?
  • Ce se întâmplă dacă adun un număr pozitiv și unul negativ? Două numere negative? Unul pozitiv și unul negativ?
  • Ce se întâmplă dacă depășesc valoarea întreagă?

Să adăugăm aceste cazuri și să îmbunătățim puțin numele testului existent.

Nu vom permite depășiri în implementarea noastră. Dacă sum depășește, vom arunca în schimb o ArithmeticException .

 clasa CalculatorShould {

     calculator calculator privat = Calculator nou();

     @Test
     void sumPositiveNumbers() {
         int suma = calculator.sum(1, 2);
         assert That(sum).isEqualTo(3);
     }

     @Test
     void sumNegativeNumbers() {
         int sum = calculator.sum(-1, -1);
         assert That(sum).isEqualTo(-2);
     }

     @Test
     void sumPositiveAndNegativeNumbers() {
         int suma = calculator.sum(1, -2);
         assert That(sum).isEqualTo(-1);
     }

     @Test
     void failWithArithmeticExceptionWhenOverflown() {
         assertThatThrownBy(() -> calculator.sum(Integer.MAX_VALUE, 1))
             .isInstanceOf(ArithmeticException.class);
     } 

}

JUnit va crea o nouă instanță a CalculatorShould înainte de a rula fiecare metodă @Test . Asta înseamnă că fiecare CalculatorShould ar trebui să aibă un calculator diferit, astfel încât să nu fie nevoie să-l insinuăm în fiecare test.

testul shouldFailWithArithmeticExceptionWhenOverflown folosește un alt tip de assert . Verifică dacă o bucată de cod a eșuat. Metoda assertThatThrownBy va rula lambda pe care l-am furnizat și se va asigura că a eșuat. După cum știm deja, toate metodele assertThat returnează un Assert specializat care ne permite să verificăm ce tip de excepție a apărut.

Acesta este un exemplu despre cum putem testa dacă codul nostru eșuează atunci când ne așteptăm. Dacă la un moment dat refactorăm Calculator și acesta nu aruncă ArithmeticException pe un overflow, testul nostru va eșua.

Model de design ObjectMother

Următorul exemplu este o clasă de validare pentru a se asigura că o instanță Person este validă.

 clasa PersonValidatorShould {

    validator privat PersonValidator = new PersonValidator();

    @Test
    void failWhenNameIsNull() {
        Persoană persoană = Persoană nouă(null, 20, Adresă nouă(...), ...);

        assertThatThrownBy(() -> validator.validate(persoana))
            .isInstanceOf(InvalidPersonException.class);
    }

    @Test
    void failWhenAgeIsNegative() {
        Persoană persoană = Persoană nouă(„Ioan”, -5, Adresă nouă(...), ...);

        assertThatThrownBy(() -> validator.validate(persoana))
            .isInstanceOf(InvalidPersonException.class);

    }
}

Modelul de design ObjectMother este adesea folosit în teste care creează obiecte complexe pentru a ascunde detaliile de instanțiere din test. Mai multe teste ar putea chiar să creeze același obiect, dar să testeze lucruri diferite pe el.

Testul #1 este foarte asemănător cu testul #2. Putem refactoriza PersonValidatorShould prin extragerea validării ca metodă privată, apoi îi transmitem instanțe Person ilegale, așteptându-se ca toate să eșueze în același mod.

 clasa PersonValidatorShould {

     validator privat PersonValidator = new PersonValidator();

     @Test
     void failWhenNameIsNull() {
         shouldFailValidation(PersonObjectMother.createPersonWithoutName());
     }

     @Test
     void failWhenAgeIsNegative() {
         shouldFailValidation(PersonObjectMother.createPersonWithNegativeAge());
     }

     private void shouldFailValidation(Person invalidPerson) {
         assertThatThrownBy(() -> validator.validate(invalidPerson))
             .isInstanceOf(InvalidPersonException.class);
   
     }
 }

Testarea aleatoriei

Cum ar trebui să testăm aleatoritatea în codul nostru?

Să presupunem că avem un PersonGenerator care are generateRandom pentru a genera instanțe aleatorii Person .

Începem prin a scrie următoarele:

 clasa PersonGeneratorShould {

     privat PersonGenerator generator = new PersonGenerator();

     @Test
     void generateValidPerson() {
         Persoana persoana = generator.generateRandom();
         assert That(persoana).
    }
}

Și atunci ar trebui să ne întrebăm:

  • Ce încerc să demonstrez aici? Ce trebuie să facă această funcționalitate?
  • Ar trebui doar să verific dacă persoana generată este o instanță non-nulă?
  • Trebuie să demonstrez că este aleatoriu?
  • Instanța generată trebuie să respecte anumite reguli de afaceri?

Ne putem simplifica testul folosind Dependency Injection.

 interfață publică RandomGenerator {
     String generateRandomString();
     int generateRandomInteger();
}

PersonGenerator are acum un alt constructor care acceptă și o instanță a acelei interfețe. În mod implicit, folosește implementarea JavaRandomGenerator care generează valori aleatorii folosind java.Random .

Cu toate acestea, în test, putem scrie o altă implementare, mai previzibilă.

 @Test
 void generateValidPerson() {
     RandomGenerator randomGenerator = new PredictableGenerator(„John Doe”, 20);
     PersonGenerator generator = new PersonGenerator(randomGenerator);
     Persoana persoana = generator.generateRandom();
     assert That(persoana).isEqualTo(new Person(„John Doe”, 20));
}

Acest test demonstrează că PersonGenerator generează instanțe aleatorii, așa cum este specificat de RandomGenerator , fără a intra în detalii despre RandomGenerator .

Testarea JavaRandomGenerator nu aduce cu adevărat nicio valoare, deoarece este un simplu wrapper în jurul java.Random . Testând-o, ați testa în esență java.Random din biblioteca standard Java. Scrierea de teste evidente va duce doar la întreținere suplimentară cu beneficii puține sau deloc.

Pentru a evita scrierea de implementări în scopuri de testare, cum ar fi PredictableGenerator , ar trebui să utilizați o bibliotecă batjocoritoare, cum ar fi Mockito.

Când am scris PredictableGenerator , am blocat manual clasa RandomGenerator . Ați fi putut să-l împodobiți și folosind Mockito:

 @Test
 void generateValidPerson() {
     RandomGenerator randomGenerator = mock(RandomGenerator.class);
     când(randomGenerator.generateRandomString()).thenReturn(„John Doe”);
     când(randomGenerator.generateRandomInteger()).thenReturn(20);

     PersonGenerator generator = new PersonGenerator(randomGenerator);
     Persoana persoana = generator.generateRandom();
     assert That(persoana).isEqualTo(new Person(„John Doe”, 20));
 }

Acest mod de scriere a testelor este mai expresiv și duce la mai puține implementări pentru teste specifice.

Mockito este o bibliotecă Java pentru scrierea de mock-uri și stub-uri. Este foarte util atunci când testați codul care depinde de biblioteci externe pe care nu le puteți instanția cu ușurință. Vă permite să scrieți comportamentul pentru aceste clase fără a le implementa direct.

Mockito permite, de asemenea, o altă sintaxă pentru crearea și injectarea de false pentru a reduce boilerplate atunci când avem mai multe teste similare cu cele cu care suntem obișnuiți:

 @ExtendWith(MockitoExtension.class) // 1
 clasa PersonGeneratorShould {

     @Mock // 2
     RandomGenerator randomGenerator;

     @InjectMocks // 3
     generator privat PersonGenerator;

     @Test
     void generateValidPerson() {
         când(randomGenerator.generateRandomString()).thenReturn(„John Doe”);
         când(randomGenerator.generateRandomInteger()).thenReturn(20);

         Persoana persoana = generator.generateRandom();
         assert That(persoana).isEqualTo(new Person(„John Doe”, 20));
     }
}

1. JUnit 5 poate folosi „extensii” pentru a-și extinde capacitățile. Această adnotare îi permite să recunoască falsurile prin adnotări și să le injecteze corect.

2. Adnotarea @Mock creează o instanță batjocorită a câmpului. Este același lucru cu scrierea mock(RandomGenerator.class) în corpul metodei noastre de testare.

3. Adnotarea @InjectMocks va crea o nouă instanță de PersonGenerator și va injecta simulari în instanța generator .

Pentru mai multe detalii despre extensiile JUnit 5 vezi aici.

Pentru mai multe detalii despre injectarea Mockito vezi aici.

Există o capcană în utilizarea @InjectMocks . Poate elimina necesitatea de a declara manual o instanță a obiectului, dar pierdem siguranța la compilare a constructorului. Dacă în orice moment cineva adaugă o altă dependență la constructor, nu vom primi aici eroarea de compilare. Acest lucru ar putea duce la teste care nu sunt ușor de detectat. Prefer să folosesc @BeforeEach pentru a configura manual instanța:

 @ExtendWith(MockitoExtension.class)
clasa PersonGeneratorShould {

     @A-și bate joc
     RandomGenerator randomGenerator;

     generator privat PersonGenerator;

     @BeforeEach
     void setUp() {
         generator = new PersonGenerator(randomGenerator);
     }

     @Test
     void generateValidPerson() {
         când(randomGenerator.generateRandomString()).thenReturn(„John Doe”);
         când(randomGenerator.generateRandomInteger()).thenReturn(20);

         Persoana persoana = generator.generateRandom();
         assert That(persoana).isEqualTo(new Person(„John Doe”, 20));
     }
}

Testarea proceselor sensibile la timp

O bucată de cod depinde adesea de marcajele de timp și avem tendința de a folosi metode precum System.currentTimeMillis() pentru a obține marcajul de timp curent al epocii.

Deși acest lucru arată bine, este greu să testăm și să dovedim dacă codul nostru funcționează corect atunci când clasa ia decizii pentru noi în interior. Un exemplu de astfel de decizie ar fi determinarea zilei curente.

 clasa IndexerShould {
     private Indexer indexer = new Indexer();
     @Test
     void generateIndexNameForTomorrow() {
         String indexName = indexer.tomorrow("indexul meu");
         // acest test ar funcționa azi, dar ce zici de mâine?
        assert That(indexName)
           .isEqualTo("indexul-meu.2022-02-02");
     }
}

Ar trebui să folosim din nou Dependency Injection pentru a putea „controla” care este ziua când generăm numele indexului.

Java are o clasă Clock pentru a gestiona cazuri de utilizare precum acesta. Putem transmite o instanță a unui Clock către Indexer nostru pentru a controla timpul. Constructorul implicit ar putea folosi Clock.systemUTC() pentru compatibilitate inversă. Acum putem înlocui apelurile System.currentTimeMillis() cu clock.millis() .

Prin injectarea unui Clock putem impune un timp previzibil în cursurile noastre și putem scrie teste mai bune.

Testarea metodelor de producere a fișierelor

  • Cum ar trebui să testăm clasele care își scriu rezultatul în fișiere?
  • Unde ar trebui să stocăm aceste fișiere pentru ca ele să funcționeze pe orice sistem de operare?
  • Cum ne putem asigura că fișierul nu există deja?

Când avem de-a face cu fișiere, poate fi dificil să scriem teste dacă încercăm să abordăm singuri aceste preocupări, așa cum vom vedea în exemplul următor. Testul care urmează este un test vechi de calitate îndoielnică. Ar trebui să testeze dacă un DogToCsvWriter serializează și scrie câini într-un fișier CSV:

 clasa DogToCsvWriterShould {

     scriitor privat DogToCsvWriter = 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"));

         String csv = Files.readString("/tmp/dogs.csv");

         assertThat(csv).isEqualTo(„Monty,corgi,maro\nZoe,malteză,alb”);
     }
}

Procesul de serializare ar trebui să fie decuplat de procesul de scriere, dar să ne concentrăm pe remedierea testului.

Prima problemă cu testul de mai sus este că nu va funcționa pe Windows, deoarece utilizatorii Windows nu vor putea rezolva calea /tmp/dogs.csv . O altă problemă este că nu va funcționa dacă fișierul există deja, deoarece nu este șters când se execută testul de mai sus. S-ar putea să funcționeze bine într-o conductă CI/CD, dar nu local dacă este rulat de mai multe ori.

JUnit 5 are o adnotare pe care o puteți folosi pentru a obține o referință la un director temporar care este creat și șters de cadru pentru dvs. În timp ce mecanismul de creare și ștergere a fișierelor temporare variază de la cadru la cadru, ideile rămân aceleași.

 clasa DogToCsvWriterShould {

     @Test
     void convertToCsv(@TempDir Cale tempDir) {
         Cale dogsCsv = tempDir.resolve("dogs.csv");
         DogToCsvWriter writer = new DogToCsvWriter(dogsCsv);
         writer.appendAsCsv(New Dog(Breed.CORGI, Color.BROWN, „Monty”));
         writer.appendAsCsv(New Dog(Breed.MALTESE, Color.WHITE, "Zoe"));

         String csv = Files.readString(dogsCsv);

         assertThat(csv).isEqualTo(„Monty,corgi,maro\nZoe,malteză,alb”);
     }
}

Cu această mică schimbare, suntem acum siguri că testul de mai sus va funcționa pe Windows, macOS și Linux fără a fi nevoie să vă faceți griji cu privire la căile absolute. De asemenea, va șterge fișierele create după test, astfel încât să putem rula acum de mai multe ori și să obținem rezultate previzibile de fiecare dată.

Testare comandă vs interogare

Care este diferența dintre o comandă și o interogare?

  • Comanda : instruim unui obiect să efectueze o acțiune care produce un efect fără a returna o valoare (metode void)
  • Interogare : cerem unui obiect să efectueze o acțiune și să returnăm un rezultat sau o excepție

Până acum, am testat în principal interogări în care am apelat o metodă care a returnat o valoare sau a aruncat o excepție în faza actului. Cum putem testa metodele void și să vedem dacă interacționează corect cu alte clase? Framework-urile oferă un set diferit de metode pentru scrierea acestor tipuri de teste.

Afirmațiile pe care le-am scris până acum pentru interogări începeau cu assertThat . Când scriem teste de comandă, folosim un set diferit de metode, deoarece nu mai inspectăm rezultatele directe ale metodelor așa cum am făcut cu interogările. Dorim să „verificăm” interacțiunile pe care metoda noastră le-a avut cu alte părți ale sistemului nostru.

 @ExtendWith(MockitoExtension.class)
 clasa FeedMentionServiceShould {

     @A-și bate joc
     depozit privat FeedRepository;

     @A-și bate joc
     emițător privat FeedMentionEventEmitter;

     serviciu privat FeedMentionService;

     @BeforeEach
     void setUp() {
         service = new FeedMentionService (depozitar, emitator);
     }

     @Test
     void insertMentionToFeed() {
         feedId lung = 1L;
         Menționează mențiunea = ...;

         când(repository.upsertMention(feedId, mentionare))
             .thenReturn(UpsertResult.success(feedId, mentionare));

         FeedInsertionEvent event = new FeedInsertionEvent(feedId, mentionare);
         mentionService.insertMentionToFeed(eveniment);

         verifica(emitter).mentionInsertedToFeed(feedId, mention);
         verifyNoMoreInteractions(emițător);
     }
}

În acest test, mai întâi ne-am batjocorit depozitul pentru a răspunde cu un UpsertResult.success atunci când a fost solicitat să ridicăm mențiunea în feedul nostru. Nu ne preocupă testarea depozitului aici. Metodele de depozit ar trebui testate în FeedRepositoryShould . Batjocorind acest comportament, nu am numit de fapt metoda depozitului. Pur și simplu i-am spus cum să răspundă data viitoare când este sunat.

Apoi i-am spus mentionService să insereze această mențiune în feedul nostru. Știm că ar trebui să emită rezultatul doar dacă a introdus cu succes mențiunea în feed. Folosind metoda de verify , ne putem asigura că metoda mentionInsertedToFeed a fost apelată cu mențiunea și feedul nostru și nu a fost apelată din nou folosind verifyNoMoreInteractions .

Gânduri finale

Scrierea de teste de calitate vine din experiență, iar cel mai bun mod de a învăța este prin a face. Sfaturile scrise în acest blog vin din practică. Este greu să vezi unele dintre capcane dacă nu le-ai întâlnit niciodată și, sperăm, că aceste sugestii ar trebui să facă designul codului mai robust. Dacă aveți teste fiabile, vă va crește încrederea în a schimba lucrurile fără să vă transpirați de fiecare dată când trebuie să implementați codul.

Vă interesează să vă alăturați echipei Mediatoolkit?
Consultați poziția noastră deschisă pentru Dezvoltator Frontend Senior !