JavaRush/Java блог/Java-проекты/Реализуем Command Pattern для работы с ботом. (Часть 2) -...
Roman Beekeeper
35 уровень

Реализуем Command Pattern для работы с ботом. (Часть 2) - "Java-проект от А до Я"

Статья из группы Java-проекты
участников

Пишем тесты к приложению

Начало статьи: пишем JRTB-3. Теперь нужно подумать о тестировании. Весь добавленный код должен быть покрыт тестами, чтобы мы были уверены, что функциональность работает как мы ожидаем. Первыми напишем unit-тесты для сервиса SendBotMessageService.
Unit-тест — это тест, который проверяет логику какой-то маленькой части приложения: обычно это методы. А все связи, у которых есть этот метод, заменяются на ненастоящие при помощи моков.
Сейчас вы все увидите. В том же пакете, только уже в папке ./src/test/java создаем класс с таким же именем, как у класса, который будем тестировать, и добавляем в конце Test. То есть для SendBotMessageService у нас будет SendBotMessageServiceTest, в котором будут все тесты на этот класс. Идея в его тестировании следующая: мы подсовываем моковый (фейковый) JavaRushTelegarmBot, у которого потом спросим, вызывался ли метод execute с таким аргументом или нет. Вот что получилось:
package com.github.javarushcommunity.jrtb.service;

import com.github.javarushcommunity.jrtb.bot.JavarushTelegramBot;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;

@DisplayName("Unit-level testing for SendBotMessageService")
public class SendBotMessageServiceTest {

   private SendBotMessageService sendBotMessageService;
   private JavarushTelegramBot javarushBot;

   @BeforeEach
   public void init() {
       javarushBot = Mockito.mock(JavarushTelegramBot.class);
       sendBotMessageService = new SendBotMessageServiceImpl(javarushBot);
   }

   @Test
   public void shouldProperlySendMessage() throws TelegramApiException {
       //given
       String chatId = "test_chat_id";
       String message = "test_message";

       SendMessage sendMessage = new SendMessage();
       sendMessage.setText(message);
       sendMessage.setChatId(chatId);
       sendMessage.enableHtml(true);

       //when
       sendBotMessageService.sendMessage(chatId, message);

       //then
       Mockito.verify(javarushBot).execute(sendMessage);
   }
}
При помощи Mockito я создал моковый объект JavaRushBot, который передал в конструктор нашему сервису. Далее написал один тест (каждый метод с аннотацией Test — это отдельный тест). Структура этого метода одна и та же всегда — он не принимает аргументы, и возвращает void. Имя теста должно рассказать о том, что мы тестируем. В нашем случае это: should properly send message — должен правильно отправить сообщение. Тест у нас поделен на три части:
  • блок //given — где мы подготавливаем все необходимое к тесту;
  • блок //when — где запускаем тот метод, который планируем тестировать;
  • блок //then — где мы проверяем, правильно ли отработал метод.
Так как пока что логика в нашем сервисе простая, одного теста для этого класса будет достаточно. Теперь напишем тест на CommandContainer:
package com.github.javarushcommunity.jrtb.command;

import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.util.Arrays;

@DisplayName("Unit-level testing for CommandContainer")
class CommandContainerTest {

   private CommandContainer commandContainer;

   @BeforeEach
   public void init() {
       SendBotMessageService sendBotMessageService = Mockito.mock(SendBotMessageService.class);
       commandContainer = new CommandContainer(sendBotMessageService);
   }

   @Test
   public void shouldGetAllTheExistingCommands() {
       //when-then
       Arrays.stream(CommandName.values())
               .forEach(commandName -> {
                   Command command = commandContainer.retrieveCommand(commandName.getCommandName());
                   Assertions.assertNotEquals(UnknownCommand.class, command.getClass());
               });
   }

   @Test
   public void shouldReturnUnknownCommand() {
       //given
       String unknownCommand = "/fgjhdfgdfg";

       //when
       Command command = commandContainer.retrieveCommand(unknownCommand);

       //then
       Assertions.assertEquals(UnknownCommand.class, command.getClass());
   }
}
Здесь не совсем очевидный тест. Он опирается на логику работы контейнера. Все команды, которые поддерживает бот, находятся в списке CommandName и должны быть в контейнере. Поэтому я взял все переменные CommandName, перешел в Stream API и для каждого выполнил поиск команды из контейнера. Если бы такой команды не было, была бы возвращена команда UnknownCommand. Это мы и проверяем в этой строке:
Assertions.assertNotEquals(UnknownCommand.class, command.getClass());
А чтобы проверить, что по умолчанию будет UnknownCommand, нужен отдельный тест — shouldReturnUnknownCommand. Советую эти тесты переписать и проанализировать. Для команд пока что будут полуформальные тесты, но их нужно писать. Логика будет такая же, как и для тестирования SendBotMessageService, поэтому я вынесу общую логику тестов в AbstractCommandTest класс, и уже каждый конкретный тест-класс будет наследоваться и определять необходимые ему поля. Так как все тесты однотипные, писать одно и тоже каждый раз не с руки, плюс это не признак хорошего кода. Вот такой получился обобщенный абстрактный класс:
package com.github.javarushcommunity.jrtb.command;

import com.github.javarushcommunity.jrtb.bot.JavarushTelegramBot;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import com.github.javarushcommunity.jrtb.service.SendBotMessageServiceImpl;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.mockito.internal.verification.VerificationModeFactory;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;

/**
* Abstract class for testing {@link Command}s.
*/
abstract class AbstractCommandTest {

   protected JavarushTelegramBot javarushBot = Mockito.mock(JavarushTelegramBot.class);
   protected SendBotMessageService sendBotMessageService = new SendBotMessageServiceImpl(javarushBot);

   abstract String getCommandName();

   abstract String getCommandMessage();

   abstract Command getCommand();

   @Test
   public void shouldProperlyExecuteCommand() throws TelegramApiException {
       //given
       Long chatId = 1234567824356L;

       Update update = new Update();
       Message message = Mockito.mock(Message.class);
       Mockito.when(message.getChatId()).thenReturn(chatId);
       Mockito.when(message.getText()).thenReturn(getCommandName());
       update.setMessage(message);

       SendMessage sendMessage = new SendMessage();
       sendMessage.setChatId(chatId.toString());
       sendMessage.setText(getCommandMessage());
       sendMessage.enableHtml(true);

       //when
       getCommand().execute(update);

       //then
       Mockito.verify(javarushBot).execute(sendMessage);
   }
}
Как видим, у нас есть три абстрактных метода, после определения которых у каждой команды должен запуститься тест, который здесь написан, и выполнится правильно. Это такой удобный подход, когда основная логика находится в абстрактном классе, а вот детали определяются в наследниках. А вот, собственно, реализации конкретных тестов:

HelpCommandTest:

package com.github.javarushcommunity.jrtb.command;

import org.junit.jupiter.api.DisplayName;

import static com.github.javarushcommunity.jrtb.command.CommandName.HELP;
import static com.github.javarushcommunity.jrtb.command.HelpCommand.HELP_MESSAGE;

@DisplayName("Unit-level testing for HelpCommand")
public class HelpCommandTest extends AbstractCommandTest {

   @Override
   String getCommandName() {
       return HELP.getCommandName();
   }

   @Override
   String getCommandMessage() {
       return HELP_MESSAGE;
   }

   @Override
   Command getCommand() {
       return new HelpCommand(sendBotMessageService);
   }
}

NoCommandTest:

package com.github.javarushcommunity.jrtb.command;

import org.junit.jupiter.api.DisplayName;

import static com.github.javarushcommunity.jrtb.command.CommandName.NO;
import static com.github.javarushcommunity.jrtb.command.NoCommand.NO_MESSAGE;

@DisplayName("Unit-level testing for NoCommand")
public class NoCommandTest extends AbstractCommandTest {

   @Override
   String getCommandName() {
       return NO.getCommandName();
   }

   @Override
   String getCommandMessage() {
       return NO_MESSAGE;
   }

   @Override
   Command getCommand() {
       return new NoCommand(sendBotMessageService);
   }
}

StartCommandTest:

package com.github.javarushcommunity.jrtb.command;

import org.junit.jupiter.api.DisplayName;

import static com.github.javarushcommunity.jrtb.command.CommandName.START;
import static com.github.javarushcommunity.jrtb.command.StartCommand.START_MESSAGE;

@DisplayName("Unit-level testing for StartCommand")
class StartCommandTest extends AbstractCommandTest {

   @Override
   String getCommandName() {
       return START.getCommandName();
   }

   @Override
   String getCommandMessage() {
       return START_MESSAGE;
   }

   @Override
   Command getCommand() {
       return new StartCommand(sendBotMessageService);
   }
}

StopCommandTest:

package com.github.javarushcommunity.jrtb.command;

import org.junit.jupiter.api.DisplayName;

import static com.github.javarushcommunity.jrtb.command.CommandName.STOP;
import static com.github.javarushcommunity.jrtb.command.StopCommand.STOP_MESSAGE;

@DisplayName("Unit-level testing for StopCommand")
public class StopCommandTest extends AbstractCommandTest {

   @Override
   String getCommandName() {
       return STOP.getCommandName();
   }

   @Override
   String getCommandMessage() {
       return STOP_MESSAGE;
   }

   @Override
   Command getCommand() {
       return new StopCommand(sendBotMessageService);
   }
}

UnknownCommandTest:

package com.github.javarushcommunity.jrtb.command;

import org.junit.jupiter.api.DisplayName;

import static com.github.javarushcommunity.jrtb.command.UnknownCommand.UNKNOWN_MESSAGE;

@DisplayName("Unit-level testing for UnknownCommand")
public class UnknownCommandTest extends AbstractCommandTest {

   @Override
   String getCommandName() {
       return "/fdgdfgdfgdbd";
   }

   @Override
   String getCommandMessage() {
       return UNKNOWN_MESSAGE;
   }

   @Override
   Command getCommand() {
       return new UnknownCommand(sendBotMessageService);
   }
}
Отлично видно, что игра стоила свеч, и благодаря AbstractCommandTest мы получили в итоге простые и понятные тесты, которые легко писать, легко понимать. В довесок избавились от лишнего дублирования кода (привет принципу DRY -> Don’t Repeat Yourself). К тому же теперь у нас есть настоящие тесты, по которым можно судить о работе приложения. Еще хорошо бы написать тест на самого бота, но там все так просто не получится и вообще, может, игра не стоит свеч, как говорится. Поэтому на данном этапе будем завершать нашу задачу. Последнее и любимое — создаем коммит, пишет сообщение: JRTB-3: added Command pattern for handling Telegram Bot commands И как обычно — гитхаб уже знает и предлагает создать пулл-реквест:"Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 2 - 1Билд прошел и можно уже мержить… Но нет! Я ж забыл обновить версию проекта и записать в RELEASE_NOTES. Добавляем запись с новой версией — 0.2.0-SNAPSHOT:"Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 2 - 2Обновляем эту версию в pom.xml и создаем новый коммит:"Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 2 - 3Новый коммит: JRTB-3: updated RELEASE_NOTES.md"Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 2 - 4Теперь пуш и ждем пока пройдет билд. Билд прошел, можно и мержить:"Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 2 - 5Ветку я не удаляю, так что всегда можно будет посмотреть и сравнить, что изменилось. Наша доска с задачами обновилась:"Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 2 - 6

Выводы

Сегодня мы сделали большое дело: внедрили Command шаблон для работы. Все настроено, и теперь добавление новой команды будет простым и понятным процессом. Также сегодня сегодня поговорили о тестировании. Немного даже поигрались с тем, чтобы не повторять код в разных тестах для команд. Традиционно предлагаю зарегистрироваться на GitHub и подписаться на мой аккаунт, чтобы следить за этой серией и другими проектами, которые я там веду. Также я создал телеграм-канал, в котором буду дублировать выход новых статей. Из интересного — код обычно выходит на неделю раньше самой статьи, и на канале я буду каждый раз писать о том, что новая задача сделана, что даст возможность разобраться с кодом до прочтения статьи. В скором времени я опубликую бота на постоянной основе, и первым узнают об этом те, кто подписан на телеграмм канал ;) Всем спасибо за прочтение, продолжение следует.

Список всех материалов серии в начале этой статьи.

Комментарии (14)
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны авторизоваться
Anonymous #2856674 Software Developer
25 марта, 10:51
Непонятно, зачем в абстрактном классе строка Mockito.when(message.getText()).thenReturn(getCommandName()); Ведь получение текста из Message при выполнении команд не вызывается ( по крайней мере на текущий момент). Тесты работают, соответственно и без этой команды
Ivanius
Уровень 34
29 октября 2023, 16:22
Модет кто более подробно разьяснить , что мы делаем в AbstractCommandClass, пожалуйста
Denis
Уровень 33
16 ноября 2022, 11:16
Все тесты прошли на Ура! Роман, спасибо!
Denis
Уровень 33
16 ноября 2022, 08:10
/* Комментарий удален */
Denis
Уровень 33
16 ноября 2022, 08:09
По количеству комментариев я полагаю, что не все дошли до тестов....или их пропустили....
Игорь HDL developer в Y
26 октября 2021, 13:25
Подобный способ вынесения реализации методов в конкретные классы, при сохранении основной логики (последовательности действий) в абстрактном классе ни что иное как паттерн шаблонный метод.
Владимир
Уровень 14
17 апреля 2021, 09:34
Почему у меня в AbstractCommandTest в 37 строке идея не видит метода setMessage и подсвечивает красным?
Roman Beekeeper тг-канал по java разработ в t.me/romankh3
11 марта 2021, 21:01
⚡️UPDATE⚡️ Друзья, создал телеграм-канал 🤓, в котором освещаю свою писательскую деятельность и свою open-source разработку в целом. Не хотите пропустить новые статьи? Присоединяйтесь ✌️
Romanya Java Developer в Продуктовая IT компа
3 апреля 2021, 00:18
С какого уровня JR возможно будет осилить этот проект?
Roman Beekeeper тг-канал по java разработ в t.me/romankh3
3 апреля 2021, 04:49
Ты не узнаешь, пока не попробуешь ) я думаю с 15-20 уже точно должно получиться
Руслан
Уровень 35
8 марта 2021, 15:46
У меня одного ошибки на maven test выходят?(00)
Руслан
Уровень 35
9 марта 2021, 13:57
В классе StartCommandTest была ошибка в методе Command getCommand(). Можно найти подсказку в логах при запуске тестирования
sergey
Уровень 22
23 февраля 2021, 07:08
Почему в CommandContainerTest мне пришлось импортировать классы Command, CommandContainer, CommandName и UnknownCommand, поскольку Идея ругалась на неизвесный класс? Что я сделал не так?
Roman Beekeeper тг-канал по java разработ в t.me/romankh3
23 февраля 2021, 08:29
Возможно разница в пакетах CommanContainer и CommanContainerTest ?