ExpectedException i pułapki łapania wyjątków w testach

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:

  1. dodanie atrybutu „expected” do adnotacji na metodzie testowej: @Test(expected = IllegalStateException.class)
  2. otoczenie kodu blokiem try-catch
  3. 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.