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.