Pisząc testy we frameworku JUnit 4 mamy kilka opcji na zapisanie warunku, że testowany kod powinien rzucić wyjątkiem. Wymieniając najprostsze, niewymagające dodatkowych bibliotek:
- dodanie atrybutu „expected” do adnotacji na metodzie testowej:
@Test(expected = IllegalStateException.class)
- otoczenie kodu blokiem try-catch
- użycie reguły ExpectedException
Pierwsza opcja wymaga najmniej pisania, ale jest mało elastyczna: nie jesteśmy w stanie zapewnić niczego o wyjątku, jedynie jego klasę. Gdyby przyszła potrzeba upewnienia się, jaki jest komunikat wyjątku, metodę testową trzeba całkowicie przepisać, zmieniając podejście na któreś z opisanych niżej.
Druga opcja jest najbardziej elastyczna: mamy dowolność operowania na złapanym wyjątku. Wprowadzamy jednak dodatkowy poziom zagnieżdżenia, wyłamując się z układu, jaki stosujemy w metodach testowych, gdzie nie występuje wyjątek, przez co powstaje wrażenie, że z jedną z metod jest coś nie tak:
@Test public void chargesForParking() { ElectricScooter scooter = rental.rentScooter(wallet); scooter.park(Duration.ofMinutes(5)); verify(wallet).charge(PARKING_FEE); } @Test public void preventsSpeeding() { ElectricScooter scooter = rental.rentScooter(wallet); try { scooter.setSpeed(70); } catch (IllegalStateException e) { assertEquals( "You are not allowed to drive faster than 20 km/h", e.getMessage() ); } }
Poza problemem z czytelnością takie „ręczne” podejście niesie ryzyko, że napiszemy test, który będzie zawsze przechodził. Tu po linijce 15 zabrakło wywołania fail()
— a jest konieczne, żeby test nie przeszedł, gdy wyjątek nie zostanie rzucony. Ze względu na szum wprowadzony przez try-catch można przeoczyć taki brak.
Trzecie podejście wydaje się najlepszą opcją: nie zaburza czytania testu, jest możliwe sprawdzanie szczegółów rzuconego wyjątku. Zobaczmy, jak wygląda kod w takiej opcji:
@Rule public ExpectedException exception = ExpectedException.none(); @Test public void preventsSpeeding() { exception.expect(IllegalStateException.class); exception.expectMessage(containsString("20 km/h")); ElectricScooter scooter = rental.rentScooter(wallet); scooter.setSpeed(70); verify(wallet).charge(SPEEDING_FINE); assertTrue(scooter.isBlocked()); }
Większość tutoriali i postów na blogach skupia się na objaśnianiu użycia reguły ExpectedException, nie wspominając o jej niebezpieczeństwach. Żadnych ostrzeżeń nie zawierają przykłady w dokumentacji ExpectedException. Autor kodu powyżej może być przekonany, że sprawdza, że naliczana jest kara i nakładana blokada — a nic takiego nie ma miejsca. Przez test przechodzi implementacja nie spełniająca zapisanych warunków.
Wywołanie scooter.setSpeed(70)
rzuca wyjątek, sterowanie opuszcza metodę testową i nigdy do niej nie wraca. Cały kod poniżej jest niewywoływany, ale nic o tym nie ostrzega, dla kompilatora wszystko jest w porządku. Nawet jeśli ktoś pamięta o skutkach korzystania z ExpectedException, może przegapić, że ta reguła jest wykorzystywana — przykładowo, gdy jest dużo linijek odstępu między ExpectedException a wywołaniem kodu poddawanego testowi w metodzie testowej lub całym teście (setup mógłby być dużo bardziej skomplikowany niż wywołanie rental.rentScooter(wallet)
a zabrakło by refaktoringu, który skróciłby test).
Na skutek użycia reguły ExpectedException mamy kod, który wyglądem sugeruje jedno, a przy wykonaniu robi zupełnie coś przeciwnego; kod „magiczny”. Jest w nim złamane słuszne i uzasadnione oczekiwanie programisty: że jeśli są linie kodu, to napisano je w określonym celu i będą wykonane. Dojrzały programista będzie dążył do testów będących czytelną specyfikacją, jak działa kod produkcyjny. Przeglądając powyższy kod może nabrać mylnego przekonania, że kod produkcyjny robi coś więcej, niż potrafi w rzeczywistości.
Stąd moja porada: nigdy nie używaj ExpectedException i usuwaj jakiekolwiek wystąpienie tej klasy ze swojego projektu, nawet jeśli jest poprawne i nie ma szans, żeby w tym konkretnym pliku coś poszło źle. Zrób dlatego, aby inny programista nie wzorował się na takich testach przy pisaniu nowych. ExpectedException zaprasza do popełniania subtelnych, trudnych do zauważenia błędów i koszt usunięcia choćby jednego złego użycia może być naprawdę duży.
Co w zamian? Potrzebujemy narzędzia, którego nie da się użyć źle tak łatwo, jak trzech opcji wymienionych poprzednio.
JUnit 4 to framework, który ma duże zasługi, ale jest już zabytkiem. Jeśli masz taką możliwość, przejdź na JUnit 5 i użyj metody assertThrows:
@Test public void preventsSpeeding() { ElectricScooter scooter = rental.rentScooter(wallet); var exception = assertThrows(IllegalStateException.class, () -> scooter.setSpeed(70) ); assertEquals( "You are not allowed to drive faster than 20 km/h", exception.getMessage() ); verify(wallet).charge(SPEEDING_FINE); assertTrue(scooter.isBlocked()); }
Jeśli musisz zostać przy JUnicie 4, składnię bardzo podobną do JUnita 5 oferuje biblioteka AssertJ:
@Test public void preventsSpeeding() { ElectricScooter scooter = rental.rentScooter(wallet); assertThatThrownBy(() -> scooter.setSpeed(70) ) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("20 km/h"); verify(wallet).charge(SPEEDING_FINE); assertThat(scooter.isBlocked()).isTrue(); }
Jak widać w tym przypadku mamy do dyspozycji zwięzły sposób wykonywania asercji na wyjątku za pomocą method chainingu, bez wprowadzania zmiennych lokalnych. Osobiście preferowałbym ostatnie podejście również w projekcie opartym o JUnit 5 — AssertJ znacząco ułatwia pisanie testów nie tylko związanych z wyjątkami, więc warto mieć tę bibliotekę w projekcie.