Тестирование кода, содержащего асинхронные вызовы, представляет собой определенную проблему. Callback-методы как правило получают управление в потоке, отличном от основного потока, в котором работает код теста. И чтобы проверить, был ли вызван такой метод с нужными параметрами, приходится прилагать некоторые усилия. При этом код теста получается громоздким и трудным для понимания. В статье предлагается решение данной проблемы с помощью библиотеки для тестирования Mockito и небольшого расширения к ней.
Для начала посмотрим, как вообще можно проверить факт вызова callback-метода с помощью Mockito. Допустим у нас есть некий listener-интерфейс с методом onMessage. Этот метод вызывается, когда к нам по сети приходит сообщение. Получение сообщения мы сымитируем созданием отдельного потока и вызовом callback-метода в нем.
public final class Message {
private final int id;
public Message(int id) {
this.id = id;
}
public int getId() {
return id;
}
}
public interface MessageListener {
void onMessage(Message message);
}
public class Test {
private MessageListener listener;
@Before
public void setUp() throws Exception {
listener = mock(MessageListener.class);
}
@Test
public void test() throws Exception {
sendMessage(new Message(1));
ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
verify(listener).onMessage(messageCaptor.capture());
assertEquals(1, messageCaptor.getValue().getId());
}
private void sendMessage(final Message message) {
new Thread() {
@Override
public void run() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
listener.onMessage(new Message(message.getId()));
}
}.start();
}
}
Этот тест почти наверняка не пройдет, верификация вызова метода onMessage отработает быстрее, чем метод будет вызван. Можно, конечно, поставить задержку перед проверкой или воспользоваться режимом верификации timeout. Но, во-первых, непонятно, какое минимальное время задержки выбрать, чтобы тест гарантированно проходил. Во-вторых, если подобных тестов много, то это может сильно увеличить время, которое будет затрачено на исполнение всех тестов: даже если асинхронный вызов отработал быстро, задержка все равно будет присутствовать.
Попробуем исправить ситуацию. Сделаем заглушку для метода onMessage, в которой будем нотифицировать поток теста, а в потоке теста будем, соответственно, ожидать нотификацию и выполнять верификацию вызова в цикле до тех пор, пока верификация не пройдет, или когда закончится время ожидания завершения теста.
@Before
public void setUp() throws Exception {
listener = mock(MessageListener.class);
doAnswer(new Answer<Void>() {
@Override
public Void answer(InvocationOnMock invocation) throws Throwable {
synchronized (listener) {
listener.notifyAll();
}
return null;
}
}).when(listener).onMessage(any(Message.class));
}
@Test(timeout = 10000)
public void test() throws Exception {
sendMessage(new Message(1));
ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
while (true) {
synchronized (listener) {
try {
verify(listener).onMessage(messageCaptor.capture());
break;
} catch (TooLittleActualInvocations | WantedButNotInvoked e) {
}
try {
listener.wait();
} catch (InterruptedException e) {
throw new MockitoAssertionError("interrupted");
}
}
}
assertEquals(1, messageCaptor.getValue().getId());
}
Данное решение работает, но выглядит не очень красиво. А если таких асинхронных вызовов несколько, то также возникнет много дублирующегося кода.
Mockito предоставляет богатые возможности для расширения, поэтому служебный код, не относящийся непосредственно тесту, мы можем спрятать «под капот» и сделать его повторно используемым. Для этого мы повесим на наш mock-объект специальный слушатель, который будет нотифицироваться каждый раз, когда будет вызываться какой-либо метод данного mock-объекта. В этот слушатель мы спрячем код нашей заглушки.
@Before
public void setUp() throws Exception {
listener = mock(MessageListener.class, async());
}
private static MockSettings async() {
return withSettings().defaultAnswer(RETURNS_DEFAULTS).invocationListeners(
new InvocationListener() {
@Override
public void reportInvocation(MethodInvocationReport methodInvocationReport) {
DescribedInvocation invocation = methodInvocationReport.getInvocation();
if (invocation instanceof InvocationOnMock) {
Object mock = ((InvocationOnMock) invocation).getMock();
synchronized (mock) {
mock.notifyAll();
}
}
}
});
}
Также мы напишем свою реализацию интерфейса VerificationMode, поместив туда логику верификации асинхронного вызова.
public final class Await implements VerificationMode {
private final VerificationMode delegate;
public Await() {
this(Mockito.times(1));
}
public Await(VerificationMode delegate) {
this.delegate = delegate;
}
@Override
public void verify(VerificationData data) {
Object mock = data.getWanted().getInvocation().getMock();
while (true) {
synchronized (mock) {
try {
delegate.verify(data);
break;
} catch (TooLittleActualInvocations | WantedButNotInvoked e) {
}
try {
mock.wait();
} catch (InterruptedException e) {
throw new MockitoAssertionError("interrupted");
}
}
}
}
}
И модифицируем наш первоначальный тест.
@Test(timeout = 10000)
public void test() throws Exception {
sendMessage(new Message(1));
ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
verify(listener, await()).onMessage(messageCaptor.capture());
assertEquals(1, messageCaptor.getValue().getId());
}
private static VerificationMode await() {
return new Await();
}
Теперь мы можем легко тестировать асинхронные вызовы, сохраняя читабельность кода тестов. Тесты при этом будут выглядеть так, как будто асинхронные вызовы в тестируемом коде отсутствуют.
Автор: yngui