Search
Write a publication
Pull to refresh

Comments 64

(сразу извиняюсь за оффтоп, но мысль приходится выкладывать полностью.)
Это к сожалению проблема практически всей айтишной литературы. Особенно касающейся архитектуры. Провести дидактическую редукцию сложной концепции дело непростое, и людям просто лень. А может они и не умеют этого делать.
Я очень критически отношусь к такой «лени» — «не можешь привести пример, значит это пустая болтовня». «Пример получится слишком большим?» — ничего мы подождем.

Как я отличаю хорошую литературу от плохой? В хорошей не пишут «мы вернемся к этому чуть позже» — ведь обычно не возвращаются. В хорошей дают развернутые примеры, и не противоречат установленным догмам, типа не стоит называть тест XTest, но для примера, чтобы развить мысль, называют свой пример ХТest. Это никак не оправдано, даже если это запись разговора по памяти, то всегда есть такой этап как редактирование. И в нужном месте нужно изменить достоверность диалога в угоду дидактической удобоваримости.

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

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

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

По опыту: люди, которые работали на запоротом проекте, обтерпелись и начали вникать во все перипетии, практически никогда не смогут его хорошо переписать

Смогут, просто им на это не дадут времени.

Смогут, ибо включат время в оценки задач.
Ну, выкатят они оценку: «3 месяца, чтобы разобраться, кратко записать, как это сейчас работает и выставить оценки на переписывание». Там им никто не даст делать задачу такую дорогую, с которой непонятно, будет ли польза в дальнейшем.

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

Это да, но
1. Никто не даст 3 месяца «разбираться».
2. Некоторые места надо сильно переписать (в т.ч. структуру БД, интерфейсы взаимодействия с другими системами и пользователем), т.е. это не тот рефакторинг, который разработчик может сделать без тысячи согласований.

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

По мелочи так можно править, но в ветке обсуждение как «хорошо переписать».

Это и есть переписать. Только не за один раз. Когда куча мелочей будет решена, последующие задачи будут легче.


И те вещи которые меняются, будут потихонечку переписаны.

Не так. Оценки включаются в бизнес задачи. Дополнительно оценивается эффект от особо вредного легаси для каждой задачи. Через некоторое время у бизнеса формируется правильное понимание ситуации.
Разумеется, сам по рефакторинг никогда не продать — значит надо продавать вкладывая в продуктовые задачи, с хорошими резонами, подкрепленными статистикой.
Да, теперь понял. Если регулярно выставлять большие оценки, оправдывая их тем, что система очень кривая, есть шанс, что дадут время переписать, чтобы дальше двигаться быстрее.
Не так.
Время на выполнение задач в кривой системе и так велико.
Если при оценках будет отдельно указано время, возникшее не от сложности задачи, а от кривизны системы (со ссылками на превышение планов по этой же причине), то бизнес сможет оценить постоянные потери.
Также в оценку логично включать время (уже не выделяя отдельно) на рефакторинг, нужный чтобы эту конкретную задачу сделать быстрее.
И наконец, для продажи рефакторинга, который позволит ускорить целый ряд задач, хорошо указать, сколько мы уже потеряли от кривизны в оценке сделанных задач, и сколько можем потерять в оценке еще не сделанных.
Разумеется, ссылаться надо не общую абстракнтую кривизну, а на конкретные дефекты, мешающие делать конкретные продуктовые задачи.

Обычно рекомендуют делать код лучше понемножку.
http://jbazuzicode.blogspot.com/2017/08/refactor-lot-but-only-when-its.html


Don't just refactor for fun. Refactor in service of delivering business value. If there's some terrible code that you never need to touch, then there's no reason to change it. Leave it terrible.

So, when are the right times to refactor?
  • When you're changing code. Refactor to make it well-designed for its new purpose.
  • When you're reading code. Every time you gain some understanding, refactor to record that understanding. Lots of renames.
  • When you're afraid of code. If there's code you should be changing or reading, but you avoid because it's such a mess, then you should definitely refactor it.


Note that this refactoring is a small improvement each time, not a dramatic major rewrite. >The goal is Better, not Good.
Статьи Дяди Боба в форме диалога это скорее притчи, которые наталкивают на правильные мысли. Они не про практичность. У меня каждый такой диалог закреплял интуитивный опыт, «знаю, а сказать не могу», в виде набора формализмов, которыми уже можно пользоваться и ссылаться при принятии решений.
Как по мне если делать тесты сложными (Применять наследования, добавлять различные параметры в конструктор и т.п.) то весь смысли в них теряется. Вместо простых изменений, не важно каких, будь-то изменения бизнес логики или структуры вам придется вспоминать или заново читать(код) архитектуру тестов. И это как по мне приведет только к удорожанию написания и поддержки тестов

Тесты могут стать сложными, только по двум причинам:


  1. В случае сложного публичного интерфейса
  2. Внутри есть обращение к внешним сервисам. Тогда наивный простой интерфейс приведет к хаосу из моков и пронизывающего все и вся DI. Как правило, такие обращения можно подтянуть поближе к интерфейсу и сделать все понятнее.

В любом случае, сложные тесты сигнализируют о сложном дизайне основного кода.

Уважаю Роберта Мартина.
Но в этот раз как-то неубедительно.


Хорошо, итак, структура тестов не должна отражать структуру кода, потому что
такая связанность делает систему хрупкой и затрудняет рефакторинг.

А что мы имеем по факту в итоге? Класс Х и тест-класс XTest.
Тот факт, что у нас нет тестов на вспомогательные классы, которые используются в Х, означает просто, что в этих классах нет логики достаточно сложной для того, чтобы ее стоило тестировать отдельно. Как только они усложнятся — появятся отдельные тесты на них (просто потому, что тестировать их через общий X Api непрактично.


В то время как тесты становятся все более определенными, код будет все более
общим

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


В итоге, из статьи лично мне неясно — а что делать-то надо?

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

Как только они усложнятся — появятся отдельные тесты на них (просто потому, что тестировать их через общий X Api непрактично.
Они усложняются как следствие дополнительных требований к X API, и тестировать их естественно через X API. Об этом в статье и говорится.

В итоге, из статьи лично мне неясно — а что делать-то надо?
Тестировать через публичный интерфейс и проектировать код так, чтобы это было удобно делать. Если возникают трудности, значит ты что-то делаешь не так, и про это можно спросить у более опытного коллеги.

Пример из жизни: недавно менял реализацию в библиотеке, патч на 1.5к строк, ни один тест из 152 штук не пострадал, так как это было полное переписывание только внутренних функций. Если бы не было тестов или они тестировали конкретные «юниты» — никогда бы не взялся за такой рефакторинг.
Если возникают трудности, значит ты что-то делаешь не так
Понять, что что-то идет не так, обычно несложно. Вопрос (и предыдущего комментатора, и я к нему присоединяюсь) — а как сделать «так»?
Пожалуй, статья хороша тем, что позволяет уточнить, в каком именно месте что-то пошло не так, но на этом польза заканчивается и остается некоторое нехорошее послевкусие. То есть «кто виноват» выяснили, теперь пора переходить к «что делать».
> а как сделать «так»?

Увы, к формальному ответу индустрия еще не пришла. Думать и пробовать, пытаться ужимать интерфейсы до минимума, меньше входных точек — меньше вещей, за которыми нужно следить.
Они усложняются как следствие дополнительных требований к X API, и тестировать их естественно через X API.

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


А еще вдруг выясняется, что один из внутренних классов недетерминированный (допустим, использует рандом) или разговаривает с внешним сервисом, который надо замокать. И внезапно для XTest становится важно знать, что именно стоит за X Api, чтобы иметь возможность замокать что надо — хотя это не часть интерфейса X Api.

> Количество тестов растет лавинообразно, если все тестировать через внешний интерфейс, и не тестировать зависимости по отдельности.

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

> Не говоря уже о том, что сами тесты становятся сложными.

Про это уже было написано выше. Сложные тесты — следствие ошибок в проектировании системы, нарушение принципов SOLID.

> Тестировать через общий интерфейс все еще можно, но уже непрактично.

В TDD тесты — это, в первую очередь, документация. Тестирование внутренностей в данном случае — это информационный шум.

Я, впрочем, не являюсь приверженцем TDD в его чистом виде, т.к. принцип написания ровно одного теста за раз и минимального количества кода, проходящего тест, считаю контрпродуктивным — приводящим к постоянным рефакторингам и костылям.

Лично я сначала продумываю архитектуру (пишу интерфейсы, вспомогательные структуры), затем пишу набор тестов под каждый интерфейс, затем код, проходящий тесты.

Давайте попробуем с конкретным примером, чтобы было понятней.


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


Как проверить, что валидация отрабатывает корректно на разных входных данных?
Написать тест на класс Валидатор? Или написать тест на класс ВебКонтроллер, и тестировать валидацию через публичный интерфейс — http запросы?

Выделяете валидатор в отдельный интерфейс IValidator и передаёте его в качестве параметра IWebControllerFactory.Create, делая, таким образом, валидацию и обработку запроса независимыми. Если же валидатор — исключительно внутренняя сущность вебконтроллера, например, из-за его тривиальности, то придётся тестировать его через внешний интерфейс.

Другой пример — реализация ассоциативного массива. У него простой и понятный внешний интерфейс, но реализация под капотом может быть разная. И тестировать правильность построения массивов с хэшами нет никакого смысла.

Ок, то есть в итоге у нас есть тест ValidatorTest и есть отдельный тест контроллера ControllerTest. Структура тестов повторяет структуру кода.

Количество тестов растет лавинообразно, если все тестировать через внешний интерфейс, и не тестировать зависимости по отдельности.

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

Что такое компонента? В статье призывается не создавать новых тестов когда мы выносим что-то в отдельный класс. Этот отдельный класс это компонента или нет? По какомй криетрию?

Что такое компонента?

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


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


В статье призывается не создавать новых тестов когда мы выносим что-то в отдельный класс. Этот отдельный класс это компонента или нет?

В статье призывается не делать тестов на появляющиеся внутренние классы, спрятанные за публичным API. Нет это не компонента, это деталь реализации компоненты.

Компонента предоставляет интерфейс, модель данных и инварианты над моделью.

Это похоже на описание хорошего класса :)


В статье призывается не делать тестов на появляющиеся внутренние классы, спрятанные за публичным API. Нет это не компонента, это деталь реализации компоненты.

Почему одна компонента не может быть деталью реализации другой?

Почему одна компонента не может быть деталью реализации другой?

Может, но если публичный API подкомпоненты используется только в родителе, то смысла тестировать ее нет. Она является внутренней по отношению к предметной области и требованиям.

Она является внутренней по отношению к предметной области и требованиям.

Я стараюсь, чтобы такие компоненты либо были сформулированны в терминах предметной области либо обладали своей внутренней предметной областью.


Иногда я не тестирую очень тесно связанные компоненты (например итератор отдельно от коллекции).

> Может, но если публичный API подкомпоненты используется только в родителе, то смысла тестировать ее нет.

В такой «подкомпоненте» может десятки и сотни тысяч строк лежать, без необходимости что-либо переиспользовать.

Давайте на примере: делаем хеш MD5, первая ссылка в гугле tls.mbed.org/md5-source-code

Тестируем только публичный API, запускаем тест, попадаем на mbedtls_printf( «failed\n» ).

Где ошибку искать? Ну или там плохой тест, какой бы вы написали для публичного API, который покажет где ошибку искать?
Как я понимаю, если сущность может быть использована как самостоятельная единица и может быть повторно использована, тогда она становится компонентой и должна тестироваться отдельно. Если же сущность не может существовать отдельно от внешнего интерфейса или присутствует в единственном экземпляре, то и тестировать её отдельно не стоит.

Я согласен про "не может существовать отдельно" но не согласен про "единственный экземпляр".


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


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

> Если у нас есть, допустим, компонента которая делает расчет и записывает его в БД

Это две компоненты в чистом виде) Независимые требования.

Хорошо, я думаю надо стремиться к тому, чтобы вынесенное в отдельный класс было доменной абстракцией (может быть какого-то внутреннего технического домена) — то есть отдельным компонентом

Ну так это. Unit test тестирует не класс, а unit.
В некоторых случаях unit — один класс, в некоторых — несколько связанных классов. Вопрос определения юнита — чисто эмпирический. Иногда даже банально обусловлен удобством тестирования. Если логика в классе начинает усложняться, напишем для него отдельные тесты, даже если раньше он тестировался как часть более крупного юнита.


Тем не менее, все равно выходит, что тесты в целом следуют за структурой классов.

Если бы не было тестов или они тестировали конкретные «юниты» — никогда бы не взялся за такой рефакторинг.

Были ли тесты хорошим кодом? Содержали ли они дублирование?

Были ли тесты хорошим кодом? Содержали ли они дублирование?

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

Я не очень понял. Нельзя как-нибудь поподробнее объяснить?


1-1 обертки над api в которых создавались нужные зависимости

Мне кажется, 1-1 обертки это и есть дублирование, нет?

означает просто, что в этих классах нет логики достаточно сложной для того, чтобы ее стоило тестировать отдельно

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


В итоге, из статьи лично мне неясно — а что делать-то надо?

Пишите тесты для контрактов, а не реализаций.

Получается у этого класса нет единственной ответственности.

Неправда. Сложная логика, требующая тестирования <> множественная ответственность. Вот банально валидатор. Сначала форма была простая, валидацию тестировали через контроллер. Потом форма все усложнялась и усложнялся валидатор. И тестировать валидатор через контроллер стало непрактично — появились отдельные тесты на валидатор.


Ибо если есть класс, то у него есть ответственность, которую можно выразить через формальный контракт, исполнение которого можно проверить тестами.

Это довольно очевидно. Но ведь посыл статьи как раз в том, что мы НЕ тестируем внутренний класс. Несмотря на то, этот класс имеет определенную четко выраженную ответственность.

Неправда. Сложная логика, требующая тестирования <> множественная ответственность.

Правда. TDD требует покрывать отказными тестами любой код.
Если вы выделили публичный класс, то вы обязаны покрыть его тестами.
Если не выделили, то он покрывается тестами класса-владельца, но это как раз тривиально, поскольку приватные классы и методы напрямую очень мало кто тестирует.
Сложность логики здесь не фигурирует, этого критерия в TDD нет.

Правда. TDD требует покрывать отказными тестами любой код.

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


то он покрывается тестами класса-владельца, но это как раз тривиально

Вот я как раз и утверждаю, что это не тривиально, если логика в классе сложная.
Либо мы по-разному понимаем публичный/внутренний класс.
Я имею в виду класс, который используется только из данного (основного) класса и больше нигде не нужен (ни отдельно, ни как вспомогательная часть другой фичи). Независимо от его сложности.

это BDD. Мы тестируем поведение конкретного класса через публичные методы и даем ему дернуть все внутренние.
Юнит тесты — все же должны быть изолированными тестами.

Это просто непрактично описывать абсолютно все

Но большую часть описать — вполне нормально.
это BDD

BDD крутится вокруг специального DSL для написания сценариев и отражающего предметную область. BDD ближе к интеграционному тестированию, потому что, как правило, в сценариях затрагивается сразу несколько компонент. Пользователь залогинился, добавил товар в корзину и зачекаутил корзину. BDD скорее инструмент для приемочного тестирования, а не инструмент разработчика.


Юнит тесты — все же должны быть изолированными тестами.

Кому должны?) В статье раскрыта суть проблемы такого подхода. Юнит тесты, которые тестируют отдельные приватные функции, только добавляют проблем.


Покрытие компоненты/модуля через публичный API позволяет эффективно проводить рефакторинги в дальнейшем.

Покрытие компоненты/модуля через публичный API позволяет эффективно проводить рефакторинги в дальнейшем.

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

Ну как не позволяют? При рефакторинге интерфейс не меняется, значит мы можем творить с кодом что хотим и тесты не должны ломаться, так как поведение закреплено. Количество тестов никак на это не влияет.

если мы к примеру добавим внутренний метод, нам придется в тесте тестирующим публичные методы создавать еще 3-4 тест-кейса. Вместо тестирования одного метода. Итого — желаемой гибкости не наблюдается.
Если внутренний метод никак не затрагивает внешний интерфейс, то зачем его тестировать?

Логика, которая в нем содержится, должна быть протестирована или нет?

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

Вы пишете контракт и тесты для внешнего интерфейса, а затем реализующий код. Если вы добавили лишней логики во внутренний код, вы нарушили правила TDD (писать минимальное количество кода, проходящее тесты). И тестировать такой код не нужно, т.к. он не будет влиять на внешний интерфейс.

То есть этот код должен быть покрыт тестами внешнего интерфейса, правильно?

Я не согласен. И структура тестов и структура кода должна быть отражением требований. Если тесты по организации не похожи на код, то код или тесты не отражает требований.

И структура тестов и структура кода должна быть отражением требований.

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

Имхо.


Какая угодно реализация может быть. Хорошая реализация внутри должна быть отражением предметной области. (см. Ubiquitous Language)


То есть микросервисы на марсе должны быть отражением какой-то части требований.

> Хорошая реализация внутри должна быть отражением предметной области.

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

Например, возьмем rate limiting. У нас есть объект который нужно лимитировать, период времени, количество вызовов. Хорошей реализацией будет просто голый redis + интерфейс с элементарными вызовами. Знает ли redis про нашу предметную область? Да нет конечно, но при этом он хорошая реализация.

Я не знаю, что такое redis, но полагаю, ято это какой-то свой продукт, со своей технической предметной областью который будут покрывать свои тесты.


Ваши тесты могут покрыть только требование "rate limiting" чего бы это ни значило. Возможно у вас будет hexagonal artchitecture со своими focused integration tests по rate limiting и отдельно протестированным тестовым адаптером. Вам не надо будет повторять тесты redis в своих тестах.

Главный вопрос — зачем? Наличие тестов, покрывающих не только публичный интерфейс, но и реализацию, привязывает разработка к конкретной реализации. Захочется сделать рефакторинг или даже поменять саму реализацию — что тогда делать с тестами?

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


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


С другой стороны ваша система в целом тоже является деталью реализации для кого-то (может как часть чьего-то бизнеса).


Когда вы рефакторите код и у вас выделился какой-то слой абстракции, если это делать осмысленно то получится типа "поддержка работы с URL" или "дополнительные методы работы со строками".


Это аналогично тому, что вы в требованиях выделили главу "В целом с URL надо обращаться вот так" или "Вот так должно происходить слияние строк через одну" и далее ссылаетесь на эту главу.


Иначе вам придется повторять одни и те же вещи что в требованиях, что в коде, что в тестах.

до момента пока не сможем написать проваливающийся тест

Очень двусмысленно. Я бы предложил "до момента, когда написать проваливающийся тест уже не сможем"

Sign up to leave a comment.

Articles