Testowanie na przykładzie

Opublikowany: 2022-02-15

Kontynuujemy naszą serię blogów o wszystkim, co jest związane z testowaniem. W tym blogu skupiamy się na prawdziwych przykładach.

Chociaż przykłady w tym poście są napisane przy użyciu JUnit 5 i AssertJ, lekcje mają zastosowanie do każdej innej struktury testów jednostkowych.

JUnit to najpopularniejszy framework testowy dla Javy. AssertJ to biblioteka Java, która pomaga programistom pisać bardziej ekspresyjne testy.

Podstawowa struktura testowa

Pierwszym przykładem testu, któremu się przyjrzymy, jest prosty kalkulator do dodawania 2 liczb.

 Kalkulator klasPowinien {

     @Test // 1
     nieważna suma () {
         Kalkulator Kalkulator = nowy Kalkulator(); // 2
         int wynik = kalkulator.sum(1, 2); // 3
         asertThat(result).isEqualTo(3); // 4
     }
}

Podczas pisania testów wolę używać konwencji nazewnictwa ClassShould , aby uniknąć powtarzania should lub test w każdej nazwie metody. Więcej na ten temat przeczytasz tutaj.

Co robi powyższy test?

Przełammy testową linię po linii:

  1. Adnotacja @Test pozwala frameworkowi JUnit wiedzieć, które metody mają być uruchamiane jako testy. To całkowicie normalne, że w klasie testowej znajdują się private metody, które nie są testami.
  2. To faza aranżacyjna naszego testu, w której przygotowujemy środowisko testowe. Wszystko, czego potrzebujemy do tego testu, to mieć instancję Calculator .
  3. Jest to faza działania , w której uruchamiamy zachowanie, które chcemy przetestować.
  4. Jest to faza potwierdzenia , w której sprawdzamy, co się stało i czy wszystko rozwiązało się zgodnie z oczekiwaniami. assertThat(result) jest częścią biblioteki AssertJ i ma wiele przeciążeń.

Każde przeciążenie zwraca wyspecjalizowany obiekt Assert . Zwrócony obiekt zawiera metody, które mają sens dla obiektu, który przekazaliśmy do metody assertThat . W naszym przypadku tym obiektem jest AbstractIntegerAssert z metodami testowania liczb całkowitych. isEqualTo(3) sprawdzi, czy result == 3 . Jeśli tak, test będzie zaliczony i nieudany w przeciwnym razie.

W tym poście na blogu nie będziemy się skupiać na żadnych implementacjach.

Innym sposobem myślenia o Aranżuj , Działaj , Potwierdź , jest Dana , Kiedy , Wtedy .

Po napisaniu naszej sum realizacji możemy zadać sobie kilka pytań:

  • Jak mogę poprawić ten test?
  • Czy jest więcej przypadków testowych, które powinienem omówić?
  • Co się stanie, jeśli dodam liczbę dodatnią i ujemną? Dwie liczby ujemne? Jeden pozytywny i jeden negatywny?
  • Co się stanie, jeśli przepełnię wartość całkowitą?

Dodajmy te przypadki i trochę ulepszmy istniejącą nazwę testu.

Nie dopuścimy do przepełnień w naszej realizacji. Jeśli sum się przepełni, zamiast tego wyrzucimy ArithmeticException .

 Kalkulator klasPowinien {

     prywatny Kalkulator Kalkulator = nowy Kalkulator();

     @Test
     void sumPositiveNumbers() {
         int sum = kalkulator.sum(1, 2);
         asertThat(sum).isEqualTo(3);
     }

     @Test
     void sumNegativeNumbers() {
         int sum = kalkulator.sum(-1, -1);
         AssertThat(sum).isEqualTo(-2);
     }

     @Test
     void sumPositiveAndNegativeNumbers() {
         int sum = kalkulator.sum(1, -2);
         ASSERTThat(sum).isEqualTo(-1);
     }

     @Test
     void failWithArithmeticExceptionWhen Overflown() {
         AssertThatThrownBy(() -> Calculator.sum(Integer.MAX_VALUE, 1))
             .isInstanceOf(ArithmeticException.class);
     } 

}

JUnit utworzy nową instancję CalculatorShould przed uruchomieniem każdej metody @Test . Oznacza to, że każdy CalculatorShould powinien mieć inny calculator , więc nie musimy go umieszczać w każdym teście.

test shouldFailWithArithmeticExceptionWhenOverflown używa innego rodzaju assert . Sprawdza, czy fragment kodu się nie powiódł. Metoda assertThatThrownBy uruchomi podaną przez nas lambdę i upewni się, że się nie powiodła. Jak już wiemy, wszystkie metody assertThat zwracają wyspecjalizowany Assert pozwalający sprawdzić, jaki typ wyjątku wystąpił.

To jest przykład tego, jak możemy sprawdzić, czy nasz kod zawodzi, gdy tego oczekujemy. Jeśli w dowolnym momencie dokonamy refaktoryzacji Calculator i nie wyrzuci on ArithmeticException w przypadku przepełnienia, nasz test zakończy się niepowodzeniem.

Wzorzec projektowy ObjectMatka

Następny przykład to klasa walidatora zapewniająca, że ​​instancja Person jest prawidłowa.

 class PersonValidatorPowinien {

    prywatny walidator PersonValidator = new PersonValidator();

    @Test
    void failWhenNameIsNull() {
        Osoba osoba = nowa Osoba(null, 20, nowy Adres(...), ...);

        attachThatThrownBy(() -> validator.validate(osoba))
            .isInstanceOf(InvalidPersonException.class);
    }

    @Test
    void failWhenAgeIsNegative() {
        Osoba osoba = nowa Osoba("Jan", -5, nowy Adres(...), ...);

        attachThatThrownBy(() -> validator.validate(osoba))
            .isInstanceOf(InvalidPersonException.class);

    }
}

Wzorzec projektowy ObjectMother jest często używany w testach, które tworzą złożone obiekty, aby ukryć szczegóły wystąpienia przed testem. Wiele testów może nawet stworzyć ten sam obiekt, ale przetestować na nim różne rzeczy.

Test #1 jest bardzo podobny do testu #2. Możemy dokonać refaktoryzacji PersonValidatorShould , wyodrębniając walidację jako metodę prywatną, a następnie przekaż do niej nielegalne instancje Person , oczekując, że wszystkie zawiodą w ten sam sposób.

 class PersonValidatorPowinien {

     prywatny walidator PersonValidator = new PersonValidator();

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

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

     private void shouldFailValidation(osoba nieważna osoba) {
         attachThatThrownBy(() -> validator.validate(invalidPerson))
             .isInstanceOf(InvalidPersonException.class);
   
     }
 }

Testowanie losowości

Jak mamy testować losowość w naszym kodzie?

Załóżmy, że mamy PersonGenerator , który ma generateRandom do generowania losowych instancji Person .

Zaczynamy od napisania:

 klasa OsobaGeneratorPowinna {

     prywatny generator PersonGenerator = new PersonGenerator();

     @Test
     void wygeneruj prawidłową osobę () {
         Osoba osoba = generator.generateRandom();
         asercja, że ​​(osoba).
    }
}

A potem powinniśmy zadać sobie pytanie:

  • Co próbuję tutaj udowodnić? Do czego służy ta funkcja?
  • Czy mam po prostu sprawdzić, czy wygenerowana osoba jest instancją inną niż null?
  • Czy muszę udowodnić, że jest losowy?
  • Czy wygenerowana instancja musi przestrzegać jakichś reguł biznesowych?

Możemy uprościć nasz test za pomocą Dependency Injection.

 interfejs publiczny RandomGenerator {
     Ciąg generujeRandomString();
     int generateRandomInteger();
}

PersonGenerator ma teraz inny konstruktor, który również akceptuje instancję tego interfejsu. Domyślnie używa implementacji JavaRandomGenerator , która generuje losowe wartości za pomocą java.Random .

Jednak w teście możemy napisać inną, bardziej przewidywalną implementację.

 @Test
 void wygeneruj prawidłową osobę () {
     RandomGenerator randomGenerator = new PredictableGenerator("Jan Kowalski", 20);
     Generator PersonGenerator = new PersonGenerator(randomGenerator);
     Osoba osoba = generator.generateRandom();
     attachThat(person).isEqualTo(nowa osoba("Jan Kowalski", 20));
}

Ten test dowodzi, że PersonGenerator generuje losowe instancje określone przez RandomGenerator bez wchodzenia w żadne szczegóły RandomGenerator .

Testowanie JavaRandomGenerator tak naprawdę nie dodaje żadnej wartości, ponieważ jest to proste opakowanie wokół java.Random . Testując go, zasadniczo testowałbyś java.Random ze standardowej biblioteki Java. Pisanie oczywistych testów doprowadzi tylko do dodatkowej konserwacji przy niewielkich, jeśli w ogóle, korzyściach.

Aby uniknąć pisania implementacji do celów testowych, takich jak PredictableGenerator , powinieneś użyć biblioteki fikcyjnej, takiej jak Mockito.

Kiedy pisaliśmy PredictableGenerator , ręcznie skasowaliśmy klasę RandomGenerator . Mógłbyś też go zatkać za pomocą Mockito:

 @Test
 void wygeneruj prawidłową osobę () {
     RandomGenerator randomGenerator = mock(RandomGenerator.class);
     when(losowyGenerator.generateRandomString()).thenReturn("Jan Kowalski");
     when(randomGenerator.generateRandomInteger()).thenReturn(20);

     Generator PersonGenerator = new PersonGenerator(randomGenerator);
     Osoba osoba = generator.generateRandom();
     attachThat(person).isEqualTo(nowa osoba("Jan Kowalski", 20));
 }

Ten sposób pisania testów jest bardziej wyrazisty i prowadzi do mniejszej liczby implementacji dla konkretnych testów.

Mockito to biblioteka Java do pisania mocków i skrótów. Jest to bardzo przydatne podczas testowania kodu, który zależy od zewnętrznych bibliotek, których nie można łatwo utworzyć. Pozwala na pisanie zachowań dla tych klas bez bezpośredniego ich implementowania.

Mockito pozwala również na inną składnię do tworzenia i wstrzykiwania mocków, aby zredukować boilerplate, gdy mamy więcej niż jeden test podobny do tego, do którego jesteśmy przyzwyczajeni:

 @ExtendWith(MockitoExtension.class) // 1
 klasa OsobaGeneratorPowinna {

     @Mock // 2
     RandomGenerator randomGenerator;

     @InjectMocks // 3
     prywatny generator PersonGenerator;

     @Test
     void wygeneruj prawidłową osobę () {
         when(losowyGenerator.generateRandomString()).thenReturn("Jan Kowalski");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         Osoba osoba = generator.generateRandom();
         attachThat(person).isEqualTo(nowa osoba("Jan Kowalski", 20));
     }
}

1. JUnit 5 może używać „rozszerzeń”, aby rozszerzyć swoje możliwości. Ta adnotacja pozwala rozpoznać atrapy za pomocą adnotacji i prawidłowo je wstawić.

2. Adnotacja @Mock tworzy wyśmiewaną instancję pola. Jest to to samo, co pisanie mock(RandomGenerator.class) w treści naszej metody testowej.

3. Adnotacja @InjectMocks utworzy nową instancję PersonGenerator i wstrzyknie mocki do instancji generator .

Więcej informacji na temat rozszerzeń JUnit 5 można znaleźć tutaj.

Więcej informacji na temat wstrzykiwania Mockito znajdziesz tutaj.

Korzystanie z @InjectMocks wiąże się z jedną pułapką. Może to wyeliminować potrzebę ręcznego deklarowania instancji obiektu, ale tracimy bezpieczeństwo konstruktora w czasie kompilacji. Jeśli w dowolnym momencie ktoś doda kolejną zależność do konstruktora, nie otrzymalibyśmy tutaj błędu kompilacji. Może to prowadzić do niepowodzenia testów, które nie są łatwe do wykrycia. Wolę używać @BeforeEach do ręcznej konfiguracji instancji:

 @ExtendWith(MockitoExtension.class)
klasa OsobaGeneratorPowinna {

     @Kpina
     RandomGenerator randomGenerator;

     prywatny generator PersonGenerator;

     @Bez tytułu
     nieważne ustawienia() {
         generator = new PersonGenerator(randomGenerator);
     }

     @Test
     void wygeneruj prawidłową osobę () {
         when(losowyGenerator.generateRandomString()).thenReturn("Jan Kowalski");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         Osoba osoba = generator.generateRandom();
         attachThat(person).isEqualTo(nowa osoba("Jan Kowalski", 20));
     }
}

Testowanie procesów wrażliwych na czas

Fragment kodu jest często zależny od znaczników czasu i zwykle używamy metod, takich jak System.currentTimeMillis() , aby uzyskać aktualny znacznik czasu epoki.

Chociaż wygląda to dobrze, trudno jest przetestować i udowodnić, czy nasz kod działa poprawnie, gdy klasa podejmuje decyzje wewnętrznie za nas. Przykładem takiej decyzji byłoby ustalenie, jaki jest obecny dzień.

 klasa IndexerPowinna {
     private Indexer Indexer = new Indexer();
     @Test
     void wygenerujNazwęIndeksuNa Jutro() {
         String indexName = indexer.tomorrow("mój-indeks");
         // ten test zadziałałby dzisiaj, ale co z jutro?
        AssertThat (nazwa_indeksu)
           .isEqualTo("mój-indeks.2022-02-02");
     }
}

Powinniśmy ponownie użyć Dependency Injection, aby móc „kontrolować” dzień podczas generowania nazwy indeksu.

Java posiada klasę Clock do obsługi takich przypadków użycia. Możemy przekazać instancję Clock do naszego Indexer , aby kontrolować czas. Domyślny konstruktor może używać Clock.systemUTC() dla zapewnienia kompatybilności wstecznej. Możemy teraz zastąpić wywołania System.currentTimeMillis() clock.millis() .

Wstrzykując Clock możemy wymusić przewidywalny czas w naszych klasach i pisać lepsze testy.

Testowanie metod tworzenia plików

  • Jak powinniśmy testować klasy, które zapisują swoje dane wyjściowe do plików?
  • Gdzie powinniśmy przechowywać te pliki, aby działały na dowolnym systemie operacyjnym?
  • Jak możemy się upewnić, że plik już nie istnieje?

Kiedy mamy do czynienia z plikami, pisanie testów może być trudne, jeśli sami spróbujemy rozwiązać te problemy, jak zobaczymy w poniższym przykładzie. Test, który następuje, jest starym testem wątpliwej jakości. Powinien sprawdzić, czy DogToCsvWriter serializuje i zapisuje psy do pliku CSV:

 class DogToCsvWriterShould {

     private DogToCsvWriter writer = new DogToCsvWriter("/tmp/dogs.csv");
     
     @Test
     void konwertuj na Csv() {
         writer.appendAsCsv(new Dog(Rasa.CORGI, Kolor.BRĄZOWY, "Monty"));
         writer.appendAsCsv(new Dog(Rasa.MALTAŃSKI, Kolor.BIAŁY, "Zoe"));

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

         attachThat(csv).isEqualTo("Monty,corgi,brązowy\nZoe,maltański,biały");
     }
}

Proces serializacji powinien być oddzielony od procesu pisania, ale skupmy się na naprawieniu testu.

Pierwszym problemem związanym z powyższym testem jest to, że nie będzie on działał w systemie Windows, ponieważ użytkownicy systemu Windows nie będą mogli rozwiązać ścieżki /tmp/dogs.csv . Inną kwestią jest to, że nie będzie działać, jeśli plik już istnieje, ponieważ nie zostanie usunięty po wykonaniu powyższego testu. Może działać poprawnie w potoku CI/CD, ale nie lokalnie, jeśli jest uruchamiany wiele razy.

JUnit 5 ma adnotację, której możesz użyć, aby uzyskać odniesienie do katalogu tymczasowego, który jest tworzony i usuwany przez framework. Chociaż mechanizm tworzenia i usuwania plików tymczasowych różni się w zależności od frameworka, pomysły pozostają takie same.

 class DogToCsvWriterShould {

     @Test
     void convertToCsv(@TempDir Ścieżka tempDir) {
         Ścieżka psyCsv = tempDir.resolve("psy.csv");
         DogToCsvWriter writer = new DogToCsvWriter(dogsCsv);
         writer.appendAsCsv(new Dog(Rasa.CORGI, Kolor.BRĄZOWY, "Monty"));
         writer.appendAsCsv(new Dog(Rasa.MALTAŃSKI, Kolor.BIAŁY, "Zoe"));

         Ciąg csv = Files.readString(dogsCsv);

         attachThat(csv).isEqualTo("Monty,corgi,brązowy\nZoe,maltański,biały");
     }
}

Dzięki tej małej zmianie jesteśmy teraz pewni, że powyższy test będzie działał w systemach Windows, macOS i Linux bez martwienia się o bezwzględne ścieżki. Usunie również utworzone pliki po teście, dzięki czemu możemy teraz uruchamiać go wiele razy i za każdym razem uzyskiwać przewidywalne wyniki.

Testowanie poleceń a zapytań

Jaka jest różnica między poleceniem a zapytaniem?

  • Polecenie : instruujemy obiekt, aby wykonał akcję, która daje efekt bez zwracania wartości (metody void)
  • Zapytanie : prosimy obiekt o wykonanie akcji i zwrócenie wyniku lub wyjątku

Do tej pory testowaliśmy głównie zapytania, w których wywołaliśmy metodę, która zwróciła wartość lub zgłosiła wyjątek w fazie działania. Jak możemy przetestować metody void i sprawdzić, czy poprawnie współdziałają z innymi klasami? Frameworki udostępniają inny zestaw metod do pisania tego rodzaju testów.

Asercje, które do tej pory napisaliśmy dla zapytań, zaczynały się od assertThat . Pisząc testy poleceń, używamy innego zestawu metod, ponieważ nie sprawdzamy już bezpośrednich wyników metod, jak to robiliśmy w przypadku zapytań. Chcemy „zweryfikować” interakcje, jakie nasza metoda miała z innymi częściami naszego systemu.

 @ExtendWith(MockitoExtension.class)
 class FeedMentionServicePowinna {

     @Kpina
     prywatne repozytorium FeedRepository;

     @Kpina
     prywatny nadajnik FeedMentionEventEmitter;

     prywatna usługa FeedMentionService;

     @Bez tytułu
     nieważne ustawienia() {
         service = new FeedMentionService(repozytorium, emiter);
     }

     @Test
     void insertMentionToFeed() {
         długi feedId = 1L;
         Wzmianka = ...;

         when(repository.upsertMention(feedId, wzmianka))
             .thenReturn(UpsertResult.success(feedId, wzmianka));

         Zdarzenie FeedInsertionEvent = nowe FeedInsertionEvent(feedId, wzmianka);
         wspomniećUsługę.insertMentionToFeed(zdarzenie);

         Zweryfikuj(emiter).mentionInsertedToFeed(feedId, wzmianka);
         zweryfikujBrakWięcejInterakcji(emiter);
     }
}

W tym teście najpierw wykpiliśmy nasze repozytorium, aby odpowiedzieć UpsertResult.success , gdy poprosiliśmy o dodanie wzmianki w naszym kanale. Nie zajmujemy się tutaj testowaniem repozytorium. Metody repozytorium należy przetestować w FeedRepositoryShould . Kpiąc z tego zachowania, tak naprawdę nie wywołaliśmy metody repozytorium. Po prostu powiedzieliśmy mu, jak ma odpowiedzieć następnym razem, gdy zostanie wywołany.

Następnie mentionService naszemu serwisowi wzmianki o wstawieniu tej wzmianki w naszym kanale. Wiemy, że powinien wyemitować wynik tylko wtedy, gdy pomyślnie umieści wzmiankę w kanale. Korzystając z metody verify , możemy upewnić się, że metoda mentionInsertedToFeed została wywołana z naszym wzmianką i kanałem i nie została wywołana ponownie przy użyciu verifyNoMoreInteractions .

Końcowe przemyślenia

Pisanie testów jakości pochodzi z doświadczenia, a najlepszym sposobem na naukę jest działanie. Wskazówki napisane na tym blogu pochodzą z praktyki. Trudno jest dostrzec niektóre z pułapek, jeśli nigdy się z nimi nie spotkałeś i miejmy nadzieję, że te sugestie powinny sprawić, że Twój projekt kodu będzie bardziej niezawodny. Posiadanie wiarygodnych testów zwiększy Twoją pewność co do zmiany rzeczy bez wysiłku za każdym razem, gdy będziesz musiał wdrożyć swój kod.

Chcesz dołączyć do zespołu Mediatoolkit?
Sprawdź naszą otwartą pozycję dla Senior Frontend Developer !