Хабрахабр

Как сэкономить на психотерапевте используя test-driven development

У вас когда-нибудь было такое состояние?

image

Хочу показать вам, как TDD может улучшить качество кода на конкретном примере.
Потому что всё то, что я встречал при изучении вопроса, было довольно-таки теоретическим.
Так получилось, что мне довелось написать два практически идентичных приложения: одно писалось в классическом стиле, так как я ещё не знал тогда TDD, в второе — как раз с использованием TDD.

Ниже я покажу, где были самые большие различия.

Да, я понимал, что баги — это нормально, их пишут все, но ощущение неполноценности никуда не уходило. Лично мне это было важно, потому что каждый раз, когда кто-то находил баг в моём коде, я ловил увесистый минус на самооценку. И как это получилось — непонятно. Также, в процессе эволюции сервиса, я иногда понимал, что сам понаписал такого, что чешутся руки всё выкинуть и переписать заново. Хотя вроде каждый шаг изменения был логичный. Как-то всё было хорошо в начале, но вот пару фич и через некоторое время уже на архитектуру без слёз не взглянешь. Ощущение того, что мне не нравится продукт собственного труда, плавно перетекало в ощущение, что программист из меня, простите, как из говна пуля.

И тогда я решил, что либо научусь писать нормально, либо пора менять профессию. Оказалось, я не один такой и схожие ощущения возникают у многих моих коллег. Я попробовал test-driven development в попытке что-то изменить в своём подходе к программированию.

И подходит не всегда и не всем.
Забегая вперёд, по результату нескольких проектов, могу сказать, что TDD даёт более чистую архитектуру, но при этом замедляет разработку.

image

Вики-статья тут.
Классический подход — сначала пишем приложение, потом покрываем его тестами. 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 этого сервиса, эмулируя его поведение в тех или иных случаях. Этим посредником является сервис. Можно сделать это по-разному, но проще всего кидать кастомный эксепшн. Итак, сервис должен как-то сообщить контроллеру о том, что система недоступна. На это поведение контроллера мы и будем писать тест.

Тест на ошибку коммуникации с сторонней системой DATA


@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)); } }

Exception Handler


@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<ErrorResponse.Message> 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( ) — бесценно.

Дальше реализуем сервис.

Чего мы хотим от сервиса?

е.: Хотим, чтобы он занимался бизнес-логикой, т.

  1. Умел отключать товары, а также уведомлял о:
  2. Доступности, если товар не отключён, есть в наличии, цвет товара — жёлтый, и DostavchenKO готов совершить доставку.
  3. Недоступности, если товара в наличии нет независимо ни от чего.
  4. Недоступности, если товар — синего цвета.
  5. Недоступности, если DostavchenKO отказывается его везти.
  6. Недоступности, если товар отключён вручную.
  7. Далее хотим, чтобы сервис выбрасывал эксепшн, если какая-то из систем недоступна.
  8. А также, чтобы не заспамить DATA, нужно организовать ленивую отправку сообщений, а именно:
  9. Если мы раньше по товару отправляли доступно и сейчас рассчитали, что доступно, то ничего не отправляем.
  10. А если раньше недоступно, а теперь доступно — отправляем.
  11. А ещё нужно это куда-то записывать…

СТОП!

Вам не кажется, что наш сервис начинает слишком многим заниматься?

Это не 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)

А теперь логику отключения мы решили вынести в отдельный сервис.

От этого сервиса на данном этапе мы хотим следующее:

  • Возможность отключать товары.
  • Хотим, чтобы он возвращал, что товар отключён, если он был отключён ранее.
  • Хотим, чтобы он возвращал, что товар доступен, если отключения ранее не было.

Глядя на хотелки, которые являются прямым следствием контракта между сервисом бизнес-логики и проектируемым, хотелось бы заметить следующее:

  1. Во-первых, сразу видно, что у приложения могут быть проблемы, если кто-то захочет отключённый товар включить обратно, т. к. на данный момент этот сервис этого делать попросту не умеет. А это значит, что, возможно, стоит обсудить этот вопрос с аналитиком, который ставил задачу на разработку. Я понимаю, что в данном случае этот вопрос должен был возникнуть сразу после первого прочтения ТЗ, но мы проектируем довольно простую систему, в более масштабных проектах это могло бы быть не так очевидно. Тем более что мы не знали, что у нас будет сущность, отвечающая только за функционал отключения товаров: напомню, что у нас она родилась только в процессе разработки.
  2. Во-вторых, сигнатура методов сервиса содержит только идентификатор товара. И сохранять в коллекцию отключённых товаров мы будем только идентификатор — как минимум потому, что у нас на вход просто больше ничего нет. Забегая вперёд, могу сказать, что, когда мы будем проектировать сервис ленивой отправки, нам там тоже придётся сохранять то, что нам передают за неимением лучшего, т. е. 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 god object будет гораздо меньше, чем если бы писали имплементацию сразу: там, напротив, добавить поле в уже существующий объект было бы проще, чем создавать ещё один класс. Интересно то, что благодаря тому, что мы уже написали кучу тестов с тем ProductAvailability, который передаём в сервис сейчас, добавление в него новых полей будет означать, что эти тесты тоже будет необходимо рефакторить, что может потребовать некоторых усилий.

Тесты

@RunWith(SpringRunner.class)
@SpringBootTest
public class LazyAvailabilityNotifierTest { @Autowired private LazyAvailabilityNotifier lazyAvailabilityNotifier; @MockBean @Qualifier("dataClient") private AvailabilityNotifier availabilityNotifier; @Autowired private AvailabilityRepository availabilityRepository; @Before public void clearDb() { availabilityRepository.deleteAll(); } @Test public void notifyIfFirstTime() { sendNotificationAndVerifyDataBase(new ProductAvailability(1L, false)); } @Test public void notifyIfAvailabilityChanged() { final ProductAvailability oldProductAvailability = new ProductAvailability(1L, false); sendNotificationAndVerifyDataBase(oldProductAvailability); final ProductAvailability newProductAvailability = new ProductAvailability(1L, true); sendNotificationAndVerifyDataBase(newProductAvailability); } @Test public void doNotNotifyIfAvailabilityDoesNotChanged() { final ProductAvailability productAvailability = new ProductAvailability(1L, false); sendNotificationAndVerifyDataBase(productAvailability); sendNotificationAndVerifyDataBase(productAvailability); sendNotificationAndVerifyDataBase(productAvailability); sendNotificationAndVerifyDataBase(productAvailability); verify(availabilityNotifier, only()).notify(eq(productAvailability)); } @Test public void doNotSaveIfSentWithException() { doThrow(new RuntimeException()).when(availabilityNotifier).notify(anyObject()); boolean exceptionThrown = false; try { availabilityNotifier.notify(new ProductAvailability(1L, false)); } catch (RuntimeException exception) { exceptionThrown = true; } assertTrue("Exception was not thrown", exceptionThrown); assertThat(availabilityRepository.findAll(), hasSize(0)); } @Test(expected = DataCommunicationException.class) public void wrapDataException() { doThrow(new RestClientException("Something wrong")).when(availabilityNotifier).notify(anyObject()); lazyAvailabilityNotifier.notify(new ProductAvailability(1L, false)); } private void sendNotificationAndVerifyDataBase(ProductAvailability productAvailability) { lazyAvailabilityNotifier.notify(productAvailability); verify(availabilityNotifier).notify(eq(productAvailability)); assertThat(availabilityRepository.findAll(), hasSize(1)); assertThat(availabilityRepository.findAll().get(0), hasProperty("productId", is(productAvailability.getProductId()))); assertThat(availabilityRepository.findAll().get(0), hasProperty("availability", is(productAvailability.isAvailable()))); }
}

Имплементация

@Component
@AllArgsConstructor
@Slf4j
public class LazyAvailabilityNotifier implements AvailabilityNotifier { private final AvailabilityRepository availabilityRepository; private final AvailabilityNotifier availabilityNotifier; @Override public void notify(ProductAvailability productAvailability) { final AvailabilityPersistenceObject persistedProductAvailability = availabilityRepository .findByProductId(productAvailability.getProductId()); if (persistedProductAvailability == null) { notifyWith(productAvailability); availabilityRepository.save(createObjectFromProductAvailability(productAvailability)); } else if (persistedProductAvailability.isAvailability() != productAvailability.isAvailable()) { notifyWith(productAvailability); persistedProductAvailability.setAvailability(productAvailability.isAvailable()); availabilityRepository.save(persistedProductAvailability); } } private void notifyWith(ProductAvailability productAvailability) { try { availabilityNotifier.notify(productAvailability); } catch (RestClientException exception) { log.error("Couldn't notify", exception); throw new DataCommunicationException(); } } private AvailabilityPersistenceObject createObjectFromProductAvailability(ProductAvailability productAvailability) { return new AvailabilityPersistenceObject(productAvailability.getProductId(), productAvailability.isAvailable()); } }

Похожее приложение надо было написать на практике. Причём получилось так, что сначала оно было написано без TDD, потом бизнес сказал, что оно не нужно, а через полгода изменились требования, и было решено переписать его заново с нуля (благо архитектура микросервисная, и что-то выкидывать было не так страшно).

На своей практике я увидел, как TDD помогает выстроить архитектуру, как мне кажется, более правильно. Написав одно и то же приложение, используя разные техники, я могу оценить их различия.

Также, пока нет имплементации, мы действительно можем «заказать» в вызываемых объектах именно тот контракт, который необходим объекту, который их вызывает, без соблазна что-то где-то быстро добавить и получить сущность, которая будет заниматься многими задачами одновременно. Я могу предположить, что причиной этого является не само создание тестов перед имплементацией, а то, что, написав тесты вначале, мы сначала думаем о том, чем будет заниматься создаваемый класс.

Это может быть связано с тем, что среднестатистический код, написанный по TDD, наверное, всё-таки лучше покрыт тестами, но именно после того, как я начал писать по TDD, у меня сократилось количество вносимых правок в код после того, как я отдал его на тестирование практически до нуля. Помимо этого, одним из главных преимуществ TDD для себя я могу выделить то, что я действительно стал более уверенным в продукте, который произвожу.

И в целом появилось ощущение, что как разработчик я стал лучше.

Для тех, кто хочет разобраться, как это всё создавалось по шагам, рекомендую обратить внимание на историю коммитов, после анализа которой, я надеюсь, процесс создания типового приложения по TDD будет более понятен. Код приложения можно найти здесь.

Вот очень полезное видео, которое я крайне рекомендую к просмотру всем, кто хочет окунуться в мир TDD.

Это необходимо, чтобы проверять, каким образом приложение будет парсить json на POJO-объекты. В коде приложения многократно используется форматированная строка, как json. Если вы используете IDEA, то быстро и без боли необходимого форматирования можно добиться, используя инъекции языка JSON.

Это долго в разработке. Программируя в стандартной парадигме, мой коллега мог себе позволить выложить сервис на проверку тестировщикам вообще без тестов, дописывая их по ходу. Это было очень быстро. По TDD такого не выйдет. Если у вас жёсткие сроки, то ваши менеджеры будут недовольны. Тут трейд офф между сделать хорошо сразу, но долго и не очень хорошо, но быстро. Я для себя выбираю первое, т. к. второе в результате оказывается дольше. И с бОльшими нервами.

Может оказаться так, что вы работаете над тестом класса, который в результате удалите. По моим ощущениям, TDD не подойдёт, если нужно произвести большой рефакторинг: потому что в отличие от приложения, создаваемого с нуля, тут не очевидно, с какой стороны подступиться и что начать делать первым.

Это история про понятный читаемый код, что может создать проблемы с производительностью. TDD — это не серебряная пуля. А потом оказывается, что, чтобы выполнять свою работу, им нужно каждому сходить в базу. Например, вы создали N классов, которые как по Фаулеру занимаются каждый своим делом. Вместо того, чтобы сделать, например, 1 god object и сходить 1 раз. И у вас будет N запросов в базу. Если вы боретесь за миллисекунды, то используя TDD нужно это учитывать: читаемый код — не всегда самый быстрый.

Больше всего боли — на первом этапе. И, наконец, на эту методологию довольно тяжело переходить — нужно научить себя по-другому мыслить. Первый интеграционный тест я писал 1,5 дня.

Если вы используете TDD и ваш код всё ещё не очень, то дело, возможно, не в методологии. Ну и последнее. Но мне помогло.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть