Алексей@jdev
Эксперт по эффективной разработке, Kotlin техлид
Информация
- В рейтинге
- 4 661-й
- Откуда
- Кольцово, Новосибирская обл., Россия
- Дата рождения
- Зарегистрирован
- Активность
Специализация
Технический директор, Архитектор программного обеспечения
Ведущий
От 500 000 ₽
Функциональное программирование
Объектно-ориентированное проектирование
Проектирование информационных систем
TDD/BDD
Kotlin
PostgreSQL
Java Spring Framework
Linux
Git
Docker
Баги как раз прокрались не в основную функциональность, а в граничные случаи.
В штатном режиме у нас обработка аутбокса асинхронно работает на том же объекте, который был сохранён в БД и десериализацию не делает. Ломался кейс когда, моментальная обработка отвалилась и пришлось репроцессить события. У нас таких было 4 штуки из миллиона за тот период, что баг был в проде. Ну и, естественно, теперь и этот кейс покрыт тестом
Тесты у нас симулируют работу клиентов, которые работают по контракту, а тут клиенты контракт нарушали и мы и клиенты это пропустили из-за того, что Джексон прятал проблему. Т.е. по хорошему в этих случаях изначально надо было 400-ку возвращать. Но нам пришлось вернуть этот баг, так как клиенты нам не подконтрольны и пофиксить их невозможно. Ну и, соответственно, эти конкретные места стали частью контракта и мы их допокрыли тестами.
Про TLS ещё добавлю, что этот способ авторизации используется только в проде. Ну и его мы чёт решили всё ещё не покрывать тестами - не стоит оно того.
Я тоже недавно перевёл на Boot 4 + Java 25 свои сервисы - 3 коммерческих и 2 опенсорсных, суммарно на 80К строк Котлин кода и 20 различных модулей Spring и библиотек.
В процессе собирал факты чтобы написать статью на хабр, но не осилил, поэтому решил хотя бы комментах поделиться своими наработками, чтобы не пропадали:)
Самое неприятное - несмотря на 95% покрытие веток интеграционными тестами у меня всё равно пробралось три бага в прод.
Первый баг - у меня пейлоад в transactional outbox-е лежал в jsonb и парсился Jackson-ом (у меня Spring Data Jdbc и поддержки json-а и из коробки нет). И у одного из типов сообщений ид генерировался дефолтным параметром в конструкторе базового класса:
И Jackson 3 начал при десереализации отдавать предпочтение дефолтным значениям. Соответственно эвенты загружались каждый раз с новыми ИДами и после обработки эвенты не удалялись по новому иду.
Второй баг - у меня часть клиентов в некоторых случаях вообще не присылала значения для обязательных примитивных полей. В Jackson 2 на этот случай был костыль, который проставлял в них 0/false. А Jackson 3 начал в этом случае бросать исключение и эти клиенты начали получать 400-ки
Третий баг - одна из интеграций авторизуется по клиентскому сертификату, а сервер поддерживает только устаревшие TLS cipher suites TLS_RSA_* без PFS. В Java 25 их отключили по умолчанию. Соответственно интеграция внезапно превратилась в тыкву, благо чинится просто удалением TLS_RSA_* из jdk.tls.disabledAlgorithms.
Помимо этого (а так же проблем Kotlin/JSpecify, описанных в статье, и проблем с модуляризацией и Jackson 3, о которых ниже) я ещё словил и решил гору проблем на этапе миграции:
Spring Data JDBC 4
Самое неприятное: поменялся дефолт аудита - LastModifiedAt начал проставляться при вставке
Заметно поменялось АПИ бинов реализации либы - если что-то конфигурируете руками, то скорее всего придётся рихтовать.
Spring Security/Web
Поменялся контракт DaoAuthenticationProvider - теперь ему нужен UserDetailsService,
multipart+OAuth2: при превышении допустимого размера начал падать с 500, вместо 413. Тут проблема именно в связке с OAuth2 - в Boot 4 он начал раньше триггерить вычитывание тела запроса;
spring-retry поменялся на spring-resilience. При миграции важно проверить семантику attempts/retries, это не чистый rename.
Testcontainers: во всех артефактах добавился префикс testcontainers - org.testcontainers:junit-jupiter -> org.testcontainers:testcontainers-junit-jupiter, org.testcontainers:postgresql -> org.testcontainers:testcontainers-postgresql и т.д.
Selenium: класс org.testcontainers.containers.BrowserWebDriverContainer ->
org.testcontainers.selenium.BrowserWebDriverContainer.
WireMock ломается из-за несовместимости с Jetty 12.1.6, которую тянет Бут 4
RestAssured 5 начал взрываться на NullPointerException внутри кишёк Groovy. Полечилось обновлением до 6.0.0.
Bucket4j: bucket4j-spring-boot-starter:0.12.8 не совместим с Бутом 4. Полечилось обновлением до 0.14.0.
Если есть вопросы по этому списку - приходите в личку, не стесняйтесь:)
Ну и наконец часть про проблемы с модуляризацией и Jackson 3 я осилил собрать в пост у себя в блоге.
Нет, дихотомия не ложная. Судя по
- скоуп разный:) В вашем случае "вся команда" - это лично вы в своей выделенной делянке, в которую никто кроме вас не лезет и из которой вы не вылазите и для которой работает "тесты зелёные - можно в прод":) Так тоже работает, если получается отгородиться от хаоса снаружи.
Но если в проекте нет владения кодом и любой разработчик может править любой код, то либо все пишут тесты, либо получить "Это когда нажал кнопку и через короткое время, увидев зеленые тесты, точно знаешь, что все работает" не получится.
У людей из банковской тусовки часто встречаю "пром" - видимо от "Промышленная эксплуатация". В общем "устоянность" термина сильно зависит от тусовки:)
Не всё так просто. Начать писать - необходимое, конечно, но недостаточное условие.
Чтобы получить
важно, чтобы вся команда писала тесты. И писала качественные тесты, а не отписки для лида, лишь бы отвалил.
Абсолютная инкапсуляция чем-то принципиально отличается от Dependency Inversion Principle и Чистой архитектуры, о которых (в моём инфопузере) каждая собака знает? Не увидел этого в статье
Я долго пытался есть этот кактус на Kotlin + Spring потому как теоретически мне идея нравится, но в итоге отказался.
Потому что:
прокидывать ошибки по стеку руками - через чур гемморойно, имхо
Result плохо интегрируется с библиотеками. В частности Spring не откатит транзакцию, а корутины не закенсалят скоуп при возврате резалта. Транзакции точно, а коррутины скорее всего можно обработать напильником, но, опять же, через чур гемморойно, имхо
В Котлине нет нормальной возможности в верхнеуровневом методе вернуть пару разных ошибок из вызываемых методов и в итоге ошибка очень быстро превращается в Exception (корневой тип ошибок), чья информативность стремится к 0. Хотя это может решиться благодаря Rich errors (доклад с их представлением).
В итоге я разделил ошибки на восстановимые (которых в моих проектах очень мало) и не восстановимые. Восстановимые возвращаю резалтом и тут же обрабатываю, а не восстановимые бросаю исключениями которые улетают до контроллера (спецефичные для метода) или миддлваря (универсальные в духе resource-not-found). Всё это у меня подробно описано тут
А, и собственно HTTP- запрос я заворачиваю в хелпер, поэтому если меняется только синтаксис, а не семантика (например параметр из пути в запрос уезжает), то это тест-кейсы не затрагивает.
Это, наверное, зависит от контекста.
Я обычно перед тем как писать какой-то код, проектирую и согласовываю АПИ с фронтом. И тесты пишу через это АПИ. А если там будут существенные изменения, то модификация тестов - будет меньшей из проблем:)
Я работаю через ТДД и там где есть возможность пытаюсь рефлексировать стоит ли оно того.
Пока набралось два кейса:
Сравнение трудозатрат на первоначальную разработку и её полный реинжиниринг
Субъективное ощущение продакта на разработку большой фичи (человеко-год) по ТДД в сравнении с другими фичами в том же проекте. Тут ретру пока не публиковал
И в обоих кейсах разработка по ТДД была не медленнее разработки без тестов и команда допуска в 2-4 раза меньше багов.
Ну то есть с ТДД можно шипать фичи с той же скоростью, но при этом спокойно спать и в целом меньше стрессовать:)
Спасибо, хорошая и нужная статья.
Только я бы добавил про
У меня последние лет 5 в куче разных проектов (среди прочих - автоматизация выделенного бизнес-процесса крупного ритейлера, медтех, автоматизация работы юридического департамента) 90% "причины по которой я пишу ПО" - положить данные в РСУБД. И поэтому 90% кода тестировать в изоляции от внешнего мира особого смысла нет. В общем, на мой взгляд, изоляция внешних зависимостей - не универсальное правило и нужна только в сложных предметных областях (я таких не видел, но предполагаю, что к ним относится банкинг, страхование, логистика, e-commerce).
А в остальных случаях эффективнее по соотношению трудозатраты/(скорость + качество разработки), писать тесты как минимум с реальным PostgreSQL на RAM-диске.
И если заморочиться, такие тесты будут не критично медленнее - до 10 секунд на запуск 1 теста, и по 50мс в среднем на тест при запуске всего набора.
В остальном - всё плюсую.
Спасибо:)
List<Any?>- является подтипомAny."оператор" + - это на самом деле просто синтаксический сахар для создания копии мапы и добавления в неё элемента (копирование вместо добавления в имеющуюся - из-за ФП головного мозга)
Соответственно:
Метод
groupByвозвращаетTableakaList<Map<String, Any>>Лямбда, которая передаётся в
rowGroups.mapвозвращаетMap<String, List<Any?>>А сам метод map возвращает
List<Map<String, List<Any?>>>И при возврате из метода
groupByвыполняется upcast (приведение к супертипу)List<Map<String, List<Any?>>>->List<Map<String, Any>>@razon
И как вы находите границы микросервисов и ограниченных контекстов?
Условно ко мне пришёл продакт, говорит хочу фичу логистики, она должна делать то-то и то-то - в результате какого процесса у меня появятся артефакты/термины "ПВЗ Customer", "ПВЗ Маппер" и т.д. и "Ограниченный контекст ПВЗ", "Ограниченный контекст Геоданные" и т.д.
@razon
Поясните, пожалуйста - куда и на каком уровне абстракции фронт встраивается в вашей модели?
В моей практике фронтовые приложения/компоненты зачастую работают со множеством (всеми) слабосвязанных между собой компонентов бэка и, кажется, сломают всю стройную картину - если фронт засунуть в один из ограниченных контекстов - он его coupling убьёт, а если в отдельный - у него самого ещё хуже coupling будет.
Так ни я в примечании к этому посте, ни Хориков в посте, на который я ссылаюсь, не пишем, что от моков надо полностью отказаться.
Я пишу, что отношение тестов без моков к тестам с моками может быть 100 к 1. То есть без моков никак - бывает в одном случае из ста. В моей практике последних 4 лет - 9 коммерческих проектов суммарно на ~100К строк Котлин-кода.
При этом среднее время теста по моим проектам - порядка 50мс:
Тут ещё, пожалуй, стоит пояснить, что под "моками" я понимаю именно мокирование собственных типов с помощью условного Mockito. А против условных WireMock, GreenMail и т.п. я ничего не имею, включая верификацию вызовов - это часть наблюдаемого (снаружи) повдениия системы, которая должна быть специфицирована в тестах.
Добавлю ещё, что эти тезисы подтверждаются фактами из моей практики
В моём предыдущем проекте с тестами без моков за 500 человеко-часов, которые я рассматривал в ретроспективе было 0.5 бага на задачу или 0.05 бага/человеко-час (подробности).
В текущем проекте за примерно человеко/год разработки нашли 2 бага (не соответствие поведения софта требованиям) и 0 регрессий. Проект ещё не завершён, поэтому ретру не проводил и точных цифр пока нет.
В этом же проекте с 93% покрытием кода тестами без моков я недавно сделал пару серьёзных изменений в модели и рефакторингов: сделал один из агрегатов частью другого, ввёл разделение доменной модели и модели персистанса, добавил версионирование частей агрегата (условно таблички и её строк) - в ходе этой работы не было внесено ни одной регрессии.
А рефакторинг (20 затронутых файлов файлов, 482 добавленных строки, 172 удалённых строки) не содержал ни одной строки изменений в тестах.
Это красноречивая иллюстрация того, что хорошие тесты проверяют поведение системы, а не её реализацию и, как следствие, не меняются при рефакторинге - изменении структуры кода, без изменения его поведения.
Я удивлён, что коммент выше заминусили в свете того, что все классики TDD предостерегают от повсеместного использования моков.
Оставлю здесь мудрость древних для будущих поколений:
Я пожалуй даже код левого блока оркистрации скину:)
- кажется, он очень похож на ваш пример кода и, похож на ваш пример с созданием версии - тут как раз создаётся новая версия некой (хитрой по сути и динамически настраиваемой по структуре) таблицы.
Ещё так попробую проиллюстрировать свою точку зрения.
Разделением ИО и логики я борюсь с этим:
Легенда:
Синие блоки - оркестрация, блоки которые взывают более одного типа других блоков и имеют когнитивную сложность <= 4
Красные блоки - ввод, блоки которые делают ввод и имеют когнитивную сложность <= 4
Зелёные блоки - бизнес-логика, блоки, которые не вызывают ввод или вывод и имеют когнитивную сложность <= 15
Жёлтые блоки - вывод, блоки, которые делают вывод и имеют когнитивную сложность <= 4
Оранжевые блоки - месиво, блоки которые и делают ио (их сложно тестить и сложно понимать из-за необходимости учитывать перформанс, обработку ошибок и порядок выполнения) и имеют когнитивную сложность > 4 (их сложно читать и хочется тестить)
Или вот ещё не раскрашенный пример:
И в идеале я стремлюсь (и очень часто у меня получается) к такому:
Но вы правы, что не всегда всё так просто и красиво. Однако достичь вот такого - вполне реально и в "волосатых" случаях:
Эта картинка как раз ближе к IOSP/IODA, чем к ФА. Ну и да, оранжевый блок я подчищу в понедельник - этот кусок только-только дописали просрав дедлайн из-за недооценки "волосатости" задачи:)
Разница в сложности задачи.
В вашем примере я не вижу сложной бизнес-логики, которую стоит выделять.
А если вы приведёте пример с действительно сложной в моём пониманиии бизнес-логикой - мне скорее всего будет а) лень б) не хватать вводных, чтобы отрефакторить его и разделить логику и ио:)