Problem nie jest nowy. Zastanawiano się nad dobrym rozwiązaniem już podczas prezentacji Bartosza Bańkowskiego i Szczepana Fabera pt. Pokochaj swoje testy [czas - 18.04] na Wroc JUG, albo pewnie jeszcze wcześniej. Idąc za radą lepszych, zawsze korzystałem z try / catch’a, aby wiedzieć gdzie dokładnie spodziewam się wyjątku.
@Test public void shouldThrowSomeException() throws Exception { // given SomeClass someClass = new SomeClass(); try { // when someClass.doSomething(); fail("This method should throw SomeException"); } catch(SomeException e) { // then assertThat(e.getMessage()).isEqualTo("Some message"); } }
Blok instrukcji try / catch wymusza na nas pewien sposób formatowania kodu i przez to nie do końca widać gdzie jest // when i // then. Można oczywiście próbować umieszczać je w trochę innym miejscu, np. // when przed try, a // then przed fail().
@Test public void shouldThrowSomeException() throws Exception { // given SomeClass someClass = new SomeClass(); // when try { someClass.doSomething(); // then fail("This method should throw SomeException"); } catch(SomeException e) { assertThat(e.getMessage()).isEqualTo("Some message"); } }
Jednak rozwiązanie dalej jest nie do końca czytelne. Gdyby jeszcze nic nie umieszczać w bloku catch, to już w ogóle, trzeba się chwilę zastanowić, co my tu tak na prawdę chcemy przetestować. Całe szczęście narzędzia do statycznej analizy kodu dbają o to, aby nie zostawiać pustych bloków catch.
Alternatywnym rozwiązaniem dla testowania rzucanych wyjątków, jest stosowanie andotacji @Test z parametrem expected w JUnit’cie :
@Test(expected = SomeException.class) public void shouldThrowSomeException() throws Exception { // given SomeClass someClass = new SomeClass(); // when someClass.doSomething(); // then fail("This method should throw SomeException"); }
Test wygląda już lepiej, ale ma swoje wady. Jak mi jakiś test nie przechodzi, to pierwsze co robię, to czytam sekcję // then testu. W tym przypadku widzę wywołanie fail() co sugeruje mi, że test zawsze powinien nie przechodzić. Dopiero po chwili zauważam, że test został zadeklarowany jako @Test(expected = SomeException.class), czyli spodziewam się wyjątku typu SomeException. Jest tutaj jednak pewne niebezpieczeństwo. Jeżeli faza // given testu, czyli przygotowania środowiska testowego, będzie trochę dłuższa, to może się zdarzyć, że tam gdzieś poleci wyjątek. Test będzie dalej przechodził, a tak naprawdę nie będzie testowane to co chcieliśmy. Wspominał o tym już Szczepan Faber w cytowanym fragmencie video. Dodatkowo nie można, jeśli byśmy chcieli sprawdzić np. treść komunikatu wyjątku. Z tych względów nie stosowałem tej konstrukcji.
Sprawa wygląda trochę lepiej w przypadku TestNG. Tutaj mamy analogiczna adnotację, mianowicie zamiast expected używamy expectedExceptions.
@Test(expectedExceptions = SomeException.class, expectedExceptionsMessageRegExp = "Some Message.*") public void shouldThrowSomeException() throws Exception { // given SomeClass someClass = new SomeClass(); // when someClass.doSomething(); // then fail("This method should throw SomeException"); }
Tu jeszcze dochodzi fajna zabawka w postaci expectedExceptionsMessageRegExp, czyli możemy za pomocą wyrażeń regularnych sprawdzić, czy wyjątek posiada spodziewaną wiadomość. Dalej jednak istnieje ryzyko wyrzucenia tego wyjątku w sekcji // given.
Podobną zabawkę daje nam JUnit, ale w trochę innym wydaniu. Mianowicie od wersji 4.7 można zastosować ExpectedException:
public class SomeClassTest { @Rule public ExpectedException thrown = ExpectedException.none(); @Test public void shouldThrowSomeException() throws Exception { // given thrown.expect(SomeException.class); thrown.expectMessage("Some Message"); SomeClass someClass = new SomeClass(); // when someClass.doSomething(); // then fail("This method should throw SomeException"); } }
Tutaj w klasie testowej musimy zdefiniować regułę (linie 3 i 4), która początkowo mówi, że nie spodziewamy się wyjątków. Natomiast już w naszych metodach testowych, redefiniujemy to zachowanie i mówimy, czego się spodziewamy w danym teście (linie 9 i 10). Możemy dzięki temu sprawdzić komunikat rzucanego wyjątku. Tutaj jednak podajemy fragment wiadomości, którą ma zawierać nasz wyjątek. Istnieje również przeciążona wersja tej metody, która jako argument przyjmuje Matcher’a Hamcrest’owego.
Do niedawna były to jedyne rozwiązania, jakie były dostępne w temacie testowania wyjątku. Jakiś czas temu jednak pojawiła się ciekawa biblioteka: catch-exception. Kod napisany za jej pomocą może wyglądać tak:
@Test public void shouldThrowSomeException() throws Exception { // given SomeClass someClass = new SomeClass(); // when caughtException(someClass).doSomething(); // then assertThat(caughtException()) .isInstanceOf(SomeException.class) .as("Some Message"); }
Czyli mamy metodę CatchException.catchException(), gdzie jako argument przekazujemy obiekt naszej klasy. Następnie wywołujemy metodę, którą chcemy przetestować. Na koniec w sekcji // then sprawdzamy czy otrzymaliśmy wyjątek, którego się spodziewaliśmy. Bezargumentowa wersja caughtException() zwraca wyjątek rzucony przez ostatnią klasę, którą przekazaliśmy do metody caughtException(). W naszym wypadku jest to ostatni wyjątek wygenerowany przez someClass.
I to rozwiązanie mi się podoba. W sekcji // when informuję, że będę łapał wyjątki, a w sekcji // then sprawdzam czy poleciał ten wyjątek, którego oczekiwałem. I do tego nie bruździ to przy formatowaniu kodu i użyciu // given // when // then. I mamy czytelny kod :)
Zafascynowany tą biblioteką postanowiłem zajrzeć do środka (kod jest dostępny na googlecode.com), aby zobaczyć jak zbudowano takie rozwiązanie.
public class CatchException { public static <T> T catchException(T obj) { return processException(obj, Exception.class, false); } }
Czyli mamy delegację pracy do metody processException(), która zwraca obiekt tego samego typu, jaki został przekazany w argumencie. Dzięki temu możemy używać biblioteki w sposób jaki pokazałem powyżej. Zobaczmy co kryje się za tą metodą:
private static <T, E extends Exception> T processException(T obj, Class<E> exceptionClazz, boolean assertException) { if (obj == null) { throw new IllegalArgumentException("obj must not be null"); } return new SubclassProxyFactory().<T> createProxy(obj.getClass(), new ExceptionProcessingInterceptor<E>(obj, exceptionClazz, assertException)); }
Po upewnieniu się, że argument nie jest null’em tworzymy (jak można się domyśleć po nazwach) proxy dla naszej klasy. Brnąc dalej w las, jeżeli klasa nie jest ani prymitywem, ani finalna, to proxy tworzone jest w ten sposób:
proxy = (T) ClassImposterizer.INSTANCE.imposterise( interceptor, targetClass);
czyli wykorzystywany jest ExceptionProcessingInterceptor z poprzedniegu listingu, wewnątrz którego znajduje się następująca metoda, gdzie już widać całą magię:
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { beforeInvocation(); try { Object retval = proxy.invoke(target, args); return afterInvocation(retval); } catch (Exception e) { return afterInvocationThrowsException(e, method); } }
Metoda beforeInvocation() czyści ostatnio złapany wyjątek, np. z wywołania poprzedniej metody. Następnie w bloku try wywoływana jest nasza rzeczywista metoda (linia 5), a następie zwracana jest (w naszym sposobie wykorzystania biblioteki) wartość wygenerowana przez oryginalną metodę. Jak coś pójdzie nie tak, to w bloku catch jest zapamiętywany rzucony wyjątek (zajmuje się tym metoda afterInvocationThrowsException()). Bardzo sprytny sposób na łapanie wyjątków, a jaki banalny zarazem.
Z ciekawostek jeszcze, to biblioteka korzysta z Mockito, a dokładniej cglib. Nie działa ona z klasami finalnymi (gdyż dla nich nie można teoretycznie tworzyć proxy), no chyba że skorzystamy w PowerMock’a i odpowiednich adnotacji w deklaracji klasy testowej:
@RunWith(PowerMockRunner.class) @PrepareForTest({ SomeClass.class }) public class SomeClassFinalPowerTest { // ... }
Wtedy zadziała :)
Na koniec wpisu, jeszcze informacja skąd się dowiedziałem o tej bibliotece. Mianowicie powstaje teraz ciekawa książka o testach: Practical Unit Testing with TestNG and Mockito. Pisana jest ona przez Tomka Kaczanowskiego i już niedługo ujrzy światło dzienne. Książka poszła już do recenzji do Szczepana Fabra, więc lipy nie będzie. Będzie można się z niej dowiedzieć m.in. o catch-exception, jak i o testach mutacyjnych, o których pisałem ostatnio. Na razie wyjdzie wersja angielska, ale będzie też robione tłumaczenie na nasz ojczysty język. Zachęcam więc do śledzenia informacji o książce jak i do jej zakupu.
Więcej informacji o tym jak powstawała książka w kolejnych wpisach.
Bardzo ciekawy artykuł, również nie przepadam za testowaniem wyjątków przy użyciu try-catch. Biblioteka catch-exception wygląda ciekawie, acz nieskromnie napiszę, że napisana przeze mnie reguła do JUnit umożliwia pisanie jeszcze czytelniejszego kodu :-). Idea zasadniczo taka samo, jednak nieco przyjemniejsza (?) składania. Niemniej dzięki za podpowiedź!
OdpowiedzUsuń@Tomasz
OdpowiedzUsuńNo fakt, w przypadku twojej biblioteki odpada nam modyfikowanie czegokolwiek w sekcji //when i mamy po prostu gołe wywołanie testowanej metody. Fakt - składnia trochę przyjemniejsza, choć osobiście trochę przez to nie widzę powiązania między testowaną metodą, a assercjami w sekcji //then.
Niestety twoje rozwiązanie działa tylko z JUnitem, a catch-exception zadziała również z TestNG.
Marcin,
OdpowiedzUsuńdzięki za ciekawy post. Ciesze się że poświęciłeś sporo miejsca problematyce testowania wyjątków - wydaje mi się że to jakiś zaniedbany fragment tematyki testów. Może po tym poście wzrośnie ogólna świadomość co można i jak należy to robić.
Sam też przesiadam sie teraz na catch-exception (używam TestNG, więc rozwiązanie podane przez Tomka N. dla mnie odpada).
A co do książki - cóż, staram się by była jak najlepsza (i oczywiście opisuje w niej sposoby testowania wyjątków :)
--
pozdrawiam
Tomek Kaczanowski
W nowszej wersji catch-exception dodał też możliwość pisania bardziej w BDD - patrz http://code.google.com/p/catch-exception/
OdpowiedzUsuń--
pozdrawiam
Tomek Kaczanowski
http://practicalunittesting.com
Wygląda ładnie. Builder rządzi.
OdpowiedzUsuń