Тестирование асинхронных вызовов при помощи Mockito

в 13:38, , рубрики: java, mockito, tdd, метки: , ,

Тестирование кода, содержащего асинхронные вызовы, представляет собой определенную проблему. 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js