Testowanie na przykładzie
Opublikowany: 2022-02-15Kontynuujemy 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:
- 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. - To faza aranżacyjna naszego testu, w której przygotowujemy środowisko testowe. Wszystko, czego potrzebujemy do tego testu, to mieć instancję
Calculator
. - Jest to faza działania , w której uruchamiamy zachowanie, które chcemy przetestować.
- 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 !