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 Уровень 18
25 марта 2024
Непонятно, зачем в абстрактном классе строка Mockito.when(message.getText()).thenReturn(getCommandName()); Ведь получение текста из Message при выполнении команд не вызывается ( по крайней мере на текущий момент). Тесты работают, соответственно и без этой команды
Ivanius Уровень 34
29 октября 2023
Модет кто более подробно разьяснить , что мы делаем в AbstractCommandClass, пожалуйста
Denis Уровень 33
16 ноября 2022
Все тесты прошли на Ура! Роман, спасибо!
Denis Уровень 33
16 ноября 2022
/* Комментарий удален */
Denis Уровень 33
16 ноября 2022
По количеству комментариев я полагаю, что не все дошли до тестов....или их пропустили....
Игорь Уровень 22
26 октября 2021
Подобный способ вынесения реализации методов в конкретные классы, при сохранении основной логики (последовательности действий) в абстрактном классе ни что иное как паттерн шаблонный метод.
Владимир Уровень 14
17 апреля 2021
Почему у меня в AbstractCommandTest в 37 строке идея не видит метода setMessage и подсвечивает красным?
Roman Beekeeper Уровень 35
11 марта 2021
⚡️UPDATE⚡️ Друзья, создал телеграм-канал 🤓, в котором освещаю свою писательскую деятельность и свою open-source разработку в целом. Не хотите пропустить новые статьи? Присоединяйтесь ✌️
Руслан Уровень 35
8 марта 2021
У меня одного ошибки на maven test выходят?(00)
sergey Уровень 22
23 февраля 2021
Почему в CommandContainerTest мне пришлось импортировать классы Command, CommandContainer, CommandName и UnknownCommand, поскольку Идея ругалась на неизвесный класс? Что я сделал не так?