Mockito – testowanie asynchronicznych wywołań
W jednym z poprzednich artykułów opisywałem podstawy korzystania z jednego z najpopularniejszych frameworków służących do mockowania – Mockito. Poruszyłem w nim najważniejsze i najpopularniejsze kwestie, jakie wiążą się z tym tematem. Dziś natomiast chciałbym wspomnieć o problemie, z którym spotkać można się dość często. Mowa o testowaniu asynchronicznych wywołań, i o tym jak Mockito może nam w tym pomóc. Zapraszam do dalszej lektury.
Test można zazwyczaj podzielić na trzy logiczne części:
- Stworzenie odpowiednich warunków, w jakich test będzie się odbywał.
- Wywołanie testowanej funkcjonalności w skonstruowanym wcześniej środowisku.
- Sprawdzenie, czy użycie testowanej funkcjonalności dało oczekiwane rezultaty.
Sekcje te wykonywane są sekwencyjnie – jedna po drugiej. Jak więc w ten model wpasowuje się testowanie metod, które uruchamiają dodatkowe wątki? Sytuację najlepiej zobrazować na przykładzie. Poniżej przedstawione są klasy, które będą brały udział w teście – pierwsza z nich to prosty silnik, którego celem jest przetwarzanie tekstu:
package eu.jabablog.mockito.concurrent.example.processing;
import java.util.ArrayList;
import java.util.List;
public class ProcessingEngine
{
private List listeners = new ArrayList();
public void addProcessingListener(ProcessingListener listener)
{
listeners.add(listener);
}
public void removeProcessingListener(ProcessingListener listener)
{
listeners.remove(listener);
}
public String process(String input)
{
for(ProcessingListener listener : listeners)
{
ProcessingNotificationTask task = new ProcessingNotificationTask(listener, input);
new Thread(task).start();
}
return input.toUpperCase();
}
}
Jak widać, daje ona możliwość rejestrowania listenerów, które powiadamiane są o procesie przetwarzania. Muszą one implementować interfejs ProcessingListener, który posiada tylko jedna metodę – onProcess.
package eu.jabablog.mockito.concurrent.example.processing;
public interface ProcessingListener
{
void onProcess(String input);
}
Ważną cechą implementacji klasy ProcessingEngine jest to, że metody onProcess listenerów uruchamiane są w nowym wątku. Wykorzystywana jest do tego celu klasa ProcessingNotificationTask, która implementuje interfejs Runnable i służy do utworzenia nowego wątku.
package eu.jabablog.mockito.concurrent.example.processing;
import java.util.logging.Logger;
public class ProcessingNotificationTask implements Runnable
{
private ProcessingListener listener;
private String input;
private Logger logger = Logger.getLogger(this.getClass().getName());
public ProcessingNotificationTask(ProcessingListener listener, String input)
{
this.listener = listener;
this.input = input;
}
public void run()
{
String message = String.format("Invoking listener: <%s>", listener.getClass().getName());
logger.info(message);
listener.onProcess(input);
}
}
Spróbujmy więc przetestować metodę process klasy ProcessingEngine pod kątem powiadamiania zarejestrowanych listenerów. Korzystając z Mockito, można to zrobić w bardzo prosty sposób – tworzymy mocka listenera, rejestrujemy go w obiekcie klasy ProcessingEngine, uruchamiamy metodę process dla testowych danych, a na koniec sprawdzamy, czy metoda onProcess listenera została wywołana. Bez większego namysłu można napisać kod podobny do poniższego.
@Test
public void testProcess()
{
// given
ProcessingEngine engine = new ProcessingEngine();
ProcessingListener listener = mock(ProcessingListener.class);
engine.addProcessingListener(listener);
// when
engine.process(TEST_INPUT);
// then
verify(listener, times(1)).onProcess(anyString());
}
Na pierwszy rzut oka wygląda dobrze, jednak już po którymś z kolei uruchomieniu testu okazuje się, że coś jest nie tak. Mockito informuje nas, że metoda onProcess nie została wywołana. Jak to możliwe? Wyjaśnienie jest bardzo proste – metoda ta wywoływana jest w osobnym wątku. Wątek ten najwyraźniej nie zdążył się zakończyć przed wywołaniem metody verify. Problem ten jest o tyle nieprzyjemny, że może nie ujawnić się od razu. Uruchamiając testy lokalnie na własnej maszynie wszystko może być w najlepszym porządku. O błędzie możemy się dowiedzieć jakiś czas później (najczęściej w chwili opuszczania miejsca pracy), gdy dostajemy maila z informacją o zepsutym buildzie. Aby test działał poprawnie, należy więc zsynchronizować wątki tak, aby weryfikacja ilości wywołań metody onProcess odbyła się dopiero po jej wywołaniu.
@Test
public void testProcess() throws InterruptedException
{
// given
ProcessingEngine engine = new ProcessingEngine();
ProcessingListener listener = mock(ProcessingListener.class);
final CountDownLatch latch = new CountDownLatch(1);
doAnswer(new Answer()
{
public Void answer(InvocationOnMock invocation) throws Throwable
{
latch.countDown();
return null;
}
}).when(listener).onProcess(anyString());
engine.addProcessingListener(listener);
// when
engine.process(TEST_INPUT);
latch.await();
// then
verify(listener, times(1)).onProcess(anyString());
}
Do wspomnianej synchronizacji posłużył obiekt klasy CountDownLatch. Tym sposobem mamy pewność, że sprawdzenie ilości wywołań metody onProcess będzie wykonane dopiero po zakończeniu wątku, w którym metoda ta jest używana. Pisząc testy jednostkowe należy zwracać baczną uwagę na kod, który wykonuje się asynchronicznie, a w przypadku testowania go skorzystać z opisanej powyżej techniki. Pozwoli to zaoszczędzić dużo czasu i zszarganych nerwów.

Ciekawy post, dzięki!
Wydaje się że możesz odpuścić times(1) bo jest to defaultowa wartość, więc samo
verify(listener).onProcess(anyString());
powinno wystarczyć.
Zastanawiałem się czy można by jakoś pominąć w testach te niskopoziomowe zabawy z CountDownLatch. Od pewnego czasu przymierzam się do biblioteki Awaitility (patrz http://code.google.com/p/awaitility/wiki/Usage) ale wydaje się, że w przypadku weryfikacji wywołań nie pomoże ona zbytnio.
Dzięki!
Co do times(1) to rzeczywiście, można sobie odpuścić. Z zasady wolę jednak, aby przykłady były nawet lekko nadmiarowe. Dzięki temu kod lepiej pokazuje możliwości narzędzia, i dodatkowo jest bardziej zrozumiały dla kogoś, kto ogląda go pierwszy raz. Takie jest przynajmniej moje zdanie :)
Co do awaitility, to wielkie dzięki za linka. Przyjrzę się temu toolowi pod kątem zastosowania w przykładach z wpisu i postaram się podzielić swoimi przemyśleniami.