Comments 26
в 95% случаев побеждают реальные пацаны.Только недавно я сталкивался с подобным legacy-проектом, в котором, несмотря на возраст всего в один(!!!) год, более 80% бюджета разработки тратилось на багфикс. Осуществить новую разработку ключевых компонентов с нуля стоило в разы дешевле, чем чинить их. При этом, заказчик не жадничал — проект имел хорошее финансирование, а разработчики, доведшие его до такого состояния, получали зарплаты существенно выше рыночных. Вообще, должен сказать, что я часто наблюдаю на рынке труда предложения «спасти» проект от финансового краха, и возраст таких проектов редко превышает 3 года. Т.е. в среднем, уже через 3 года дальнейшая разработка таких проектов становится нерентабельной, и инвесторы прибегают к радикальным решениям.
P.S.: rudnevr, статья, конечно, имеет несколько броскую стилистику изложения, но в целом, по моему мнению, вам удалось ухватить проблему за хвост. Мне кажется, что статья заслуживает больше, чем ее оценили, и соответствует своему саркастическому стилю. Продолжайте писать.
Пацан накодил — пацан протестил! (Название видео оригинальное)
У каждого инструменты свои плюсы и минусы. Если вы не видите минусов юнитестов на равне с их плюсами, то это очень плохо.
Да, unit-тесты на моках хорошо дают покрытие функций. Если у вас библиотека, то без unit-тестов никуда.
Если у вас большой проект, который надо постоянно менять, то поддержка Unit-тестов будет очень дорогим камнем на шее. Вы просто не вытянете саппортить тестые, потому тут приходит на помощь поведенческие тесты, которые, тоже могут дать неплохое покрытие. Да, они медленные, но зато они требуют минимум поддержки. Для частых релизов и мутных приложений такие тесты могут быть идеальными.
И если у вас проект, что надо выкладывать фиксы каждый час, то можно вообще использовать только A|B-тесты.
Разные ситуации бывают, надо уметь выбирать самое оптмимальное решение с точки зрения бизнеса.
Проектов, в которых надо выкладывать фиксы каждый час, а значит, и апдейты — очень мало, и тем более неясно, как это делать без хорошего покрытия.
Фактически, в случае интеграционных тестов, люди просто пренебрегают ошибочными сценариями, тестируя happy paths.
Относительно поддержки юнит-тестов я отдельно напишу.
Проектов, в которых надо выкладывать фиксы каждый час, а значит, и апдейты — очень мало, и тем более неясно, как это делать без хорошего покрытия.
Сами же пишете про тестирование библиотек на юзерах.
Я же пишу про a/b-тестирование.
Есть много приложений (например е-магазины), где на самом деле бизнесу не нужен ваш код, бизнесу нужна конверсия. Если конверсия растёт, то код приложения работает, если конверсия падает, то стало хуже.
Говорить о том, что функциональные тесты не проверяют негативные сценарии крайне не правильно. Очень даже хорошо ими ломать всё приложение и смотреть, как оно поведёт себя.
У меня довольно большой опыт разработки самых разных приложений, и с unit-тестами есть очень много проблем. Я бы сказал, что это самый дорогой метод тестирования и часто избыточный. Практически во всех моих проектах unit-тесты забрасывались со временем, ибо они требовали очень больших ресурсов.
1. Если у вас нет unit-тестирования, то вы можете выкинуть кучу кода. Все эти интерфейсы и фабрики вам не нужны, если у вас всегда одна реализация. Скорее всего, у вас станет и меньше слоёв — меньше кода, меньше багов и меньше работы по его поддержке.
2. Юнит тесты очень сильно завязаны на реализации. Если вы захотите сделать большой рефакторинг, то тесты просто перестанут комплироваться. Чем более высокоуровневые ваши тесты, тем легче вам переписать всё целиком.
3. Замокать окружение для высокоуровневых тестов очень сложно, намного проще запустить ваше приложение в каком-то контейнере и гонять его целиком с куском реальных данных, чем поддерживать моки.
Все эти пункты указывают на серьезные проблемы с архитектурой.
Меньше слоев и меньше абстракций — выше зацепление, сложнее сопровождение, труднее изолировать изменения при рефакторинге. Абстракции на то и абстракции, что их не нужно сопровождать — это ведь не детали реализации, а лишь контракты и интерфейсы.
Тест, завязанный на детали реализации — плохой тест, он бесполезен при рефакторинге, так как просто дублирует эти детали и отправляется на помойку при их изменении.
Система, которую проще протестить целиком (а обычно "проще" означает "а никаким другим способом ее и не протестишь"), называется Big Ball of Mud.
Меньше слоев и меньше абстракций — выше зацепление, сложнее сопровождение, труднее изолировать изменения при рефакторинге.
Я тут вам возразил бы. Абстракция — это в философии просто общий интерфейс. В программировании это всегда живой код, ещё один уровень имплементации, который точно так нужно сопровождать. Кроме того, абстракции имеют ту нехорошую ловушку, что нередко при изменении требований они перестают им удовлетворять. Что мы делаем в таком случае? Порождаем ещё один уровень, и/или создаём новый интерфейс, сосуществующий параллельно со старым и т.д. Больше контрактов — значит, разработчику надо помнить про их существование, про их применимость и т.д. Всё это, начиная с какого-то уровня сложности, значительно усложняет и замедляет сопровождение.
Поэтому есть некая «золотая середина», несколько уровней абстракции, которые просты и понятны. За эту границу заступать нежелательно.
Например, есть у меня экшен контроллера, в котором я вытаскиваю из базы объекты ровно такие, какие нужны мне для view. Грубо говоря:
public async Task<ActionResult<UserProfileResponse> GetUserProfile(UserProfileRequest request)
{
return await _dbContext.Users
.Where(x => x.Id == request.Id)
.Select(x => new UserProfileResponse()
{
Id = x.Id,
Name = x.Name
}).ToListAsync();
}
Дальше генерим webClinet через nswag и дёргаем API в функциональных тестах. Просто, легко поддерживать и мало кода, а ещё можно тюнить под конкретный запрос. Вы можете потом дать джуну задачу добавить поле в UserProfileRequest и он это сможет сделать. Вы можете взять программиста, который всё перепишет на Хаскеле, но ваш тест будет работать, ибо это API вызовы. Такие высокоуровневые тесты живут, пока не поменялись требования, они совершенно не тормозять рефакторинг системы.
Для юнит-теста надо надо мокать базу, для этого надо пилить слои. Надо делать, какой-нибдуь IRepository.GetUserProfile(), делать слой данных, делать мэппинг данных из UserProfile в UserProfileResponse. (Вы же не будете класть объекты уровня Api в DAL).
Кстати, вот в случае SaveUserProfile уже мэппинг и слой абстракции не повредит, потому что там будет 100500 всяких валидаций, запросов к сторонним сервисам и прочее, так что там есть смысл выносить код save из класса контроллера. Просто чтобы спрятать сложность куда-то в отдельное место.
Сначала разберем ошибочные сценарии.
1) Представим, что в вашем клиенте баг и у вас прилетает request==null, из которого возращается 500 ошибка на клиенте, причем в логах ничего нет.
2) Если request.id==null, аналогично будет возвращаться, видимо, пустой лист. (И в логах опять ничего нет, если только вы не профилируете все запросы).
3) Если id нормальный, но в базе отсутствует, что должно возращаться? То же самое, и в логах опять ничего нет.
4) Таймауты и коннекшены падают как придется
Отдельно следует заметить, что этот код не содержит _никаких_ утверждений о том, как именно он должен вести себя в ошибочных сценариях. Т.е. нет разницы между «не подумал» и «так и задумал».
Теперь предположим, что у нас есть набор изменений. Например, добавилась логика в маппинг ответа, LastName, FirstName, FullName, Middle Initial. Часть из них обязательная, часть нет, часть вычисляемая. Добавилась связанная сущность UserAddress, к которой тоже релевантны все проверки и которая может как отсутствовать в базе, так и присутствовать и быть не полной.
А если у клиента свое представление о том, как должен выглядеть payload ответа, то ему нельзя просто вернуть UserProfileResponse.
Соответственно, скорее всего эти изменения будут постепенно раздувать метод до неподдерживаемых пределов, а количество комбинаций валидных и невалидных сценариев очень быстро превысит человечески понимаемое, поддерживаемое количество.
Т.е. у этого кода уже сейчас, в самой простой стадии, примерно нулевая устойчивость как к ошибочным сценариям, так и к сценариям самых базовых изменений и расширений.
При том, что при юнит-тестинге эта устойчивость появилась бы сразу, из коробки.
(Собственно говоря, уже написав, я сообразил, что метод возвращает лист профайлов, т.е. еще и на сценарии с множественными профайлами нужны тест-кейсы).
Т.е. у этого кода уже сейчас, в самой простой стадии, примерно нулевая устойчивость как к ошибочным сценариям, так и к сценариям самых базовых изменений и расширений.
Как раз его очень просто расширять и изменять. И тестировать через API. В нём просто нет кода, который нужен только для тестов. Ну а какая должна быть устойчивость? ну вернёт он пустой объект если что-то не так, ну или валидация атрибутов сработает.
1) Представим, что в вашем клиенте баг и у вас прилетает request==null, из которого возращается 500 ошибка на клиенте, причем в логах ничего нет.
Я не понимаю, что это за логи, которые не видят ошибку 500.
Валидация UserProfileRequest обычно делается за счёт атрибутов самого фреймворка. Но писать валидацию в методе контроллера — это тоже нормально.
Приложение обычно перехватывает все эксепшены и пишет их в логи. Ну если у вас какое-то публичное API, то да, имеет смысл париться тестами валидацией и сложными кейсами API (хотя IRL если клиент шлёт в API всякую хрень, то сам дурак). Но всё равно не ясно зачем это выносить на более низкий уровень.
Если нужно будет доставать связанные сущности в самом Select'е то нет смысла это куда-то выносить, ибо это будут сущности уровня API и их можно мэппить прямо в запросе. Когда и если будет навороченная логика, тогда её стоит и выносить.
LastName, FirstName, FullName, Middle Initial надо будет добавить только в одном месте. Если у вас пойдут слои, то это надо будет добавлять в куче мест, ещё и мэппить. И всё только для того, чтобы сделать тестирование. Ну если бюджеты бесконечные, то можно. А я за разумную достаточность.
С точки зрения же разработки это супертривиально, добавить один тест и одну проверку в код. И неясно, каким образом поиск продакшен бага — это не бесконечный бюджет, а проверка на баг, которая в рутинном процессе занимает ну пять минут, — бесконечный.
Неважно какими средствами делать валидацию и перехват экспешенов, декларативно или императивно. В ТДД имплементация вообще дело десятое, тут главное reasoning и specification. Т.е. мы подразумеваем, что если человек написал сценарий, то он так и задумывал. А если не написал, то и не подумал. Это работает для всех.
Обсуждая детали имплементации, вы смотрите на код со стороны написавшего, а не читающего и поддерживающего. Написавшему много что может быть ясно. Вопрос, что потом с этим делать.
https://rbcs-us.com/documents/Why-Most-Unit-Testing-is-Waste.pdf
Для юнит-теста надо надо мокать базу, для этого надо пилить слои. Надо делать, какой-нибдуь IRepository.GetUserProfile(), делать слой данных, делать мэппинг данных из UserProfile в UserProfileResponse. (Вы же не будете класть объекты уровня Api в DAL).
Классический вопрос, озвученный в 2014 году David Heinemeier Hansson: "TDD is dead. Long live testing." (хотя на самом деле с TDD он мало связан, но, так случилось, что объектом его критики стал именно TDD, вероятно потому, что архитектура Ruby on Rails часто критиковалась приверженцами TDD). Результатом его поста стал 5-ти серийный сериал "Is TDD Dead?", в котором подробно раскрывается озвученная Вами проблема. В сериале принимал участие сам David Heinemeier Hansson, создатель Ruby on Rails, Martin Fowler, автор книги PoEAA, по которой и был создан Ruby on Rails, и Kent Beck — основатель TDD и друг Martin Fowler. Т.е. более компетентный ответ сложно найти.
Проблема данного кода не в том, что его трудно протестировать изолированно (изоляцию, на самом деле, легко осуществить на уровне коннектора БД, о чем писал Кент Бек в «Test-Driven Development By Example»), если изоляцию вообще нужно делать, а в том, что в этом коде невозможно отделить то, ЧТО он делает, от того, КАК он это делает. Т.е. смешаны политики разного уровня, которые будут изменяться в разное время, с разной частотой и по разным причинам. В качестве примера можно привести некогда популярный веб-фреймворк Pylons, который прекратил свое существование, крупные обратно-несовместимые изменения в веб-фреймворке CherryPy, которые отразились на другом веб-фреймворке TurboGears, и т.п. На фронтенде хорошо известна история с совершенно обратно-несовместимым релизом Angular2.
И здесь перед вами возникает вопрос поиска баланса выгод и затрат — что произойдет, если IO-устройство (а, с точки зрения принципов Clean Architecture, Front/Page Controller — это просто IO-устройство) выпустило обратно-несовместимый релиз или объявило о прекращении своего существования? Что произойдет, если вам понадобится добавить поддержку, например, CLI-интерфейса? Дешевле ли Вам заложить уровень абстракции сейчас, и защитить свою политику более высокого уровня от низкоуровневых изменений, или же вам дешевле каждый «апгрейдить» свой код? Каждое решение — это поиск компромиса.
В связи с тем, что в последнее время заметно усилился интерес к CQRS, построение запроса обычно инкапсулируется в Query объект (пример), при этом не обязательно использовать ни доменные модели (по причине вырожденности их поведения), ни Repository. Сторонники Clean Architecture выделяют класс UseCase/Interactor, который, так же, как и класс Query, является разновидностью паттерна Команда, но, по определению, имеет немного более высокий уровень политики.
Ну вот когда надо будет делать CLI, тогда можно делать генерализацию и всё остальное. Я стараюсь максимально следовать принципам KISS и YAGNI и не усложнять пока это возможно.
Грубо говоря, CQRS удобная штука для разруливания сложных кейсов, но пихать её вообще везде глупо.
максимально следовать принципам KISS и YAGNI и не усложнять пока это возможно.Суть YAGNI заключается не в том, чтобы не усложнять, а в достижении наилучшей экономики разработки в условиях неполной информированности. А это значит, что выделение лишних абстракций (и любое другое усложнение) оправдано лишь в том случае, если стоимость их выделения в будущем будет существенно дороже, чем сейчас.
С другой стороны, «хороший архитектор всегда максимизирует количество непринятых решений», обеспечивая при этом высокие экономические показатели разработки при любом сценарии развития программы. YAGNI должен способствовать эволюции программы, а не препятствовать ей. Например, если код нарушает Open-Closed Principle (OCP) или Stable Dependencies Principle (SDP), то это, на самом деле, не YAGNI, поскольку это противоречит основной его цели — достижение наилучшей экономики разработки в условиях неполной информированности.
Приведенный вами фрагмент кода нарушает Stable Dependencies Principle (SDP). Оправдано ли это нарушение? Ответ зависит от баланса получаемых выгод и затрат. Если у Вас всего один такой метод — разумеется, вам дешевле пофиксить его, когда публичный интерфейс веб-фреймворка будет изменен. С другой стороны, мне известны проекты, которые на протяжении двух лет не могли соскочить со старой версии используемого фреймворка. Хорошее приложение стремится минимизировть зависимость от конкретного фреймворка и от конкретной его версии.
но пихать её вообще везде глупо.Конечно.
Тесты штука сугубо утилитарная. У меня к ним несколько требований: минимум ложных срабатываний, отлавливать максимум регрессионных ошибок и не мешать рефакторингу кода.
Хотя тесты с моками проще писать, они слишком чувствительны к изменениям в реализации и на длительных промежутках времени классические тесты дают лучший эффект. Неплохой баланс получается, если пользоваться моками лишь на границе систем, а внутренности тестировать классикой.
TDD, мокисты и реальные пацаны