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

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

    image

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

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

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

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

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

    Что такое 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, который передаём в сервис сейчас, добавление в него новых полей будет означать, что эти тесты тоже будет необходимо рефакторить, что может потребовать некоторых усилий. А это значит, что желающих сделать из 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());
        }
    
    }


    Заключение



    Похожее приложение надо было написать на практике. Причём получилось так, что сначала оно было написано без 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 и ваш код всё ещё не очень, то дело, возможно, не в методологии. Но мне помогло.

    Леруа Мерлен
    Ритейлер — технологическая компания платформа.

    Комментарии 27

      +4
      «Написав одно и то же приложение, используя разные техники, я могу оценить их различия. На своей практике я увидел, как TDD помогает выстроить архитектуру, как мне кажется, более правильно. „
      А не приходило в голову, что с ТДД ты выстроил архитектуру более правильно, потому что ты уже выстроил ее один раз до того? :)
        –2
        Хм…
        Новый способ разработки: разработка через повторную разработку.
        ДеньСуркаДрайвенДевелопмент? =)
          +3
          Временная разница между несколькими итерациями была полгода. Я уже не особо помнил, что было в первый раз, так как погрузился в другие задачи. Также, пока я не написал приложение второй раз, довольно тяжело было оценить качество первой итерации. После первого написания я считал, что архитектура, в общем-то, не так уж и плоха.
            +3
            Давайте проведем еще один эксперимент :) Напишите какой-нибудь сервис, используя ТДД, а потом через полгода еще раз его же, но уже без ТДД. Если второй раз получится хуже, будем считать исходный тезис доказанным :)
              –3

              Так себе аналогия. Если сначала без, а потом с ТДД, тогда еще можно притянуть.

                +1
                Почитайте внимательно, вы потеряли контекст
          0
          Мои пять копеек на счет TDD.

          1) TDD никак не влияет на архитектуру, ее определяет лишь полнота требований к продукту.
          2) TDD замедляет разработку. Можно придумать сотни разных сценариев тестирования на одну небольшую функциональность. Причем процентов 80 будет абсолютно бесполезными.
          3) Самое главное. TDD создает иллюзию хорошего кода. Это как ЕГЭ, главное тесты пройти.

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

          Нет, это не так. Совсем не так. Скажите, если вы сначала вызовете ToList(), а потом отфильтруете, тест упадет? Нет, и вряд ли для тестов вы инжектите миллион записей, чтобы оценить реальное падение производительности. Конечно, существует еще и код-ревью, скажете вы. А не проще ли найти людей, которые умеют работать, чем неумех покрывать миллионом тест-кейсов и часами код-ревью?

          Почему при таком количестве фреймворков, отличных инструментов, скрамов и код-ревью качество кода не растет год от года? Сложность растет? Что-то я думаю COM+ в 2000 году писать было куда сложнее, чем очередной REST API в 2019. Может быть, просто начнем думать над тем кодом, что пишем, а не надеяться на 50 проверок?
            +2
            «1) TDD никак не влияет на архитектуру, ее определяет лишь полнота требований к продукту.»

            Согласен, однако, если мы говорим о продукте, одни и те же бизнес-требования можно реализовать как монолитом так и микросервисами. Плюс, если мы говорим об одном сервисе, то требования к нему не расскажут Вам о разделении кода по слоям, принципам single responsibility и т.д. — с точки зрения требований абсолютно не важно каким образом Вы их реализовали.

            «3) Самое главное. TDD создает иллюзию хорошего кода. Это как ЕГЭ, главное тесты пройти.»

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

            «TDD пытается поставить с ног на голову и говорит, что именно проверка на ошибки и есть способ написания кода.»

            В этой статье, я бы хотел предложить как способ написания кода первоначальный вопрос: «Что я от этого кода, собственно говоря, хочу?». Я не использую термин «проверки на ошибки». По моему мнению, покрытие кода тестами — это всего лишь дополнительная плюшка сверху.

            «Может быть, просто начнем думать над тем кодом, что пишем, а не надеяться на 50 проверок?»

            Вы абсолютно правы, я именно к этому и призываю). Абсолютно бесполезно писать тесты ради тестов.
              0
              1) Плюс, если мы говорим об одном сервисе, то требования к нему не расскажут Вам о разделении кода по слоям, принципам single responsibility

              Не согласен. Требования они разные бывают. Требования к расширяемости, например, тоже требование. И разделение кода по пяти слоям и создание 100500 фабрик это не всегда нужные вещи. Иногда и монолит вполне себе нормально.

              2) Вопрос «Что я от этого кода, собственно говоря, хочу?»

              Этот вопрос и есть не про TDD. Этот вопрос про бизнес-требования к продукту. Они просто должны быть описаны в ТЗ, и разработчик сможет это реализовать.

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

              У вас как-то юнит-тесты с перфоманс-тестами смешались. :)

                0
                >Почему при таком количестве фреймворков, отличных инструментов, скрамов и код-ревью качество кода не растет год от года?
                1) неумение выбросить прототип
                2) аутсорс без контроля
                3) отстуствие адекватных разработчиков, которые будут поддерживать код качественным

                >Конечно, существует еще и код-ревью, скажете вы. А не проще ли найти людей, которые умеют работать, чем неумех покрывать миллионом тест-кейсов и часами код-ревью?
                Ага, программирование это же просто по кнопкам тыкать
                  0
                  А не проще ли найти людей, которые умеют работать, чем неумех покрывать миллионом тест-кейсов и часами код-ревью?

                  А сколько такие люди запросят денег и нервов за свои умения и работу, в сравнении с теми что покрыты тестами и ревью?

                    –3
                    В идеальном мире тесты нужны, так как изначально это не то что сейчас под ними подразумевается.
                    Изначально тесты это просто автоматизация рутины, например пишешь авторизацию и чтобы каждый раз вручную в браузере не ф5ячить и вбивать, программист пишет код который делает это за него. Так покрытие было например 25-35% и на основе цифр уже возник культ, а давайте всё вообще покроем, и чем больше тем лучше.
                    Разделение писать до или после бредовое изначально, зачем вообще возводить в абсолют и тем более давать этому отдельный термин «ттд», просто пиши как удобно, хочешь до хочешь после, а лучше и так и так по ситуации.
                    По поводу замедления ну тут опять таки все просто, они ускоряют когда ты их пишешь для ускорения, а как только начинаешь писать чтобы было, начинает тормозить
                      0
                      1) TDD никак не влияет на архитектуру, ее определяет лишь полнота требований к продукту.

                      Таки влияет. ;)
                      Очевидно, это происходит при условии TDD + голова.
                      При наличии только TDD, или только головы, вероятность сделать хуже — выше.

                      2) TDD замедляет разработку. Можно придумать сотни разных сценариев тестирования на одну небольшую функциональность. Причем процентов 80 будет абсолютно бесполезными.

                      Зачем придумывать и реализовывать бесполезные сценарии тестирования? Кто и что заставляет делать бесполезное?
                      Создавайте ровно те тесты, которые очевидно полезны.

                        0
                        Зачем придумывать и реализовывать бесполезные сценарии тестирования? Кто и что заставляет делать бесполезное? Создавайте ровно те тесты, которые очевидно полезны.

                        Такова философия TDD:


                        1. Написать один минимальный тест, который код не будет проходить. И нет, нельзя написать сразу все тесты.


                        2. Написать минимальный кусок кода, после которого будут проходить все тесты. При этом нельзя думать на несколько шагов вперёд, нельзя заранее продумывать архитектуру, нельзя реализовывать функционал, выходящий за рамки написанных тестов.


                        3. Перейти к п.1, если требуемый функционал ещё не достигнут.


                        4. Все тесты проходят? Отлично. Вот только теперь можно заняться рефакторингом и, наконец, переписать код нормально. И именно рефакторингом — функционал кода меняться не должен! Хочется добавить функционал? Начинай с п.1.



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

                          0

                          Можно ссылку на источник где именно 1,2,3,4 а не 1,2,4,3?

                            0

                            Да, верно. Рефакторинг можно делать после каждого из тестов. Ну суть понятна: код поначалу пишется кое-как, а зачем тратится время на его переписывание.

                              0

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

                        0
                        1) TDD никак не влияет на архитектуру, ее определяет лишь полнота требований к продукту.

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


                        2) TDD замедляет разработку.

                        Верно. TDD действительно замедляет разработку. Но фишка TDD не в разработке, а в поддержке. Если вы пишете код по принципу "написать и забыть", то TDD вам действительно не нужен. Положительный эффект от TDD проявляется на длинных дистанциях.


                        Можно придумать сотни разных сценариев тестирования на одну небольшую функциональность. Причем процентов 80 будет абсолютно бесполезными.

                        Написание нормальных тестов — тоже искусство. А тесты ради 100% покрытия не имеют никакого смысла.


                        3) Самое главное. TDD создает иллюзию хорошего кода. Это как ЕГЭ, главное тесты пройти.

                        Это верно для любых тестов. Прохождение тестов совершенно не гарантирует правильность работы кода.


                        Тесты были придуманы для того, чтобы неидеальные программисты имели дополнительную проверку на ошибки. TDD пытается поставить с ног на голову и говорит, что именно проверка на ошибки и есть способ написания кода.

                        Нет. Тест — это не проверка на ошибки. Это фиксация поведения кода при определённых входных данных. Тесты помогают программисту не сломать код при рефакторинге и добавлении функционала — вот их основная задача.

                          0

                          TDD не нуждается в сотнях тестовых кейсов "а проверим как код себя поведёт в таких-то условиях". TDD про исправление "ошибок" особого вида 'not implemented yet". Если вы написали тест и он не упал — это ошибочный тест по TDD.

                            0

                            Или это значит, что вы реализовали функционал до написания теста, что тоже противоречит TDD.

                              0

                              Именно это я имел в виду — тест ошибочный.

                          0

                          Есть хоть одно исследование как TDD влияет на метрики?
                          Я вот нашел https://arxiv.org/ftp/arxiv/papers/1711/1711.05082.pdf


                          Судя по метрикам TDD архитектуру и дизайн кода УХУДШАЮТ, причем по вполне очевидной причине. Попытка покрыть тестами весь код приводит к дроблению классов на все более мелкие, что приводит к снижению согласованности и увеличению связности классов.


                          Но есть и другое исследование https://people.engr.ncsu.edu/gjin2/Classes/591/Spring2017/case-tdd-b.pdf


                          Там приводятся тоже вполне очевидные выводы:
                          TDD увеличивает время разработки на 15%-20%, но снижает плотность багов в 1,5-2 раза. Только непонятно плотность учитывает увеличенный объем кода от TDD или нет.


                          И еще одно исследование, большое https://www.nomachetejuggling.com/files/tdd_thesis.pdf
                          Почитайте сами.


                          Увы в моей практике возрастание сроков на 15%-20% и пропорциональное увеличение объема кода гарантированно ставит крест на TDD.

                            +1
                            Большое спасибо за ссылки на исследования! Я, к сожалению, не смог быстро ознакомится с исследованиями полностью, но внимательно прочитал Summary (Conclusions) каждого из них.

                            И, если честно, после прочитанного не могу разделить Вашего пессимизма.

                            В первом исследовании, действительно, пишут, что TDD не всегда улучшает high сohesion. Однако, автор уточняет, что утверждение верно, если TDD практикует программист с недостаточным количеством опыта.

                            Цитата
                            Here, the empirical data indicates that TDD does not always produce highly cohesive code as suggested in the literature. This is the case, at least, when the TDD users are inexperienced developers.

                            Во втором же, автор пишет в первом предложении Conclusions and Discussion, что TDD даёт плюшки без значимого снижения продуктивности

                            Цитата
                            Our experiences and distilled lessons learned, all point to the fact that TDD seems to be
                            applicable in various domains and can significantly reduce the defect density of developed
                            software without significant productivity reduction of the development team

                            Третье же исследование утверждает, что TDD в среднем на 21% улучшает качество кода.
                            В первом предложении Chapter 7 Conclusions.

                            Цитата
                            This study provided substantial evidence that Test-Driven Development is, indeed,
                            an effective tool for improving the quality of source code. Test-Driven Development
                            offered an overall improvement of 21% over code written using the test-last technique.
                            Considering the sheer volume of the codebases being analyzed, it’s reasonable to
                            conclude that the substantial difference is noteworthy.

                            И резюмируется следующее:
                            «With an improvement in metrics scores of 21%, Test-Driven Development appears to be extremely valuable as a Software Engineering practice. This work lends a great deal of additional validity to the assertion by Martin (2007) that not following the disciplines of Test-Driven Development would simply be unprofessional.»
                              0

                              Прочитал длинное последнее исследование и немного удивлен. Несмотря на подробный разбор метрик там говорится что повышение LCOM улучшает дизайн, хотя это неверно. Рост LCOM говорит об ухудшении. Это подрывает доверие к исследованию, надо перепроверить все выводы.
                              Да и само исследование выглядит ангажированным (кто-то очень хотел доказать что TDD это круто).


                              Однако, автор уточняет, что утверждение верно, если TDD практикует программист с недостаточным количеством опыта.

                              Когда я учился в универе у нас был небольшой курс по методологиям.
                              Тогда было модно XP, мы спросили у препода о его мнении насчет этой методологии.


                              Он рассказал историю, про бум методик обучения в Советском союзе. В 80-х годах поднялся, как сейчас модно говорить, хайп с методиками школьного обучения. Проводились симпозиумы (конференции) заслуженных преподавателей, где они делились опытом о своих успехах. Только у других успехи были скромнее.


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


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


                              Мне почти всю жизнь довелось работать с посредственными программистами или чуть выше среднего, поэтому от всех "магических" методик я не получил никакого профита. А специально обученных TDD я не видел. Да и курсов на эту тему не видел.

                            0
                            Приведенное в статье TDD не канонично.
                            Согласно Кенту Беку не нужно сразу писать исчерпывающие тесты.
                            Вначале пишется пустой тест на несуществующий класс, затем создаётся класс. Если тест проходит, то пишется пустой тест на несуществующий метод, затем в класс добавляется сам метод. Затем тест на метод дополняется примитивной логикой, после чего уже пишется реализация самого метода, ну и т.д. Параллельно это все сдабривается изрядной долей рефакторинга, уже написанных тестов, так и методов.
                            Понятно, что многие вещи в процессе упускаются, но не на столько.
                            Должно соблюдаться правило: красный тест -> зеленый тест -> рефакторинг, и только после этого новый тест.
                              0
                              Согласен, на каноничность я не претендую. В моей практике именно каноничный TDD не помог отказаться от психотерапевта не прижился. Тут я рассказываю про свой опыт того что помогло.

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое