Как сэкономить на психотерапевте используя test-driven development
У вас когда-нибудь было такое состояние?
Хочу показать вам, как TDD может улучшить качество кода на конкретном примере.
Потому что всё то, что я встречал при изучении вопроса, было довольно-таки теоретическим.
Так получилось, что мне довелось написать два практически идентичных приложения: одно писалось в классическом стиле, так как я ещё не знал тогда TDD, в второе — как раз с использованием TDD.
Ниже я покажу, где были самые большие различия.
Лично мне это было важно потому, что каждый раз, когда кто-то находил баг в моём коде, я ловил увесистый минус на самооценку. Да, я понимал, что баги — это нормально, их пишут все, но ощущение неполноценности никуда не уходило. Также, в процессе эволюции сервиса, я иногда понимал, что сам понаписал такого, что чешутся руки всё выкинуть и переписать заново. И как это получилось — непонятно. Как-то всё было хорошо в начале, но вот пару фич и через некоторое время уже на архитектуру без слёз не взглянешь. Хотя вроде каждый шаг изменения был логичный. Ощущение того, что мне не нравится продукт собственного труда плавно перетекал в ощущение, что программист из меня, простите, как из говна пуля.
Оказалось, я не один такой и схожие ощущения возникают у многих моих коллег. И тогда я решил, что либо научусь писать нормально, либо пора менять профессию. Я попробовал test-driven development, в попытке что-то изменить в своём подходе к программированию.
Забегая вперёд, по результату нескольких проектов, могу сказать, что TDD даёт более чистую архитектуру, но при этом замедляет разработку. И подходит не всегда и не всем.
TDD — разработка через тестирование. Вики-статья тут.
Классический подход — сначала пишем приложение, потом покрываем его тестами.
TDD-подход — сначала пишем тесты на класс, потом имплементацию. Двигаемся по уровням абстракции — от самого высокого, до прикладного, попутно разбивая приложение на слои-классы, у которых мы заказываем требуемое нам поведение будучи свободными от конкретной реализации.
И, если бы я читал это впервые, я бы тоже ничего не понял.
Слишком много абстрактных слов: давайте разбираться на примере.
Будем писать реальное спринговое приложение на Java, будем писать его по TDD и я постараюсь показать свой мыслительный процесс в процессе разработки и в конце сделать выводы — имеет ли смысл тратить время на TDD или нет.
Допустим, нам настолько повезло, что у нас есть ТЗ того, что нам нужно разработать. Обычно аналитики с ним не заморачиваются, и оно выглядит примерно следующим образом:
Необходимо разработать микросервис, который будет рассчитывать возможность продажи товара с последующей доставкой клиенту на дом. Информация об этой возможности должна быть отправлена в стороннюю систему DATA
Бизнес-логика следующая: товар доступен для продажи с доставкой, если:
- Товар есть в наличии
- Подрядчик (допустим, компания DostavchenKO) имеет возможность его отвезти клиенту
- Цвет товара — не синий (не любим синий)
О изменении количества товара на полке магазина наш микросервис будут уведомлять через http запрос.
Это уведомление является триггером к расчёту доступности.
Плюс к этому, чтобы жизнь мёдом не казалась:
- У пользователя должна быть возможность отключать в ручном режиме некоторые товары.
- Чтобы не заспамить DATA необходимо отправлять только данные доступности по тем товарам, которые изменились.
Читаем пару раз ТЗ — и в путь.
В TDD один из самых главных вопросов который придётся задавать ко всему тому что вы пишете, — это: «Чего я хочу от… ?»
И, первый вопрос мы задаём как раз ко всему приложению.
Итак, вопрос:
Что я хочу от своего микросервиса?
Ответ:
На самом деле очень много всего. Даже такая простая логика даёт очень много вариантов, попытка записать которые, а тем более создать для всех них тесты, может оказаться непосильной задачей. Поэтому для ответа на вопрос на уровне приложения мы выберем только основные тест-кейсы.
Т. е. мы предполагаем, что все входные данные валидного формата, сторонние системы отвечают в штатном режиме и ранее по товару информации не было.
Итак, я хочу чтобы:
- Пришло событие, что на полке товара нет. Уведомляем, что доставка недоступна.
- Пришло событие, что жёлтый товар — в наличии, DostavchenKO готов его отвезти. Уведомляем о доступности товара.
- Пришло два подряд сообщения — оба с положительным количеством товара в магазине. Отправили только одно сообщение.
- Пришло два сообщения: в первом товар в магазине есть, во втором — уже нет. Отправляем два сообщения: сначала — доступен, потом — нет.
- Я могу отключить товар вручную, и по нему больше не отсылаются уведомления.
- …
Тут главное — вовремя остановиться: как я уже писал, вариантов слишком много, и все их тут описывать не имеет смысла — только самые основные. В дальнейшем, когда мы будем писать тесты на бизнес-логику, их совокупность, скорее всего, покроет всё то, что мы тут придумаем. Основная мотивация тут — это быть уверенными, что, если эти тесты проходят, значит, приложение работает так, как нам надо.
Все эти хотелки мы сейчас будем перегонять в тесты. Причём, т. к. это хотелки на уровне приложения, то тесты у нас будут с поднятием спрингового контекста, т. е. довольно тяжёлые.
И на этом, к сожалению, для многих TDD заканчивается, т. к., чтобы написать такой интеграционный тест, нужно довольно много усилий, которые люди не всегда готовы тратить. И да, это самый сложный шаг, но, поверьте, после того, как вы его пройдёте, дальше код чуть ли не сам себя будет писать, а вы будете уверены, что ваше приложение будет работать именно так, как вы хотите.
В процессе ответа на вопрос уже можно начинать писать код в сгенерированном spring initializr-классе. Имена тестов — это как раз наши хотелки. Пока просто создаём пустые методы:
@Test
public void notifyNotAvailableIfProductQuantityIsZero() {}
@Test
public void notifyAvailableYellowProductIfPositiveQuantityAndDostavchenkoApproved() {}
@Test
public void notifyOnceOnSeveralEqualProductMessages() {}
@Test
public void notifyFirstAvailableThenNotIfProductQuantityMovedFromPositiveToZero() {}
@Test
public void noNotificationOnDisabledProduct() {}
По поводу именования методов: очень советую делать их информативными, а не test1(), test2(), т. к. впоследствии, когда вы забудете, что за класс вы писали и за что он отвечает, у вас будет возможность, вместо того чтобы пытаться разобрать непосредственно код, просто открыть тест и прочитать по методам контракт, которому класс удовлетворяет.
Начинаем заполнять тесты
Основная идея — это эмулировать всё внешнее, чтобы проверить, что творится внутри.
«Внешнее» по отношению к нашему сервису — это всё, что НЕ сам микросервис, но что с ним непосредственно коммуницирует.
В данном случае внешнее — это:
- Система, которая будет наш сервис уведомлять о изменениях количества товара
- Клиент, который будет отключать товары в ручном режиме
- Сторонняя система DostavchenKO
Чтобы эмулировать запросы первых двух используем спринговый MockMvc.
Для эмуляции DostavchenKO используем wiremock или MockRestServiceServer.
В результате наш интеграционный тест выглядит так:
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureWireMock(port = 8090)
public class TddExampleApplicationTests {
@Autowired
private MockMvc mockMvc;
@Before
public void init() {
WireMock.reset();
}
@Test
public void notifyNotAvailableIfProductQuantityIsZero() throws Exception {
stubNotification(
// language=JSON
"{\n" +
" \"productId\": 111,\n" +
" \"available\": false\n" +
"}");
performQuantityUpdateRequest(
// language=JSON
"{\n" +
" \"productId\": 111,\n" +
" \"color\" : \"red\", \n" +
" \"productQuantity\": 0\n" +
"}");
verify(1, postRequestedFor(urlEqualTo("/notify")));
}
@Test
public void notifyAvailableYellowProductIfPositiveQuantityAndDostavchenkoApproved() throws Exception {
stubDostavchenko("112");
stubNotification(
// language=JSON
"{\n" +
" \"productId\": 112,\n" +
" \"available\": true\n" +
"}");
performQuantityUpdateRequest(
// language=JSON
"{\n" +
" \"productId\": 112,\n" +
" \"color\" : \"Yellow\", \n" +
" \"productQuantity\": 10\n" +
"}");
verify(1, postRequestedFor(urlEqualTo("/notify")));
}
@Test
public void notifyOnceOnSeveralEqualProductMessages() throws Exception {
stubDostavchenko("113");
stubNotification(
// language=JSON
"{\n" +
" \"productId\": 113,\n" +
" \"available\": true\n" +
"}");
for (int i = 0; i < 5; i++) {
performQuantityUpdateRequest(
// language=JSON
"{\n" +
" \"productId\": 113,\n" +
" \"color\" : \"Yellow\", \n" +
" \"productQuantity\": 10\n" +
"}");
}
verify(1, postRequestedFor(urlEqualTo("/notify")));
}
@Test
public void notifyFirstAvailableThenNotIfProductQuantityMovedFromPositiveToZero() throws Exception {
stubDostavchenko("114");
stubNotification(
// language=JSON
"{\n" +
" \"productId\": 114,\n" +
" \"available\": true\n" +
"}");
performQuantityUpdateRequest(
// language=JSON
"{\n" +
" \"productId\": 114,\n" +
" \"color\" : \"Yellow\",\n" +
" \"productQuantity\": 10\n" +
"}");
stubNotification(
// language=JSON
"{\n" +
" \"productId\": 114,\n" +
" \"available\": false\n" +
"}");
performQuantityUpdateRequest(
// language=JSON
"{\n" +
" \"productId\": 114,\n" +
" \"color\" : \"Yellow\",\n" +
" \"productQuantity\": 0\n" +
"}");
verify(2, postRequestedFor(urlEqualTo("/notify")));
}
@Test
public void noNotificationOnDisabledProduct() throws Exception {
stubNotification(
// language=JSON
"{\n" +
" \"productId\": 115,\n" +
" \"available\": false\n" +
"}");
disableProduct(115);
for (int i = 0; i < 5; i++) {
performQuantityUpdateRequest(
// language=JSON
"{\n" +
" \"productId\": 115,\n" +
" \"color\" : \"Yellow\",\n" +
" \"productQuantity\": " + i + "\n" +
"}");
}
verify(1, postRequestedFor(urlEqualTo("/notify")));
}
private void disableProduct(int productId) throws Exception {
mockMvc.perform(
post("/disableProduct?productId=" + productId)
).andDo(
print()
).andExpect(
status().isOk()
);
}
private void performQuantityUpdateRequest(String content) throws Exception {
mockMvc.perform(
post("/product-quantity-update")
.contentType(MediaType.APPLICATION_JSON)
.content(content)
).andDo(
print()
).andExpect(
status().isOk()
);
}
private void stubNotification(String content) {
stubFor(WireMock.post(urlEqualTo("/notify"))
.withHeader("Content-Type", equalTo(MediaType.APPLICATION_JSON_UTF8_VALUE))
.withRequestBody(equalToJson(content))
.willReturn(aResponse().withStatus(HttpStatus.OK_200)));
}
private void stubDostavchenko(final String productId) {
stubFor(get(urlEqualTo("/isDeliveryAvailable?productId=" + productId))
.willReturn(aResponse().withStatus(HttpStatus.OK_200).withBody("true")));
}
}
Мы написали интеграционный тест, прохождение которого нам гарантирует работоспособность системы по основным юзер стори. И мы сделали это ДО того как начать реализовывать сервис.
Одно из преимуществ такого подхода — это то, что в процессе написания пришлось сходить в реальный DostavchenKO и получить оттуда реальный ответ на реальный запрос, который мы внесли в наш стаб. Очень хорошо, что мы этим озаботились в самом начале разработки, а не после того, как весь код написан. И тут оказывается, что формат не тот, который указан в ТЗ, или сервис вообще недоступен, или ещё что-нибудь.
Также хотелось бы отметить, что мы пока ещё не только не написали ни одной строчки кода, который потом пойдёт в прод, но ещё даже не сделали ни одного предположения по поводу того, как внутри будет устроен наш микросервис: какие там будут слои, будем ли мы использовать базу, если да, то какую, и т. д. На момент написания теста мы абстрагированы от реализации, и, как увидим далее, это может дать ряд архитектурных преимуществ.
В отличие от каноничного TDD, где после теста сразу пишется имплементация, интеграционный тест не будет проходить ещё очень долго, На самом деле он не станет зелёным до самого конца разработки, пока не будет написано абсолютно всё, включая проперти файлы.
Едем дальше.
После того, как мы написали интеграционный тест, и теперь уверены в том, что после того, как сдадим задачу, мы можем спокойно спать по ночам, пришла пора приступить к программированию слоёв. И первый слой, который мы будем реализовывать — это контроллер. Почему именно он? Потому что это точка входа в программу. Нам необходимо двигаться сверху вниз, от самого первого слоя, с которым будет взаимодействовать пользователь, до последнего.
Это важно.
И снова всё начинается с того же вопроса:
Что я хочу от контроллера?
Ответ:
Мы знаем, что контроллер занимается общением с пользователем, валидацией и конвертацией входных данных и не содержит бизнес-логики. Таким образом, ответ на этот вопрос может быть примерно следующим:
Я хочу чтобы:
- Пользователю вернулся BAD_REQUEST при попытке отключить товар с невалидным id
- BAD_REQUEST при попытке уведомить о изменении товара с невалидным id
- BAD_REQUEST при попытке уведомления об отрицательном количестве
- INTERNAL_SERVER_ERROR, если DostavchenKO недоступен
- INTERNAL_SERVER_ERROR, eсли не смогли отправить в DATA
Так как мы хотим быть юзер-френдли, то для всех пунктов выше, помимо http-кода, необходимо выводить кастомное сообщение с описанием проблемы, чтобы пользователь понимал, в чём проблема.
- 200, если обработка прошла успешно
- INTERNAL_SERVER_ERROR с дефолтным сообщением во всех остальных случаях, чтобы не светить стектрейс
Пока я не начал писать по TDD, я в последнюю очередь думал о том, что выведет моя система для пользователя в каком-то частном и, на первый взгляд, маловероятном случае. Не думал по одной простой причине — писать реализацию и так сложно, на то, чтобы учесть абсолютно все краевые случаи, иногда не хватает оперативной памяти мозга. А после написанной имплементации анализировать код на то, что ты, возможно, не учёл заранее, — ещё то удовольствие: мы же все считаем, что пишем идеальный код сразу). Пока имплементации нет — о ней не нужно думать, и нет боли её менять, если что. Написав тест сначала, у тебя нет необходимости ждать, пока звёзды сойдутся, и после вывода в прод откажет определённое количество систем, и к вам прибежит заказчик с просьбой что-то поправить. И это относится не только к контроллеру.
Начинаем писать тесты
С первыми тремя всё понятно: используем спринговую валидацию, если пришёл невалидный реквест — приложение выкинет эксепшн, который мы поймаем в exception handler. Тут, как говорится, всё работает само, а вот откуда контроллер узнает, что какая-то сторонняя система недоступна?
Совершенно понятно, что сам контроллер о сторонних системах знать ничего не должен, т.к. какую систему спросить и о чём — это бизнес-логика, т. е. должен быть какой-то посредник. Этим посредником является сервис. И мы будем писать тесты на контроллер, используя mock этого сервиса, эмулируя его поведение в тех или иных случаях. Итак, сервис должен как-то сообщить контроллеру о том, что система недоступна. Можно сделать это по-разному, но проще всего кидать кастомный эксепшн. На это поведение контроллера мы и будем писать тест.
@RunWith(SpringRunner.class)
@WebMvcTest
@AutoConfigureMockMvc
public class ControllerTest {
@MockBean
private UpdateProcessorService updateProcessorService;
@Test
public void returnServerErrorOnDataCommunicationError() throws Exception {
doThrow(new DataCommunicationException()).when(updateProcessorService).processUpdate(any(Update.class));
performUpdate(
//language=JSON
"{\n" +
" \"productId\": 1,\n" +
" \"color\": \"red\",\n" +
" \"productQuantity\": 10\n" +
"}"
).andDo(
print()
).andExpect(
status().isInternalServerError()
).andExpect(
content().json("{\n" +
" \"errors\": [\n" +
" {\n" +
" \"message\": \"Can't communicate with Data system\"\n" +
" }\n" +
" ]\n" +
"}")
);
}
}
На этом этапе сами собой появились несколько вещей:
- Сервис, который будет инжектится в контроллер и которому будет делегирована обработка входящего сообщения по новому количеству товара.
- Метод этого сервиса, а соответственно и его сигнатура, который будет эту обработку проводить.
- Осознание того, что метод должен выкидывать кастомный эксепшн при недоступности системы.
- Сам этот кастомный эксепшн.
Почему сами собой? Потому что, как вы помните, мы ещё не написали имплементацию. И все эти сущности появились в процессе того, как мы программируем тесты. Чтобы компилятор не ругался, в реальном коде, нам придётся создать всё описанное выше. Благо, практически любое IDE поможет нам сгенерировать необходимые сущности. Таким образом мы вроде пишем тест —, а приложение наполняется классами и методами.
Итого, тесты на контроллер выглядят следующим образом:
@RunWith(SpringRunner.class)
@WebMvcTest
@AutoConfigureMockMvc
public class ControllerTest {
@InjectMocks
private Controller controller;
@MockBean
private UpdateProcessorService updateProcessorService;
@Autowired
private MockMvc mvc;
@Test
public void returnBadRequestOnDisableWithInvalidProductId() throws Exception {
mvc.perform(
post("/disableProduct?productId=-443")
).andDo(
print()
).andExpect(
status().isBadRequest()
).andExpect(
content().json(getInvalidProductIdJsonContent())
);
}
@Test
public void returnBadRequestOnNotifyWithInvalidProductId() throws Exception {
performUpdate(
//language=JSON
"{\n" +
" \"productId\": -1,\n" +
" \"color\": \"red\",\n" +
" \"productQuantity\": 0\n" +
"}"
).andDo(
print()
).andExpect(
status().isBadRequest()
).andExpect(
content().json(getInvalidProductIdJsonContent())
);
}
@Test
public void returnBadRequestOnNotifyWithNegativeProductQuantity() throws Exception {
performUpdate(
//language=JSON
"{\n" +
" \"productId\": 1,\n" +
" \"color\": \"red\",\n" +
" \"productQuantity\": -10\n" +
"}"
).andDo(
print()
).andExpect(
status().isBadRequest()
).andExpect(
content().json("{\n" +
" \"errors\": [\n" +
" {\n" +
" \"message\": \"productQuantity is invalid\"\n" +
" }\n" +
" ]\n" +
"}")
);
}
@Test
public void returnServerErrorOnDostavchenkoCommunicationError() throws Exception {
doThrow(new DostavchenkoException()).when(updateProcessorService).processUpdate(any(Update.class));
performUpdate(
//language=JSON
"{\n" +
" \"productId\": 1,\n" +
" \"color\": \"red\",\n" +
" \"productQuantity\": 10\n" +
"}"
).andDo(
print()
).andExpect(
status().isInternalServerError()
).andExpect(
content().json("{\n" +
" \"errors\": [\n" +
" {\n" +
" \"message\": \"DostavchenKO communication exception\"\n" +
" }\n" +
" ]\n" +
"}")
);
}
@Test
public void returnServerErrorOnDataCommunicationError() throws Exception {
doThrow(new DataCommunicationException()).when(updateProcessorService).processUpdate(any(Update.class));
performUpdate(
//language=JSON
"{\n" +
" \"productId\": 1,\n" +
" \"color\": \"red\",\n" +
" \"productQuantity\": 10\n" +
"}"
).andDo(
print()
).andExpect(
status().isInternalServerError()
).andExpect(
content().json("{\n" +
" \"errors\": [\n" +
" {\n" +
" \"message\": \"Can't communicate with Data system\"\n" +
" }\n" +
" ]\n" +
"}")
);
}
@Test
public void return200OnSuccess() throws Exception {
performUpdate(
//language=JSON
"{\n" +
" \"productId\": 1,\n" +
" \"color\": \"red\",\n" +
" \"productQuantity\": 10\n" +
"}"
).andDo(
print()
).andExpect(
status().isOk()
);
}
@Test
public void returnServerErrorOnUnexpectedException() throws Exception {
doThrow(new RuntimeException()).when(updateProcessorService).processUpdate(any(Update.class));
performUpdate(
//language=JSON
"{\n" +
" \"productId\": 1,\n" +
" \"color\": \"red\",\n" +
" \"productQuantity\": 10\n" +
"}"
).andDo(
print()
).andExpect(
status().isInternalServerError()
).andExpect(
content().json("{\n" +
" \"errors\": [\n" +
" {\n" +
" \"message\": \"Internal Server Error\"\n" +
" }\n" +
" ]\n" +
"}")
);
}
@Test
public void returnTwoErrorMessagesOnInvalidProductIdAndNegativeQuantity() throws Exception {
performUpdate(
//language=JSON
"{\n" +
" \"productId\": -1,\n" +
" \"color\": \"red\",\n" +
" \"productQuantity\": -10\n" +
"}"
).andDo(
print()
).andExpect(
status().isBadRequest()
).andExpect(
content().json("{\n" +
" \"errors\": [\n" +
" { \"message\": \"productQuantity is invalid\" },\n" +
" { \"message\": \"productId is invalid\" }\n" +
" ]\n" +
"}")
);
}
private ResultActions performUpdate(String jsonContent) throws Exception {
return mvc.perform(
post("/product-quantity-update")
.contentType(MediaType.APPLICATION_JSON_UTF8_VALUE)
.content(jsonContent)
);
}
private String getInvalidProductIdJsonContent() {
return
//language=JSON
"{\n" +
" \"errors\": [\n" +
" {\n" +
" \"message\": \"productId is invalid\"\n" +
" }\n" +
" ]\n" +
"}";
}
}
Теперь уже мы можем написать имплементацию и добиться того чтобы все тесты успешно проходили:
@RestController
@AllArgsConstructor
@Validated
@Slf4j
public class Controller {
private final UpdateProcessorService updateProcessorService;
@PostMapping("/product-quantity-update")
public void updateQuantity(@RequestBody @Valid Update update) {
updateProcessorService.processUpdate(update);
}
@PostMapping("/disableProduct")
public void disableProduct(@RequestParam("productId") @Min(0) Long productId) {
updateProcessorService.disableProduct(Long.valueOf(productId));
}
}
@ControllerAdvice
@Slf4j
public class ApplicationExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
@ResponseBody
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse onConstraintViolationException(ConstraintViolationException exception) {
log.info("Constraint Violation", exception);
return new ErrorResponse(exception.getConstraintViolations().stream()
.map(constraintViolation -> new ErrorResponse.Message(
((PathImpl) constraintViolation.getPropertyPath()).getLeafNode().toString() +
" is invalid"))
.collect(Collectors.toList()));
}
@ExceptionHandler(value = MethodArgumentNotValidException.class)
@ResponseBody
@ResponseStatus(value = HttpStatus.BAD_REQUEST)
public ErrorResponse onMethodArgumentNotValidException(MethodArgumentNotValidException exception) {
log.info(exception.getMessage());
List fieldErrors = exception.getBindingResult().getFieldErrors().stream()
.map(fieldError -> new ErrorResponse.Message(fieldError.getField() + " is invalid"))
.collect(Collectors.toList());
return new ErrorResponse(fieldErrors);
}
@ExceptionHandler(DostavchenkoException.class)
@ResponseBody
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse onDostavchenkoCommunicationException(DostavchenkoException exception) {
log.error("DostavchenKO communication exception", exception);
return new ErrorResponse(Collections.singletonList(
new ErrorResponse.Message("DostavchenKO communication exception")));
}
@ExceptionHandler(DataCommunicationException.class)
@ResponseBody
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse onDataCommunicationException(DataCommunicationException exception) {
log.error("DostavchenKO communication exception", exception);
return new ErrorResponse(Collections.singletonList(
new ErrorResponse.Message("Can't communicate with Data system")));
}
@ExceptionHandler(Exception.class)
@ResponseBody
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse onException(Exception exception) {
log.error("Error while processing", exception);
return new ErrorResponse(Collections.singletonList(
new ErrorResponse.Message(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())));
}
}
В TDD не надо держать весь код в голове.
Давайте ещё раз: не надо держать всю архитектуру в оперативной памяти. Достаточно смотреть на один слой. Он простой.
В обычном процессе мозгов не хватает, потому что есть куча имплементаций. Если вы супергерой, который умеет учитывать все нюансы большого проекта в голове, то TDD применять не нужно. Я так не умею. Чем больше проект — тем больше я ошибаюсь.
После осознания того, что вам нужно понять только то, что нужно следующему слою, наступает просветление в жизни. Дело в том, что этот подход позволяет не заниматься ненужными вещами. Вот общаешься ты с девушкой. Она рассказывает что-то про проблему на работе. И ты думаешь, как её решить, голову ломаешь. А ей не надо её решить, ей надо просто рассказать. И всё. Она просто захотела поделиться чем-то. Узнать об этом на первом же этапе listen () — бесценно. Для всего остального… ну вы знаете.
Дальше реализуем сервис.
Чего мы хотим от сервиса?
Хотим, чтобы он занимался бизнес-логикой, т. е.:
- Умел отключать товары, а также уведомлял о:
- Доступности, если товар не отключён, есть в наличии, цвет товара — жёлтый, и DostavchenKO готов совершить доставку.
- Недоступности, если товара в наличии нет независимо ни от чего.
- Недоступности, если товар — синего цвета.
- Недоступности, если DostavchenKO отказывается его везти.
- Недоступности, если товар отключён вручную.
- Далее хотим, чтобы сервис выбрасывал эксепшн, если какая-то из систем недоступна.
- А также, чтобы не заспамить DATA, нужно организовать ленивую отправку сообщений, а именно:
- Если мы раньше по товару отправляли доступно и сейчас рассчитали, что доступно, то ничего не отправляем.
- А если раньше недоступно, а теперь доступно — отправляем.
- А ещё нужно это куда-то записывать…
СТОП!
Вам не кажется, что наш сервис начинает слишком многим заниматься?
Судя по нашим хотелкам, он и товары отключать умеет, и доступность считает, и следит за тем, чтобы не отправлять ранее отправленные сообщения. Это не high cohesion. Нужно вынести разнородные функциональности в различные классы, а посему быть аж трём сервисам: один будет заниматься отключением товаров, другой — рассчитывать возможность доставки и передавать её дальше сервису, который будет решать, стоит ли её отправлять или нет. Кстати, таким образом сервис бизнес-логики ничего не будет знать о системе DATA, что есть тоже несомненный плюс.
По моему опыту, довольно часто, с головой уйдя в реализацию, легко упустить из виду архитектурные моменты. Если бы мы писали сервис сразу, не задумываясь о том, чем он должен заниматься, и, что ещё важнее, чем НЕ должен, то вероятность перекрытия зон ответственности возросла бы. От себя хотелось бы добавить, что именно этот пример, который со мной случился в реальной практике, и качественное различие результатов подходов TDD и последовательного программирования вдохновили меня на написание этого поста.
Размышляя о сервисе бизнес-логики по тем же соображениям high cohesion, мы понимаем, что необходим ещё один уровень абстракции между ним и реальным DostavchenKO. И, так как мы проектируем сервис первым, мы можем потребовать от клиента DostavchenKO такого внутреннего контракта, которого мы захотим. В процессе написания теста на бизнес-логику мы поймём, чего мы хотим от клиента следующей сигнатуры:
public boolean isAvailableForTransportation(Long productId) {...}
На уровне сервиса нам совершенно всё равно, каким образом отвечает реальный DostavchenKO: в дальнейшем задача клиента будет каким-то образом выцепить эту информацию из него. Когда-то это может быть просто, а когда-то будет необходимо сделать несколько запросов: на данный момент мы от этого абстрагированы.
Похожую сигнатуру хотим от сервиса, который будет заниматься отключёнными товарами:
public boolean isProductEnabled(Long productId) {...}
Итак, вопросы «Чего я хочу от сервиса бизнес-логики?», записанные в тестах, выглядят следующим образом:
@RunWith(MockitoJUnitRunner.class)
public class UpdateProcessorServiceTest {
@InjectMocks
private UpdateProcessorService updateProcessorService;
@Mock
private ManualExclusionService manualExclusionService;
@Mock
private DostavchenkoClient dostavchenkoClient;
@Mock
private AvailabilityNotifier availabilityNotifier;
@Test
public void notifyAvailableIfYellowProductIsEnabledAndReadyForTransportation() {
final Update testProduct = new Update(1L, 10L, "Yellow");
when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId())).thenReturn(true);
when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true);
updateProcessorService.processUpdate(testProduct);
verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), true)));
}
@Test
public void notifyNotAvailableIfProductIsAbsent() {
final Update testProduct = new Update(1L, 0L, "Yellow");
updateProcessorService.processUpdate(testProduct);
verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false)));
verifyNoMoreInteractions(manualExclusionService);
verifyNoMoreInteractions(dostavchenkoClient);
}
@Test
public void notifyNotAvailableIfProductIsBlue() {
final Update testProduct = new Update(1L, 10L, "Blue");
updateProcessorService.processUpdate(testProduct);
verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false)));
verifyNoMoreInteractions(manualExclusionService);
verifyNoMoreInteractions(dostavchenkoClient);
}
@Test
public void notifyNotAvailableIfProductIsDisabled() {
final Update testProduct = new Update(1L, 10L, "Yellow");
when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(false);
updateProcessorService.processUpdate(testProduct);
verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false)));
verifyNoMoreInteractions(dostavchenkoClient);
}
@Test
public void notifyNotAvailableIfProductIsNotReadyForTransportation() {
final Update testProduct = new Update(1L, 10L, "Yellow");
when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId())).thenReturn(false);
when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true);
updateProcessorService.processUpdate(testProduct);
verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false)));
}
@Test(expected = DostavchenkoException.class)
public void throwCustomExceptionIfDostavchenkoCommunicationFailed() {
final Update testProduct = new Update(1L, 10L, "Yellow");
when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId()))
.thenThrow(new RestClientException("Something's wrong"));
when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true);
updateProcessorService.processUpdate(testProduct);
}
}
На этом этапе сами собой родились:
- Клиент DostavchenKO с сингатурой удобной для сервиса
- Сервис, в котором необходимо будет реализовывать логику ленивой отправки, кому проектируемый сервис будет передавать результаты своей работы
- Сервис отключенных товаров и его сигнатура
Имплементация:
@RequiredArgsConstructor
@Service
@Slf4j
public class UpdateProcessorService {
private final AvailabilityNotifier availabilityNotifier;
private final DostavchenkoClient dostavchenkoClient;
private final ManualExclusionService manualExclusionService;
public void processUpdate(Update update) {
if (update.getProductQuantity() <= 0) {
availabilityNotifier.notify(getNotAvailableProduct(update.getProductId()));
return;
}
if ("Blue".equals(update.getColor())) {
availabilityNotifier.notify(getNotAvailableProduct(update.getProductId()));
return;
}
if (!manualExclusionService.isProductEnabled(update.getProductId())) {
availabilityNotifier.notify(getNotAvailableProduct(update.getProductId()));
return;
}
try {
final boolean availableForTransportation = dostavchenkoClient.isAvailableForTransportation(update.getProductId());
availabilityNotifier.notify(new ProductAvailability(update.getProductId(), availableForTransportation));
} catch (Exception exception) {
log.warn("Problems communicating with DostavchenKO", exception);
throw new DostavchenkoException();
}
}
private ProductAvailability getNotAvailableProduct(Long productId) {
return new ProductAvailability(productId, false);
}
}
Настало время для одной из неизбежных для TDD фаз — рефакторинга. Если вы помните, то после реализации контроллера контракт сервиса выглядел следующим образом:
public void disableProduct(long productId)
А теперь логику отключения мы решили вынести в отдельный сервис.
От этого сервиса на данном этапе мы хотим следующее:
- Возможность отключать товары.
- Хотим, чтобы он возвращал, что товар отключён, если он был отключён ранее.
- Хотим, чтобы он возвращал, что товар доступен, если отключения ранее не было.
Глядя на хотелки, которые являются прямым следствием контракта между сервисом бизнес-логики и проектируемым, хотелось бы заметить следующее:
- Во-первых, сразу видно, что у приложения могут быть проблемы, если кто-то захочет отключённый товар включить обратно, т. к. на данный момент этот сервис этого делать попросту не умеет. А это значит, что, возможно, стоит обсудить этот вопрос с аналитиком, который ставил задачу на разработку. Я понимаю, что в данном случае этот вопрос должен был возникнуть сразу после первого прочтения ТЗ, но мы проектируем довольно простую систему, в более масштабных проектах это могло бы быть не так очевидно. Тем более что мы не знали, что у нас будет сущность, отвечающая только за функционал отключения товаров: напомню, что у нас она родилась только в процессе разработки.
- Во-вторых, сигнатура методов сервиса содержит только идентификатор товара. И сохранять в коллекцию отключённых товаров мы будем только идентификатор — как минимум потому, что у нас на вход просто больше ничего нет. Забегая вперёд, могу сказать, что, когда мы будем проектировать сервис ленивой отправки, нам там тоже придётся сохранять то, что нам передают за неимением лучшего, т. е. ProductAvailability. Как видно из вышесказанного, мы нигде не сохраняем сам товар. Т. е. вместо того, чтобы иметь god object, товар с флагами отключён, доступен для доставки и ещё бог весть какими, как у нас могло бы получиться, если бы не использовали TDD, у нас в каждом сервисе есть своя коллекция своих сущностей, которая выполняет только одну работу. И это получилось, что называется, «само» — мы просто задавали один вопрос: «Чего я хочу от …» И это второй пример того, как, используя TDD, мы получаем более правильную архитектуру.
Тесты и имплементация получаются совсем простыми:
@SpringBootTest
@RunWith(SpringRunner.class)
public class ManualExclusionServiceTest {
@Autowired
private ManualExclusionService service;
@Autowired
private ManualExclusionRepository manualExclusionRepository;
@Before
public void clearDb() {
manualExclusionRepository.deleteAll();
}
@Test
public void disableItem() {
Long productId = 100L;
service.disableProduct(productId);
assertThat(service.isProductEnabled(productId), is(false));
}
@Test
public void returnEnabledIfProductWasNotDisabled() {
assertThat(service.isProductEnabled(100L), is(true));
assertThat(service.isProductEnabled(200L), is(true));
}
}
@Service
@AllArgsConstructor
public class ManualExclusionService {
private final ManualExclusionRepository manualExclusionRepository;
public boolean isProductEnabled(Long productId) {
return !manualExclusionRepository.exists(productId);
}
public void disableProduct(long productId) {
manualExclusionRepository.save(new ManualExclusion(productId));
}
}
Итак, мы добрались до последнего сервиса, который будет следить за тем, чтобы система DATA не была заспамлена одинаковыми сообщениями.
Напомню, что в него уже передаётся результат работы сервиса бизнес-логики, т. е. объект ProductAvailability, в котором всего два поля: productId и isAvailable.
По старой доброй традиции начинаем думать о том, чего мы хотим от этого сервиса:
- Отправка нотификации в первый раз в любом случае.
- Отправка нотификации, если доступность товара изменилась.
- Ничего не отправляем, если нет.
- Если отправка в стороннюю систему закончилась исключением, то в базу данных отправленных нотификаций нотификация, вызвавшая исключение, попасть не должна.
- Также при эксепшене со стороны DATA сервису необходимо выкинуть свой DataCommunicationException.
Здесь всё относительно просто, но хотелось бы отметить один момент:
Нам необходима информация о том, что мы отправляли раньше, а значит, у нас будет репозиторий, в который мы будем сохранять прошлые расчёты по доступности товаров.
Объект ProductAvailability для сохранения не подходит, т. к. как минимум там нет идентификатора, а значит, логично создать ещё один. Тут главное — не психануть и не добавить этот идентификатор вкупе с @Document (в качестве базы будем использовать MongoDb) и индексами в сам ProductAvailability.
Нужно понимать, что объект ProductAvailability со всеми немногочисленными полями создавался на этапе проектирования классов, которые находятся по иерархии вызовов выше, чем тот, который мы сейчас проектируем. Эти классы ничего не должны знать о специфичных для базы данных полях, т. к. при проектировании этой информации не потребовалось.
Но это всё разговоры.
Интересно то, что благодаря тому, что мы уже написали кучу тестов с тем ProductAvailability, который передаём в сервис сейчас, добавление в него новых полей будет означать, что эти тесты тоже будет нео