Mockito и Behavior Driven Development

от
Java    test, bdd

Когда разработчик только начинает изучать принципы тестирования, он, как правило, знакомится в первую очередь с тестированием, основывающемся на проверке данных. То есть, чтобы протестировать функцию сложения чисел разработчик пишет, например, такой тест: “Заданы числа 2 и 3, в качестве результата ожидается 5”, и по тому, выполнилось ли это утверждение или нет, он делает вывод о верной или неверной реализации тестируемой функции.
Такой подход хорошо работает, когда код достаточно прост, результат предсказуем, и его проверка не займёт относительно много времени. Но на практике часто функции сложные, тяжело покрыть тестами каждый возможный исход, либо подготовка исходных или проверка конечных данных для теста занимает много времени. Можно, конечно, подумать: “Это сложно тестировать автоматически, поэтому пусть тестировщик придумает тесты и проверяет их вручную”. Но это не решение проблемы, потому что проект может собираться несколько раз в день, а ручное тестирование – самое затратное по времени, в чём наверняка каждый из вас убеждался, перезапуская в n-цатый раз эмулятор с мидлетом или обновляя страницу в браузере после очередной правки в коде сервера. После отладки в несколько часов (а так бывает, когда ещё и баг воспроизводится не всегда, а при некоторых условиях) возникает вопрос: “А можно ли сделать так, чтобы потратить время на написание тестов нетривиальной функции, которые будут выполняться автоматически и занимать меньше времени?”. Часто ответ здесь: “Да, можно!”, если тестировать не только данные, но и поведение тестируемого объекта. И далее мы переходим к принципу тестирования, который фокусируется на том, как программа функционирует, а не что она производит в конечном итоге. Этот принцип называется Behavior Driven Development.
Предположим, что мы написали некоторый модуль, подумали над тем, как он должен вести себя в той или иной ситуации. Как же нам отследить при тестировании как модуль ведёт себя в реальности и соответствует ли это нашим ожиданиям? Первое, что приходит на ум - логирование:
  1. public void testingFunction() {
  2.     if(condition) {
  3.         frame.repaint();
  4.         log.debug("frame repaint");
  5.         if(blablabla) {
  6.             frame.getContentPane().removeAll();
  7.             log.debug("Remove widgets. Logs! Need more LO-O-O-OGS!");
  8.         }
  9.     } else {
  10.         log.debug("condition false. Nothing to do");
  11.     }
Минусы тут очевидны: во-первых, "тестируя" таким образом код, мы вторгаемся в код, вставляя вызовы логгера, а хорошие тесты не должны модифицировать тот код, который они проверяют. Во-вторых, такие логи тяжело читать, да и разобрать их автоматически не так просто, а в третьих, к счастью, нет разработчиков, которые пишут "тесты" подобным образом.
Хорошо, логирование на каждый чих нам не подходит, но как нам иначе выяснить, как ведёт себя тестируемый код? В этом нам поможет библиотека Mockito, но сначала нужно понять, что такое mock-объекты, и для чего они нужны в тестировании.

Рассмотрим пример с таким известным интерфейсом как List. У него есть методы, которые выполняют действия, как это описано в документации. Т. е. метод add добавляет объект в список и возвращает true, если список изменился, или false, если реализация списка не допускает хранения дубликатов, а такой объект в списке уже есть; есть метод size, который возвращает размер списка; есть метод get(int index), который возвращает объект по данному индексу или бросает исключение, если значение индекса некорректно. Mock-объект - это объект, который имеет тот же интерфейс, что и реальный объект, но реализация его - "тупая", то есть mock-объект при вызове методов add, size, get и т. д. вернёт значения по умолчанию, а конкретнее:
false для add
0 для size
null для get
Отмечу ещё, что в случае, если метод реального объекта возвращает какую-либо коллекцию, то метод mock-объекта вернёт пустую коллекцию.

Зачем нужны mock-объекты, если они такие "тупые"? Дело в том, что mock-объект можно "спросить": "Ответь, был ли вызван такой-то метод да ещё и с такими-то параметрами?". Наш первый пример:
  1. import static org.mockito.Mockito.*;
  2.  
  3. List mockedList = mock(List.class); //создали mock-объект для класса List
  4.  
  5. mockedList.add("one"); //вызвали у него метод add с параметром "one"
  6. mockedList.clear(); //вызвали у него метод clear
  7.  
  8. verify(mockedList).add("one"); //проверили, что метод add был вызван, да ещё и с уточнением, что параметром был именно "add"
  9. verify(mockedList).clear(); //проверили, что метод clear был вызван
В дальнейшем строки импорта я буду опускать для краткости.

Но mock-объекты полезны не только этим. Пожалуй, самая важная возможность - mock можно научить специфичному поведению. Как уже выше отмечалось, созданный mock при вызове методов возвращает значения по умолчанию, а теперь давайте научим его дпугим действиям:
  1. List mockedList = mock(ArrayList.class); //можно сказать Mockito создать mock конкретного класса, не только интерфейса
  2.  
  3. when(mockedList.get(0)).thenReturn("first"); //здесь мы задаём mock'у поведение: "Если будет вызван метод get с параметром 0, то верни значение "first" (а не null, как по умолчанию)"
  4. when(mockedList.get(1)).thenThrow(new RuntimeException()); //здесь мы задаём mock'у поведение: "Если будет вызван метод get с параметром 1, то брось исключение"
  5.  
  6. System.out.println(mockedList.get(0)); //посмотрим, что выведет в консоль, если вызвать у mock метод get(0)
  7. System.out.println(mockedList.get(1)); //а эта строка должна бросить исключение
  8. System.out.println(mockedList.get(999)); //здесь будет выведено null, потому что мы не задали поведение для get(999) и будет выполнено действие по умолчанию
  9.  
  10. verify(mockedList).get(0); //можно так же проверить, что был вызов метода, для которого мы определили другое поведение.
Когда задаётся поведение для mock, можно указывать не только определённые параметры, но и использовать так называемые Argument matchers:
  1. private ArgumentMatcher isValid() {
  2.         return new ArgumentMatcher() {
  3.  
  4.             @Override
  5.             public boolean matches(Object element) {
  6.                 //Implementation
  7.             }
  8.         };
  9. }
  10.  
  11. ...
  12.  
  13. when(mockedList.get(anyInt())).thenReturn("element"); //задаём поведение: "если будет вызван метод get с любым параметром типа int, верни "element" "
  14. when(mockedList.contains(argThat(isValid()))).thenReturn(true); //задаём поведение: "если будет вызван метод contains c параметром, который допустим для нашего собственного ArgumentMatcher, то вернуть true"
  15.  
  16. System.out.println(mockedList.get(999)); //будет выведено "element"
  17.  
  18. verify(mockedList).get(anyInt()); //ArgumentMatcher можно использовать так же при проверке вызова методов

Замечание: если использовать ArgumentMatcher, то все параметры должны предоставляться с их помощью:
  1. verify(mockedList).set(anyInt(), "replaced"); //неправильно
  2. verify(mockedList).set(anyInt(), eq("replaced")); //правильно
Можно так же проверить сколько раз вызывался метод:
  1. mockedList.add("once");
  2.  
  3. mockedList.add("twice");
  4. mockedList.add("twice");
  5.  
  6. mockedList.add("three times");
  7. mockedList.add("three times");
  8. mockedList.add("three times");
  9.  
  10. verify(mockedList).add("once"); //проверяем, что метод add с параметром "once" вызывался один раз
  11. verify(mockedList, times(1)).add("once"); //то же самое, но в другой записи
  12.  
  13. verify(mockedList, times(2)).add("twice"); //проверяем, что метод add с параметром "twice" вызывался 2 раза
  14. verify(mockedList, times(3)).add("three times"); //проверяем, что метод add с параметром "three times" вызывался 3 раза
  15.  
  16. verify(mockedList, never()).add("never happened"); //проверяем, что метод add с параметром "never happened" не вызывался никогда, можно использовать вместо never() times(0)
  17.  
  18. verify(mockedList, atLeastOnce()).add("three times"); //проверяем, что метод add с параметром "three times" вызывался не менее одного раза
  19. verify(mockedList, atLeast(2)).add("five times");  //проверяем, что метод add с параметром "five times" вызывался не менее 2 раз
  20. verify(mockedList, atMost(5)).add("three times"); //проверяем, что метод add с параметром "three times" вызывался не более 5 раз
Как было показано выше, в первых примерах, можно заставить mock бросить исключение:
  1. doThrow(new RuntimeException()).when(mockedList).clear(); //эта запись используется для void методов
  2. mockedList.clear();
Можно проверять порядок вызова методов:
  1. List mockedList = mock(List.class);
  2.  
  3. mockedList.add("first");
  4. mockedList.add("second");
  5.  
  6. InOrder inOrder = inOrder(mockedList);
  7.  
  8. inOrder.verify(mockedList).add("first");
  9. inOrder.verify(mockedList).add("second");
Можно проверить, что никакие методы mock'а не вызывались:
  1. verifyZeroInteractions(mockedList);
Можно проверить, что после некоторых действий никакие методы mock'а больше не вызывались:
  1. verify(mockedList).add("one"); //проверили, что вызывался add("one")
  2. verifyNoMoreInteractions(mockedList); //и больше ничего
Для mock можно задать поведение, чтобы при вызове одного и того же метода поведение было различным, в зависимости от того, в который раз вызывается метод:
  1. when(mockedList.get(0)) //поведение: когда
  2.     .thenThrow(new RuntimeException()) //в первый раз брось исключение
  3.     .thenReturn("foo"); // во второй и последующие разы верни объект "foo"
  4.  
  5. mockedList.get(0); //будет брошено исключение
  6.  
  7. System.out.println(mockedList.get(0)); //в консоль будет выведено "foo"
  8. System.out.println(mockedList.get(0)); //в консоль снова будет выведено "foo"
Возможно, при тестировании понадобится, чтобы mock вёл себя так, как ведёт себя оригинальный объект, чтобы не писать сложную логику обучения для mock, в Mockito есть следующий способ мокирования:
  1. List list = new LinkedList(); // создаём реальный объект
  2. List spy = spy(list); //создаём его mock, но который будет вести себя по умолчанию в точности так, как ведёт себя реальный объект
  3.  
  4. when(spy.size()).thenReturn(100); //но такой mock при желании тоже можно переобучить другому поведению
  5. //давайте немного поэкспериментируем
  6. spy.add("one");
  7. spy.add("two");
  8.  
  9. System.out.println(spy.get(0)); //в консоль будет выведено "one"
  10.  
  11. System.out.println(spy.size()); //в консоль будет выведено 100, а не 2, так как мы переобучили mock.size()
  12.  
  13. verify(spy).add("one"); //проверять вызовы методов таких mock'ов так же легко, как и обычных
  14. verify(spy).add("two");
Но переопределение поведения некоторых методов придётся записывать иначе:
  1. List list = new LinkedList();
  2. List spy = spy(list);
  3.  
  4. when(spy.get(0)).thenReturn("foo"); //так нельзя: будет вызван реальный метод get(0), поэтому здесь будет брошено исключение IndexOutOfBoundsException, так как список пустой
  5.  
  6. doReturn("foo").when(spy).get(0); //писать надо так

На этом закончим разбор возможностей Mockito, поскольку остальные возможности были введены позже, и могут отсутствовать, если вы используете старую версию библиотеки, из новых возможностей стоит упомянуть проверку с таймаутом:
  1. verify(mock, timeout(100).times(1)).someMethod();
что на практике помогает тестировать асинхронный код.

Как вы могли видеть, в примерах они создаются с помощью методов mock() и spy(). Mockito позволяет создавать их так же с помощью аннотаций:
  1. @Mock
  2. private List mockList;
  3. @Spy
  4. private List spyList;
При тестировании mock'и можно внедрять в тестируемый объект:
  1. public class MyCoolObject {
  2.  
  3.     private List list;
  4.     private Map map;
  5. }
  6. ...
  7. public class MyCoolObjectTest {
  8.  
  9.     @Mock
  10.     private List mockList;
  11.  
  12.     @Mock
  13.     private Map mockMap;
  14.  
  15.     @InjectMocks //указываем, куда внедрять mock'и
  16.     private MyCoolObject object;
  17.  
  18.     @Before
  19.     public void setUp() {
  20.  
  21.     }
  22. }
Есть несколько способов начать процесс внедрения:
1. В методе setUp вызвать:
  1. MockitoAnnotations.initMocks(this);
2. Указать над объявлением тестового класса аннотацию @MockitoJUnitRunner
3. Добавить в тестовый класс поле (требуется версия JUnit не ниже 4.9):
  1. @Rule
  2. public MockitoJUnitRule mockitoJUnitRule = new MockitoJUnitRule(this);
Внедрение mock'ов происходит через конструктор, сеттер или внедрение непостредственно в поле. Внедрение происходит по типу реального объекта и mock'а, если есть несколько mock'ов одного и того же типа или тестируемый объект имеет несколько полей одного типа, то внедрение происходит по имени mock'а и поля реального объекта.

Замечания:
1. Mockito не умеет работать с private/final/static методами, конструкторами и final классами. Если без этого при написании тестов совсем никак не обойтись, то можно использовать библиотеку PowerMock, которая является надстройкой над Mockito.

И в заключение пример:
Предположим, что мы пишем сервис, который принимает некие сообщения. Сообщения состоят из двух полей: prefix и body. Body - это непосредственно само сообщение, а значение prefix говорит, что сервис должен сделать с сообщением: если префикс - "mail", то сообщение нужно отправить по электронной почте, если префикс - "database", то сообщение нужно сохранить в базу данных, если сообщение имеет другой префикс, то его нужно записать в лог.
Message:
  1. package org.mockitoexample;
  2.  
  3. /**
  4.  *
  5.  * @author Igor
  6.  */
  7. public class Message {
  8.  
  9.     private final String prefix;
  10.     private final String body;
  11.  
  12.     public Message(String prefix, String body) {
  13.         this.prefix = prefix;
  14.         this.body = body;
  15.     }
  16.  
  17.     public String getPrefix() {
  18.         return prefix;
  19.     }
  20.  
  21.     public String getBody() {
  22.         return body;
  23.     }
  24.  
  25.     @Override
  26.     public String toString() {
  27.         return "Message{" + "prefix=" + prefix + ", body=" + body + '}';
  28.     }
  29.  
  30. }
MessageHandler:
  1. package org.mockitoexample;
  2.  
  3. /**
  4.  *
  5.  * @author Igor
  6.  */
  7. public interface MessageHandler {
  8.  
  9.     void handle(Message msg);
  10.  
  11. }
MailSender:
  1. package org.mockitoexample;
  2.  
  3. /**
  4.  *
  5.  * @author Igor
  6.  */
  7. public class MailSender implements MessageHandler {
  8.  
  9.     @Override
  10.     public void handle(Message msg) {
  11.         //Implementation
  12.     }
  13.  
  14. }
MessageDAO:
  1. package org.mockitoexample;
  2.  
  3. /**
  4.  *
  5.  * @author Igor
  6.  */
  7. public class MessageDAO implements MessageHandler {
  8.  
  9.     @Override
  10.     public void handle(Message msg) {
  11.         //Implementation
  12.     }
  13.  
  14. }
MessageLogger:
  1. package org.mockitoexample;
  2.  
  3. /**
  4.  *
  5.  * @author Igor
  6.  */
  7. public class MessageLogger implements MessageHandler {
  8.  
  9.     @Override
  10.     public void handle(Message msg) {
  11.         //Implementation
  12.     }
  13.  
  14. }
MessageService:
  1. package org.mockitoexample;
  2.  
  3. import java.util.HashMap;
  4. import java.util.Map;
  5.  
  6. /**
  7.  *
  8.  * @author Igor
  9.  */
  10. public class MessageService {
  11.  
  12.     private MessageLogger logger;
  13.  
  14.     private Map<String, MessageHandler> handlers;
  15.  
  16.     public MessageService() {
  17.         handlers = new HashMap<>(2);
  18.         handlers.put("mail", new MailSender());
  19.         handlers.put("database", new MessageDAO());
  20.         logger = new MessageLogger();
  21.     }
  22.  
  23.     public void handle(Message msg) {
  24.         if (!isMessageValid(msg)) {
  25.             throw new IllegalArgumentException("bad message: " + msg.toString());
  26.         }
  27.         MessageHandler handler = handlers.get(msg.getPrefix());
  28.         if (handler == null) {
  29.             logger.handle(msg);
  30.         } else {
  31.             handler.handle(msg);
  32.         }
  33.     }
  34.  
  35.     private boolean isMessageValid(Message msg) {
  36.         if (msg == null) {
  37.             return false;
  38.         }
  39.         return !("".equals(msg.getPrefix().trim())
  40.             || "".equals(msg.getBody().trim()));
  41.     }
  42. }
Нам нужно потестировать MessageService. Думаем над возможными сценариями:
1. У сообщения нет префикса или он пустой. В этом случае должно быть брошено исключение;
2. У сообщения нет тела или оно пустое. В этом случае должно быть брошено исключение;
3. Сообщение валидное и имеет префикс "mail". В этом случае его должен обработать MailSender;
4. Сообщение валидное и имеет префикс "database". В этом случае его должен обработать MessageDAO;
5. Сообщение валидное и имеет префикс, отличный от "mail" и "database". В этом случае его должен обработать MessageLogger;
Если бы мы руководствовались принципами Data Driven Development, то планы последних тестов были бы примерно такими:
1. Перед тестом зафиксировать данные (убедиться, что база данных (почтовый ящик или файл логов тоже) пуста, если нет, то очистить её либо запомнить состояние до теста);
2. Подготовить сообщение для каждого теста;
3. Прогнать тест;
4. Сравнить состояние данных в БД (почтовом ящике, файле с логами) с исходным;
5. Подчистить за собой ресурсы, откатить изменения в БД (почтовом ящике, файле с логами);
Очевидно, что пункты 1, 2, 4, 5 будут занимать больше времени, чем сам тест. А если у нас не пять тестов для одной-то функции, а мы имеем дело с тестированием частей большой системы? Количество тестов может быть несколько десятков, а то и сотен, в зависимости от фантазии при разработке сценариев тестов.
Но раз уж мы решили убедиться в преимуществах Behavior Driven Development, самое время начать писать тесты в этом стиле:
  1. package org.mockitoexample;
  2.  
  3. import java.util.Map;
  4. import org.junit.Before;
  5. import org.junit.Test;
  6. import org.junit.runner.RunWith;
  7. import org.mockito.InjectMocks;
  8. import org.mockito.Matchers;
  9. import org.mockito.Mock;
  10. import static org.mockito.Mockito.never;
  11. import static org.mockito.Mockito.verify;
  12. import static org.mockito.Mockito.verifyZeroInteractions;
  13. import static org.mockito.Mockito.when;
  14. import org.mockito.runners.MockitoJUnitRunner;
  15.  
  16. /**
  17.  *
  18.  * @author Igor
  19.  */
  20. @RunWith(MockitoJUnitRunner.class) //необходимо для внедрения mock'ов
  21. public class MessageServiceTest {
  22.  
  23.     //создаём mock'и наших классов, обрабатывающих сообщения
  24.     @Mock
  25.     private MailSender mailSender;
  26.  
  27.     @Mock
  28.     private MessageDAO messageDAO;
  29.  
  30.     @Mock
  31.     private MessageLogger logger;
  32.  
  33.     //также создаём mock для Map handlers (см. поле в MessageService)
  34.     @Mock
  35.     private Map<String, MessageHandler> handlers;
  36.  
  37.     //сюда будем внедрять наши mock'и
  38.     @InjectMocks
  39.     private MessageService service;
  40.  
  41.     public MessageServiceTest() {
  42.     }
  43.  
  44.     @Before
  45.     public void setUp() {
  46.         //обучаем наш mock некоторым действиям, в конструкторе MessageService реальный объект Map заполняется реальными объектами, а мы научим его "отвечать" mock'ами обработчиками сообщений
  47.         when(handlers.get("mail")).thenReturn(mailSender);
  48.         when(handlers.get("database")).thenReturn(messageDAO);
  49.     }
  50.  
  51.     //первый сценарий. Здесь не нужны даже проверки из Mockito, JUnit справится
  52.     @Test(expected = IllegalArgumentException.class)
  53.     public void messageWithInvalidPrefixShouldRaiseException() {
  54.         Message msg = new Message(" ", "body");
  55.         service.handle(msg);
  56.     }
  57.  
  58.     //второй сценарий. Как и в первом тесте - проверки Mockito не нужны
  59.     @Test(expected = IllegalArgumentException.class)
  60.     public void messageWithInvalidBodyShouldRaiseException() {
  61.         Message msg = new Message("mail", "");
  62.         service.handle(msg);
  63.     }
  64.  
  65.     //третий сценарий. Уже интереснее
  66.     @Test
  67.     public void messageWithMailPrefixShouldBeSentBymail() {
  68.         Message msg = new Message("mail", "body");
  69.         service.handle(msg);
  70.  
  71.         verify(mailSender).handle(msg); //проверяем, что сообщение обработал mailSender
  72.         //а messageDAO и logger ничего не обработали
  73.         verify(messageDAO, never()).handle(Matchers.any(Message.class));
  74.         verify(logger, never()).handle(Matchers.any(Message.class));
  75.     }
  76.  
  77.     //четвёртый сценарий
  78.     @Test
  79.     public void messageWithDatabasePrefixShouldBeSavedToDatabase() {
  80.         Message msg = new Message("database", "body");
  81.         service.handle(msg);
  82.  
  83.         verify(messageDAO).handle(msg); //проверяем, что сообщение обработал messageDAO
  84.         verifyZeroInteractions(mailSender, logger); //более краткая запись, в предыдущем тесте можно было сделать так же
  85.     }
  86.  
  87.     //пятый сценарий
  88.     @Test
  89.     public void messageWithValidPrefixShouldBeLoggedIfThereIsNotSpecifiedHandlers() {
  90.         Message msg = new Message("title", "body");
  91.         service.handle(msg);
  92.  
  93.         verify(messageDAO).handle(msg);
  94.         verifyZeroInteractions(mailSender, logger);
  95.     }
  96.  
  97. }
Как видите, система покрыта тестами, и, чтобы убедиться в её полной работоспособности, достаточно будет написать только по одному тесту для каждого обработчика, чтобы убедиться, что сообщение отсылается по email, сохраняется в базу и пишется в лог. Но это уже совсем другая история.
MockitoExample.zip
  • +8
  • views 9017