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

в 7:41, , рубрики: java, Leroy Merlin, Microservices, spring, spring boot, tdd, Блог компании Леруа Мерлен, Тестирование веб-сервисов

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

image

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

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

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

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

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

Что такое TDD ещё раз

image

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

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

И, если бы я читал это впервые, я бы тоже ничего не понял.
Слишком много абстрактных слов: давайте разбираться на примере.
Будем писать реальное спринговое приложение на Java, будем писать его по TDD, и я постараюсь показать свой мыслительный процесс в процессе разработки и в конце сделать выводы — имеет ли смысл тратить время на TDD или нет.

Практическая задача

Допустим, нам настолько повезло, что у нас есть ТЗ того, что нам нужно разработать. Обычно аналитики с ним не заморачиваются, и оно выглядит примерно следующим образом:

Необходимо разработать микросервис, который будет рассчитывать возможность продажи товара с последующей доставкой клиенту на дом. Информация об этой возможности должна быть отправлена в стороннюю систему DATA

Бизнес-логика следующая: товар доступен для продажи с доставкой, если:

  • Товар есть в наличии
  • Подрядчик (допустим, компания DostavchenKO) имеет возможность его отвезти клиенту
  • Цвет товара — не синий (не любим синий)

Об изменении количества товара на полке магазина наш микросервис будут уведомлять через http-запрос.

Это уведомление является триггером к расчёту доступности.

Плюс к этому, чтобы жизнь мёдом не казалась:

  • У пользователя должна быть возможность отключать в ручном режиме некоторые товары.
  • Чтобы не заспамить DATA необходимо отправлять только данные доступности по тем товарам, которые изменились.

Читаем пару раз ТЗ — и в путь.

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

Интеграционный тест

В TDD один из самых главных вопросов который придётся задавать ко всему тому что вы пишете, — это: «Чего я хочу от… ?»

И первый вопрос мы задаём как раз ко всему приложению.
Итак, вопрос:

Что я хочу от своего микросервиса?

Ответ:

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

Т. е., мы предполагаем, что все входные данные валидного формата, сторонние системы отвечают в штатном режиме и ранее по товару информации не было.

Итак, я хочу, чтобы:

  • Пришло событие, что на полке товара нет. Уведомляем, что доставка недоступна.
  • Пришло событие, что жёлтый товар — в наличии, DostavchenKO готов его отвезти. Уведомляем о доступности товара.
  • Пришло два подряд сообщения — оба с положительным количеством товара в магазине. Отправили только одно сообщение.
  • Пришло два сообщения: в первом товар в магазине есть, во втором — уже нет. Отправляем два сообщения: сначала — доступен, потом — нет.
  • Я могу отключить товар вручную, и по нему больше не отсылаются уведомления.

Тут главное — вовремя остановиться: как я уже писал, вариантов слишком много, и все их тут описывать не имеет смысла — только самые основные. В дальнейшем, когда мы будем писать тесты на бизнес-логику, их совокупность, скорее всего, покроет всё то, что мы тут придумаем. Основная мотивация тут — это быть уверенными, что если эти тесты проходят, значит, приложение работает так, как нам надо.

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

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

В процессе ответа на вопрос уже можно начинать писать код в сгенерированном 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": falsen" +
                        "}");

        performQuantityUpdateRequest(
                // language=JSON
                "{n" +
                        "  "productId": 111,n" +
                        "  "color" : "red",  n" +
                        "  "productQuantity": 0n" +
                        "}");

        verify(1, postRequestedFor(urlEqualTo("/notify")));
    }

    @Test
    public void notifyAvailableYellowProductIfPositiveQuantityAndDostavchenkoApproved() throws Exception {
        stubDostavchenko("112");

        stubNotification(
                // language=JSON
                "{n" +
                        "  "productId": 112,n" +
                        "  "available": truen" +
                        "}");

        performQuantityUpdateRequest(
                // language=JSON
                "{n" +
                        "  "productId": 112,n" +
                        "  "color" : "Yellow",  n" +
                        "  "productQuantity": 10n" +
                        "}");

        verify(1, postRequestedFor(urlEqualTo("/notify")));
    }

    @Test
    public void notifyOnceOnSeveralEqualProductMessages() throws Exception {
        stubDostavchenko("113");

        stubNotification(
                // language=JSON
                "{n" +
                        "  "productId": 113,n" +
                        "  "available": truen" +
                        "}");

        for (int i = 0; i < 5; i++) {
            performQuantityUpdateRequest(
                    // language=JSON
                    "{n" +
                            "  "productId": 113,n" +
                            "  "color" : "Yellow",  n" +
                            "  "productQuantity": 10n" +
                            "}");
        }

        verify(1, postRequestedFor(urlEqualTo("/notify")));
    }

    @Test
    public void notifyFirstAvailableThenNotIfProductQuantityMovedFromPositiveToZero() throws Exception {
        stubDostavchenko("114");

        stubNotification(
                // language=JSON
                "{n" +
                        "  "productId": 114,n" +
                        "  "available": truen" +
                        "}");

        performQuantityUpdateRequest(
                // language=JSON
                "{n" +
                        "  "productId": 114,n" +
                        "  "color" : "Yellow",n" +
                        "  "productQuantity": 10n" +
                        "}");

        stubNotification(
                // language=JSON
                "{n" +
                        "  "productId": 114,n" +
                        "  "available": falsen" +
                        "}");

        performQuantityUpdateRequest(
                // language=JSON
                "{n" +
                        "  "productId": 114,n" +
                        "  "color" : "Yellow",n" +
                        "  "productQuantity": 0n" +
                        "}");

        verify(2, postRequestedFor(urlEqualTo("/notify")));
    }

    @Test
    public void noNotificationOnDisabledProduct() throws Exception {
        stubNotification(
                // language=JSON
                "{n" +
                        "  "productId": 115,n" +
                        "  "available": falsen" +
                        "}");

        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": 10n" +
                        "}"
        ).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": 0n" +
                        "}"
        ).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": -10n" +
                        "}"
        ).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": 10n" +
                        "}"
        ).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": 10n" +
                        "}"
        ).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": 10n" +
                        "}"
        ).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": 10n" +
                        "}"
        ).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": -10n" +
                        "}"
        ).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( ) — бесценно. Для всего остального… ну вы знаете.

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

Сервис

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

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

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

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

СТОП!

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

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

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

Тесты

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

}

Заключение

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

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

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

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

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

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

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

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

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

В чём минусы подхода?

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

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

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

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

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

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

Автор: Павел Юркин

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js