monco83: (Default)
Очень раздражают IT-евангелисты. К адептам юнит-тестирования, в особенности TDD, это относится в максимальной степени. Читая таких евангелистов (а они сейчас в IT-пространстве доминируют) не устаю поражаться как тем "истинам", которые бывают приняты за базовые аксиомы, так и всеобщему "заговору молчания", которым встречают голого короля.

Так вот, у адептов юнит-тестирования непререкаемой догмой является эквивалентность понятий "тестируемы код" и "хороший дизайн". Под хорошо спректированным кодом сторонники юнит-тестирования зачастую понимают только тот код, который легко тестируется: этот факт, якобы, само собой свидетельствует о высоком качестве кода и о следовании принципам S.O.L.I.D.
Вот пример с набором типичных рекомендаций как сделать код тестируемым (а дизайн "хорошим").
https://www.toptal.com/qa/how-to-write-testable-code-and-why-it-matters
Тут вы встретите всё, что обычно бывает в таких текстах:
1. Проклятие статическим классам, которые означают "сильное зацепление" и сокрытие явных зависимостей.
2. Проклятие синглтонам из-за их статического характера.
3. Проклятие оператору new, потому что код становится зависимым от конкретных типов, а не от абстракций.
4. Прославление Dependency Injection

Как бы выглядело приложение настоящего TDD-зелота? Нет ни статических классов, ни статических функций вообще (а, значит, нет и синглтонов), все public- и protected-методы помечены как virtual. Приватных методов нет, т.к. приватные методы нельзя сделать виртуальными. Все зависимости между компонентами системы передаются либо в параметрах конструктора, либо в аргументах вызова метода в виде ссылок на интерфейсы. Вот тогда мы всё можем замокать и протестировать до самых кишок.

Является ли мир TDD-программиста хорошо спроектированным? Сам TDD-программист абсолютно в этом уверен и способен привести тонну аргументации в подтверждение своей точки зрения (смотри ссылку на статью выше). Но давайте задумаемся над вопросом, не получается ли так, что забота о простоте тестирования уводит нас в сторону от чистоты кода, который должен в первую очередь ясно и лаконично выражать концепции бизнес-логики приложения?

Разве функция расчёта площади треугольника по формуле Герона не носит сама по себе явно статический характер? А расчёт силы гравитации между двумя телами массами m1 и m2 центры которых находятся на расстоянии R друг от друга, разве не является "просто формулой", которую естественне всего представить в виде статической функции?
В мире обычного проектирования, код, которому нужно получить площадь треугольника сделает вызов:
TriangleCalc.GetSquareByGeron(a,b,c);
В мире TDD придётся написать что-то типа:
var triangleCalc = new TriangleCalc();
trianglуCalc.GetSquareBeGeron(a,b,c);
Либо же, помятуя о запрете оператора new, вводить интерфейс ITriangleCalc и передавать компоненту экземпляр интерфейса в виде "явной зависимости".

Впрочем, TDD-евангелисты, глядя на такие примеры, скорее всего согласятся, что это перебор, даже, наверное, помянут антипаттерн "академическое приложение": дескать, не такие уж мы экстремисты и ко всякой рекомендации надо подходить с умом. Но в том-то и проблема, что рекомендации евангелистов (и IT-сфера здесь не исключение) всегда носят абсолютный характер. "Статика - зло", - об этом кричат везде и повсюду. Но никто не напишет о том, когда статика - это правильно и хорошо и в каких случаях её следует использовать. Другая же проблема заключается в том, что необходимость тестирования действительно может нарушить ранее принятый хорошо спроектированный дизайн.

Рассмотрим случай с кэшированием. Пользователю достуен некий метод GetByKey(key) класса XXX и мы хотим убедиться, что в случае двух последовательных обращений по одному и тому же ключу функция Load(key) будет вызвана лишь однажды, а в другой раз последует обращение к кэшу. Если тестировать объект как чёрный ящик, проверить это мы никогда не сможем. Но мы можем раскрыть содержимое этого чёрного ящика, отнаследовавшись от класса XXX и переопеделив в классе-наследнике метод Load таким образом, чтобы он увеличивал счётчик обращений при каждом своём вызове. Но, стоп!, для этого переопределяемый метод должен быть объявлен как virtual protected. А в соответствии с намерениями нашего дизайна, этот метод объявлен у нас приватным, потому что не было никакой необходимости его переопределять. Ничего не поделаешь, придётся вносить изменения в код класса XXX.

Рассмотрим теперь вопрос взаимодействия компонентов. В проекте, над которым я в данный момент работаю, есть класс XXXParser, который разбирает старый экзотический "язык" XXX, есть класс YYYGenerator, который помогает строить YYY-скрипты, и есть класс Converter, который представляет собой типичный медиатор - он женит парсер с генератором и перегоняет скрипты из одного языка в другой. Так вот, в этом конвертере откровенно нарушаются догмы TDD-проектирования. Мой медиатор обращается к XXXParser-у через его статический фасад, а для генерации YYY-кода он обращается напрямую к статическому классу YYYGenerator. Да, такой класс не замокаешь. Чтобы сделать код класса тестируемым (в терминах "белого ящика"), мне пришлось бы избавляться от статики, вводить пару интерфейсов IXXXParser и IYYYGenerator, а затем "настраивать" класс Converter путём передачи объектов парсера и генератора через конструктор. Но дело в том, что за пределами задачи тестирования я совершенно лишён мотивации строить проектное решение подобным образом. Ни мой Parser, ни мой Generator не представляют собой кандидатов для использования паттерна стратегия - это типичные "хэлперы", мне в голову не приходит такой случай, при котором вариативность алгоритмов парсинга или генерации была бы востребована. Мой Converter делает именно то, что и задумывалось по дизайну. Да, он не раскрывает все свои зависимости "явно" посредством длинного списка аргументов конструктора, зато имеет своим плюсом ясность и лаконичность использования в клиентском коде.

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

P.S. Большой постскриптум.
Моё твёрдое убеждение состоит в том, что слепое следование принципам TDD способно разрушить хороший дизайн. Сегодняшний день я потратил на поиски человека, который бы не побоялся сказать про "голого короля". И нашёл. Ниже будут цитаты.
James O Coplien
http://rbcs-us.com/documents/Segue.pdf
It can be even worse: the very act of unit testing may cause the interface of the map to grow in a way that’s invisible in the delivered program as a whole. Felix Petriconi and I have been debating the unit testing issue in Email, and today he wrote me that: “You are right. E.g. we introduced in our application lots of interfaces to get the code under (unit) test and from my point of view the readability degraded.” David Heinemeier Hannson calls this “test-induced design damage:” degradation of code and quality in the interest of making testing more convenient (http://david.heinemeierhansson.com/2014/testinduced-design-damage.html). Rex Black adds that such tradeoffs exist at the system level as well as at the unit level.

David Heinemeier Hansson
http://david.heinemeierhansson.com/2014/test-induced-design-damage.html
It's from this unfortunate maxim that much of the test-induced design damage flows. Such damage is defined as changes to your code that either facilitates a) easier test-first, b) speedy tests, or c) unit tests, but does so by harming the clarity of the code through — usually through needless indirection and conceptual overhead. Code that is warped out of shape solely to accomodate testing objectives.
...
I think part of why we've been able to go so long with only murmurs of a debate about the value of TDD as a design principle, is post hoc rationalization. If you accept the premise that red-green-refactor is the true guiding light for all programming design, any sacrifices on its altar seem trivial. Who cares if you need two or three extra layers of indirection to unit test a controller? OF COURSE it's worth it.
...
I think part of why we've been able to go so long with only murmurs of a debate about the value of TDD as a design principle, is post hoc rationalization. If you accept the premise that red-green-refactor is the true guiding light for all programming design, any sacrifices on its altar seem trivial. Who cares if you need two or three extra layers of indirection to unit test a controller? OF COURSE it's worth it.
...
Above all, you do not let your tests drive your design, you let your design drive your tests! The design is going to point you in the right direction of what layer in the MVC cake should get the most test frosting.

When you stop driving your design first, and primarily, through your tests, your eyes will open to much more interesting perspectives on the code. The answer to how can I make it better, is how can I make it clearer, not how can I test it faster or more isolated.

The design integrity of your system is far more important than being able to test it any particular layer. Stop obsessing about unit tests, embrace backfilling of tests when you're happy with the design, and strive for overall system clarity as your principle pursuit.


P.P.S. Стоит ещё упомянуть статью Сергея Теплякова «Тестируемый дизайн vs. хороший дизайн». Статья не направлена против юнит-тестирования, но в ней явно проводится мысль о том, что "хороший дизайн" стоит на первом месте.
monco83: (Default)
https://ayende.com/blog/3955/repository-is-the-new-singleton

This venerable structure is almost sacred for many people. It is also, incidentally, wrong.

The main problem is that the data access concerns don’t end up in the business layer. There are presentation concerns that affect that as well.


Хоть кто-то осмелился прямо об этом написать. Нет, действительно, поразительно мало пишут об этой проблеме (прочтите статью, чтобы мне не тратить слов на описание самой проблемы). Столько слов мужами от программирования написано про "separation of concerns" или про "inversion of control", и всё это, разумеется, при соблюдении всех принципов SOLID... А вот то, что добавление одного фильтра на форме репорта прошьёт всю вашу структуру до самых SQL-кишков, об этом, почему-то, мало кто хочет задумываться (хотя программисты сталкиваются с такими вещами постоянно). Особенно интересно, когда таким фильром является галочка типа "срочные (актуальные, приоритетные) заявки", появившаяся в одном из репортов по воле левой пятки какого-нибудь "насяльника", и которая представляет собой комбинацию условий вида "status not in (closed, canceled) and ((due_date-getdate())<5 or (due_date-getdate()<10 and IsVip))". Вот тут и задумаешься: с точки зрения программистского фэншуя к какой зоне ответственности эту функцию отнести: к Presentation Layer, к Business Layer или к Data Access Layer? Вы можете прочитать пятисотстраничный майкрософтовский талмуд Application Architecture Guide, но ответа на такие вопросы вы там не найдёте (вообще, на редкость бестолковое издание). Но и в других книгах я ответа на подобные вопросы не встречал. Хотя каждый разработчик с этими вопросами сталкивается едва ли не ежедневно.

В реальном мире .NET проблема эта зачастую обходится с помощью linq-запросов (у автора статьи как раз этот подход и показан). В этом случае DAL вырождается всего лишь в набор соответствующих таблицам IQueryable-коллекций, а логика построения запросов выделяется в какой-нибудь ServiceLayer. Разумеется, при таком подходе безумные фантазии SOLID-пуристов об интерфейсе IDataAccessLayer (меняя реализации которого, как перчатки, мы можем безболезненно заставить наше приложение работать хоть с MsSQL, хоть с Oracle, хоть с данными, хранимыми в XML, хоть с зоопарком legacy-систем) выбрасываются в помойку. Достаточно сказать, что LINQ-провайдеры данных нарушают принцип L. - Liskov substitution. Потому что разбирая дерево выражений различные провайдеры различно интерпретируютт содержимое этого дерева и, в отличие от linq-to-objects, работают лишь с ограниченным количеством пользовательских функций. В EF5+MsSQL функция FirstOrDefault() будет работать, а функция First() уже выдаст NotImplementedException. Как поведёт себя тот же EF5 в связке с Oracle - это вопрос отдельного исследования. Замокав свой IDataAccessLayer классом, который имитирует источники данных, возвращая за фасадом IQueryable обычные массивы или списки, вы можете протестировать свой ServiceLayer вдоль и поперёк, но указанные проблемы обнаружите всё равно лишь при интеграционном тестировании.

А что будет, если вместо смены провайдера базы данных вам придётся работать с хитрым форматом legacy-данных, размазанных по каким-нибудь файлам (наследие COBOL хотя бы)? Вот, получили вы объект типа IQueryable и что станете с этим счастьем делать? LINQ-провайдера данных для вашего случая нет. Никакого. Попробуете распарсить дерево выражений и привести его к виду, пригодному для интерпретации? И тут вы сразу подумаете о том, что удобную для интерпретации структуру данных для фильтра запроса вы могли получить сразу в готовом виде из вашей формочки. Если б не уселись писать "академическое приложение". Короче, как ни крути, немсотря на все потуги к абстракции, ваше приложение в конечном итоге всё равно оказывается пропитанным знанием о КОНКРЕТНОМ типе источника данных (ладно, не всегда, но очень часто).

Старик Энгельс где-то в «Диалектике природы» (лень искать цитату) писал о бессмысленности противопоставления подходов "от абстрактного к конкретному" и "от конкретного к абстрактному" в научном познании. В научном познании эти подходы сосуществуют одновременно и взаимнообусловленно: накопление фактов ведёт к общим выводам, общие выводы позволяют лучше понять конкретные факты. Такая же ситуация и в проектировании программных продуктов - программист всегда ищет абстракцию, которая позволяет понизить уровень сложности задачи, но к этим абстракциям программист идёт от общего понимания задачи во всей её конкретности. И часто случается так, что конкретный аспект оказывает столь весомое влияние на всю структуру приложения, что влияние это не скроешь за фасадом обманчиво соблазнительного "dependency injection".
monco83: (Default)
http://habrahabr.ru/post/147927/
>Я также не знаю, как правильно декомпозировать функционал. В Python или C++, если мне нужна была маленькая функция для преобразования строки в число, я просто писал её в конце файла. В Java или C# я вынужден выносить её в отдельный класс StringUtils.

Profile

monco83: (Default)
monco83

June 2017

S M T W T F S
    123
45678910
11121314151617
18192021 222324
252627282930 

Syndicate

RSS Atom

Most Popular Tags

Style Credit

Expand Cut Tags

No cut tags
Page generated Sep. 20th, 2017 02:23 am
Powered by Dreamwidth Studios