Автор: Sergey Teplyakov
и книге Стива Фримана 'Growing Object-Oriented Software Guided by Tests'
DISCLAIMER: как и многие ревью, в этот раз будет гораздо больше обсуждений темы дизайна и автоматического тестирования, чем непосредственно рецензии книги. Так что, не проходите мимо!
Содержание
- Почему столько мнений?
- Замедляют ли они разработку и окупаются ли они позднее?
- Как тесты влияют на процесс разработки?
- Все ли нужно тестировать и достаточно ли юнит-тестов?
- Тесты вперед или назад?
- Каким должно быть качество тестов?
- Подходы к улучшению кода тестов
- О книге 'Growing Object-Oriented Software Guided by Tests'
В мире разработки ПО нет более неоднозначной темы, чем тестирование. Нет, я не о тестировании ПО в целом, тут все ясно, оно нужно, без него никак. А вот отношение к автоматическим тестам и особенно к юнит-тестам весьма неоднозначное.
Кто-то считает, что автоматическое тестирование ограничивается юнит-тестированием и 100% покрытие кода является обязательным. Есть и такие, кто считает юнит-тесты пустой тратой времени и максимум ограничиваются интеграционными тестами, а все остальное считают проблемами QA отдела. Ну есть и те, кто находится где-то посередине и просто старается использовать инструмент по назначению.
Чем обусловлен такой разброс мнений? Является ли автоматическое тестирование обязательным инструментом или без него можно обойтись? Замедляют ли они разработку и окупаются ли они позднее? Как они влияют на дизайн и процесс разработки? Все ли нужно тестировать и достаточно ли юнит-тестов? Нужно ли писать тесты до проакшн кода или же достаточно думать о контракте и спецификации модуля? Каким должно быть качество тестов?
Я постараюсь дать ответы на эти вопросы, опираясь на свой опыт и мнение авторов книги 'Growing Object-Oriented Software Guided by Tests'.
Почему столько мнений?
У этого вопроса есть простой ответ, хотя он вряд ли будет сильно полезным. Как и в любом вопросе, все дело в людях. Любой инструмент требует времени для изучения, и даже после этого никто не гарантирует, что он будет использован по назначению.
Юнит-тесты иногда навязываются сверху, когда менеджмент решает перейти на новую методологию разработки. При этом, команда без опыта юнит-тестирования и с не слишком высокими навыками дизайна приведет к системе, покрытой тестами, но с высокой стоимостью сопровождения (продакшн кода и тестов) из-за невысокого качества дизайна системы и хрупких тестов.
Тестируемый дизайн не обязательно является хорошим дизайном. Мы можем покрыть тестами большую часть функциональности, слепо выделяя интерфейсы и протаскивая всевозможные зависимости тестируемым классам. Но в результате мы придем к хрупким тестам, хрупкому дизайну, и, возможно, нерабочему приложению, поскольку 5 автономно работающих модулей не гарантируют успешной их совместной работы.
Автоматическое тестирования это не только практика конкретного разработчика. Чтобы тесты были полезными, должен быть соответствующий опыт, процессы разработки и подходы к дизайну.
ЦИТАТАМы встречали команды, которые следуют базовым практикам (написанию и запуску тестов), но получают не тот результат, на который рассчитывали, поскольку не следуют более глубоким процессам, которые лежат за этими практиками.
Замедляют ли они разработку и окупаются ли они позднее?
Несколько лет назад я столкнулся с задачей 'продавить' использование юнит-тестов в нашей команде, для чего нужно было убедить в их пользе разработчиков со стороны заказчика. Интеграционных тестов было немало, а вот модульных тестов не было вовсе.
Существует множество преимуществ автоматизированного тестирования, которые отлично описал Кент Бек в своем выступлении Developer Testing. Здесь и повторное использование затраченных усилий, и документация, и влияние на дизайн, и 'ответственность' разработчика (accountability), но я напирал не на это.
Сколько нужно времени, чтобы проверить работоспособность новой возможности клиентской части нашей системы? Для этого нужно убедиться в работоспособности окружения, обновить базу данных, запустить сервер, 3 сервиса, запустить клиентское приложение, проклацать его до нужного места и ... увидеть, что она (новая возможность) не работает. Вполне возможно, что это именно на этом проекте было вечно разваленное окружение и для проверки даже самой простой возможности требовалось 15 минут, но я уверен, что это не уникальная ситуация.
Конечно, чтобы написать тест некоторой функциональности клиентского приложения, ее придется отделить от логики представления, но если она не слишком тривиальна, то проверить ее юнит-тестами может оказаться самым простым решением даже в краткосрочной перспективе.
Конечно я не говорю, что разработка любой возможности с автоматическими тестами будет быстрее, и сам я далеко не всегда придерживаюсь этого подхода. Но *довольно часто * дела обстоят именно так.
Хрупкие тесты вокруг хрупкого кода замедлят разработку и усложнят сопровождение. Но вменяемые тесты, которые проверяют 'что' делает тестируемый код, не акцентируя внимание на том 'как' он это делает упростят сопровождаемость кода.
ЦИТАТАТакже следует различать внешнее и внутреннее качество: внешнее качество определяет, насколько хорошо система отвечает потребностям заказчиков и пользователей (является ли она функциональной, надежной, доступной, отзывчивой и т.п.), а внутреннее качество говорит о том, насколько хорошо система отвечает потребностям ее разработчиков и администраторов (насколько ее легко понять, изменить и т.п.). Всем понятен смысл внешнего качества, обычно оно отражается в контракте на разработку ПО. Внутреннее качество не менее важно, но обычно его сложнее достичь. Внутреннее качество позволяет справляться с постоянными и непредвиденными изменения, который, на самом деле, являются неотъемлемой частью ПО. Поддержка внутреннего качества позволяет нам изменять поведение системы безопасным и предсказуемым образом, поскольку оно минимизирует риск того, что изменение потребует серьезных переработок системы.
Как тесты влияют на процесс разработки?
В некоторых методологиях, таких как XP, юнит-тестирование (особенно TDD) является частью методологии разработки, в большинстве же других случаях влияние тестов на методологию не столь явно. Но даже если такое влияние не является обязательным, попытка покрыть приложение автоматизированными тестами обязательно окажет влияние на многие аспекты разработки ПО.
Влияние тестов на дизайн достаточно очевидно. Чтобы проверить поведение класса, как минимум должна быть возможность создать класс в тестовом окружении. Если же класс неявно завязан на внешнее окружение, то сделать это будет крайне сложно. Именно поэтому наличие юнит-тестов приводит к большей гранулярности системы и более четкому интерфейсу и модульности классов (подробнее об этом см. в 'Идеальная архитектура').
ЦИТАТАЕсли мы сталкиваемся с возможностью, которую сложно протестировать, мы не задаемся вопросом, как это сделать, вместо этого мы спрашиваем себя: а почему это сложно сделать? Наш опыт говорит, что если код сложно протестировать, то улучшать нужно именно дизайн.
Типичным подходом к получению тестируемого дизайна является выделение зависимостей класса с передачей их через конструктор. Если это делать бездумно, то очень легко создать систему, каждый класс которой покрыт юнит-тестами, а в целом система является нерабочей (подробнее см. в 'Тестируемый дизайн vs. Хороший дизайн').
Именно по этой причине юнит-тестов недостаточно и нужны более высокоуровневые автоматизированные тесты, которые тестируют целый модуль или подсистему (интеграционные тесты), и систему целиком (end-to-end тесты). Интеграционные тесты в этом случае покажут, как работают классы с реальным окружением и друг с другом, а end-to-end тесты покажут, что некоторая возможность работает корректно с точки зрения всей системы.
При интеграционные тесты также оказывают влияние на тестириуемый код, но на более высоком уровне. Для того, чтобы интеграционные тесты были возможными, система должна быть хорошо структурирована архитектурно: должны быть выделены четкие границы более крупных компонент, с понятными входами и выходами. End-to-end тесты тоже оказывают некоторое влияние на архитектуру, ведь автоматизация требует наличия некоторых программных интерфейсов для взаимодействия с системой, но они все же играют скорее роль пользовательской документации и спецификации системы.
Обычно, когда говорится о TDD, то подразумевается, что разработка 'драйвится' именно модульными тестами, однако авторы книги 'Growing Object-Oriented Programming Guided by Tests' идут дальше:
ЦИТАТАКогда мы реализуем некоторую возможность системы, то мы начинаем с написания приемочного (acceptance) теста, который проверяет ожидаемую функциональность. Пока этот тест не проходит, мы знаем, что данная возможность системы еще не реализована, когда он проходит функциональность готова. При разработке новой возможности, приемочный тест показывает, нужен ли нам код, который мы собираемся написать, что позволяет писать лишь то, что важно в данный момент[ST1] . После написания приемочного теста, для разработки конкретной возможности мы следует циклам тест/реализация/рефакторинг уже на уровне модулей.
Что не менее важно, разработка новой возможности с приемочного теста, позволяет нам смотреть на систему с точки зрения пользователя, понимая, что им нужно, не думая о возможностях с точки зрения реализации.
Этот подход кажется более разумным, чем классический взгляд на TDD.
Как мы свами подходим к разработке некоторой функциональности? Вначале мы должны понять, что же мы хотим сделать с точки зрения пользователя: мы собираем или ожидаем от кого-то некоего описания возможности с высокоуровневой точки зрения. Лишь после этого мы начинаем двигаться (обычно) снизу вверх по иерархии системы, чтобы заполнить недостающие части для реализации этой возможности.
Предложенный подход делает этот процесс более формальным: мы начинаем с acceptance-теста, который подскажет нам, когда можно остановиться, а затем перейдем к дизайну и реализации.
Но здесь мы можем столкнуться с теми же сложностями, что и при попытке покрыть юнит-тестами код, который написан без их учета: система может быть не предназначена для покрытия ее end-to-end тестами ни в каком виде. По своей природе acceptance-тесты могут возаимодействовать с базой данных, с пользовательским интерфейсом или вызывать batch-процесс из командной строки. Но даже в этом случае написание автоматизированного acceptance-теста может быть очень сложным процессом.
Авторы предлагают такой подход: работа над любым новым проектом должна начинаться с автоматизации сборки и развертывания системы, после чего начинается разработка работающего скелета приложения/модуля (см. Build Pattern: Shipping Skeleton). И только после этого начинается 'выращивание' или наслаивание функциональности приложения.
ЦИТАТАНичего не дает лучшего представления о процессе, чем попытка его автоматизации.
Если посмотреть на автоматическое тестирование с такой стороны, то их влияние на процесс разработки (анализ, планирование, архитектуру, дизайн и кодирование) станет более очевидным.
Все ли нужно тестировать и достаточно ли юнит-тестов?
Хороший дизайн системы, высокая степень покрытия тестами или количество комментариев в коде не являются самоцелью. Мы инвестируем свое время в дизайн, поскольку он может помочь нам понять требования и упростить сопровождение в будущем. Мы пишем комментарии, чтобы отразить свои ключевые решения и помочь себе же в будущем. И мы пишем тесты, чтобы проверить работоспособность решения сейчас и застраховаться себя от случайных изменений в будущем. Но каждый раз нам нужно понимать, что ничего из этого не является конечной целью.
Вся возня с объектной декомпозицией и хорошим дизайном предназначены для борьбы со сложностью. Мы хотим выделить наиболее сложные аспекты системы в автономные модули, чтобы была возможность думать о них, не обращая внимание на незначительные детали. И здесь ценность юнит-тестов будет максимальной. Но что если основная сложность заключается в интеграции модулей между собой? Или вы пишите кастомный WCF канал, где вся логика заключается в переопределении нужных методов? Очевидно, что в этом случае значительно полезнее будут не модульные, а интеграционные тесты.
ЦИТАТАКак много должно быть юнит-тестов, основанных на мок-объектах для разрыва внешних зависимостей, и как много должно быть интеграционных тестов? Мы не думаем, что есть единый ответ на этот вопрос. Все сильно зависит от контекста команды и ее окружения. Лучшее, что мы можем получить от тестов (которые являются важной частью TDD), так это уверенность в том, что наши изменения ничего не сломают: страх убивает прогресс. Сложность заключается в том, чтобы эта уверенность была оправданной.
Большинство проектов находятся где-то посередине и автоматические тесты должны содержать как юнит-тесты, так и интеграционные тесты, которые проверяют работу модулей между собой и с реальным окружением, получая пирамиду, показанную ранее.
Тесты вперед или назад?
Должен ли разработчик писать тесты до кодирования, во время или сразу после? Мне кажется, не вполне честным говорить человеку 'как' он должен выполнять свою работу. Можно говорить о том, 'что' разработчик должен сделать и какие артефакты должны появиться в результате, при этом каждый вправе выбирать свой собственный подход.
Именно поэтому я считаю не корректным требовать писать тесты до кода, следуя именно мелкими шагами (падающий тест, код, рефакторинг). Размер шага переключения тест-код не могут определяться тим-лидом или ПМ-ом, размер переключения зависит от конкретного человека и его комфортного ритма переключения между задачами. Если мне комфортнее вначале набросать диаграмму классов, потом накидать классы с их контрактами, потом набросать реализацию, чтобы проверить свои мысли и лишь потом переходить к тестам, то это мое личное право.
ЦИТАТАЛюбой код, включая тестовый код, должен подчеркивать, что он делает, а не как, чем больше деталей реализации содержится в тестовом методе, тем сложнее читателю кода понять, что из этого является важным. Мы стараемся убрать из тестового метода все, что не улучшает его описательных характеристик в терминах бизнес-области и тестируемой возможности. Иногда это требует реструктурирования кода, а иногда просто игнорирования синтаксического сахара.
На самом деле, TDD это не столько test-first development, сколько specification first development. Юнит-тесты в этом случае не просто проверяют, что некоторый if statement был выполнен, они проверяют некоторый аспект поведения, о котором мы подумали еще до реализации. Но что если для спецификации я использую контракты, которые справляются с этой задачей замечательно, а для полного понимания, 'что' должен делать класс мне удобнее набросать реализацию и понять 'как' он это будет делать?
ЦИТАТАНаписание теста до кода требует, чтобы мы сосредоточились на наших намерениях, и мы не начинаем разработку следующего куска, пока не получим од означного описания того, что же должны получить в результате. Процесс написания тестов до кода помогает нам увидеть, что дизайн является слишком жестким или нечетким.Мы также заметили, что написание тестов до кода позволяют нам получить быструю обратную связь о качестве наших идей в отношении дизайна попытка сделать код пригодным для тестирования обычно делает его более ясным и модульным.
Я уважаю мнение авторов 'Growing Object-Oriented Software Guided by Tests' по поводу написания тестов до кода, поскольку их подход основан на логике и аргументах, а не эмоциях. Тем не менее, я оставляю за собой и своими коллегами право самостоятельно решать, как им удобнее всего понимать, что же должна делать система: с помощью тестов или каким-то другим способом.
Каким должно быть качество тестов?
Одним из главных аргументов против автоматизированного тестирования явялется увеличение объема кода, который придется поддерживать. И это действительно так, ведь в зависимости от задачи, юнит-тесты вполне могут увеличить суммарный размер кодовой базы на 50 и более процентов. Такой объем кода сказывается на производительности команды, особенно учитывая более пренебрежительное отношение к качеству кода тестов.
Как и в вопросах сложности, у хрупкости тестов есть неотъемлемая (essential) и привнесенная или случайная (accidental) составляющие. С одной стороны, тесты должны и будут изменяться при изменении требований, поскольку тест говорит о том, 'что' должен делать тестируемый код, то при изменении предполагаемого поведения тест будет изменен (это часть неотъемлемой хрупкости тестов).
На практике же тесты ломаются гораздо чаще. Хотя считается, что тесты должны быть страховочным тросом во время рефакторинга, на практике практически любой рефакторинг приводит к изменению огромного количества тестов. Изменение реализации тоже часто приводит к поломке тестов, хотя по идее этого быть не должно. Добавление функционала (типа добавление еще одной зависимости) приводит к веерным обновлениям большого количества тестов.
ЦИТАТАХрупкость тестов определяется не только тем, как написан сам тест, она также связана с дизайном системы. Если объект сложно отвязать от его окружения, поскольку зависимостей слишком много или они скрыты, тест будет падать при изменении отдаленных частей системы. При этом становится сложным оценить эффект домино при изменении кода. Поэтому хрупкость тестов можно использовать в качестве ценной обратной связи о качестве дизайна системы.
Причин у такого положения как минимум две. Во-первых, это невысокое качество тестируемого кода (как сказано в цитате выше), а во-вторых, отсутствие отношение к тестам, как к продакшн коду. Первая проблема более сложная, и выходит за рамки данного обсуждения (хотя неоднократно обсуждается в книге), поэтому давайте остановимся на второй составляющей.
Подходы к улучшению кода тестов
Проверять 'что', а не 'как'. Юнит-тесты не должны тестировать if statement, они должны тестировать соответствующий аспект класса. Не нужно тестировать закрытые методы, нужно относится к тестируемому коду, как к черному (максимум полупрозрачному) ящику. Чем абстрактнее тест, тем он более устойчивый, поскольку не подрывает инкапсуляции тестируемого кода.
Проводить рефакторинг кода тестов. Стоит выделять базовые классы для тестирования схожего кода, выделять утилитные классы и т.п. В общем, следует использовать те же подходы, что и для поддержания качества продакшн кода и изменять его по мере получения новых знаний о тестируемом коде.
Читабельность тестов и сообщений об ошибках. Как и любой хороший метод, тест должен оперировать на одном уровне абстракции и быть максимально декларативным. Вместо вызова нескольких методов для инициализации тестируемого кода можно сделать фабричный метод, а вместо пяти утверждений можно вызвать метод VerifyThatClassContainsRequiredData(), особенно если такая проверка нужна в нескольких местах.
В книге 'Growing Object-Oriented Software Guided by Tests' этой теме уделяется очень серьезное внимание. Даже продакшн код стоит писать так, чтобы он читался как книга, так что уже говорить за тесты, которые вполне могут выступать в форме спецификации. [Test]public void reportsTotalSalesOfOrderedProducts(){ havingReceived(anOrder() .withLine('Deerstalker Hat', 1) .withLine('Tweed Cape', 1)), havingReceived(anOrder() .withLine('Deerstalker Hat', 1)), TotalSalesReport report = gui.openSalesReport(), report.displaysTotalSalesFor('Deerstalker Hat', equalTo(2)), report.displaysTotalSalesFor('Tweed Cape', equalTo(1)),}
Приведенный код не был написан с самого начала, а неоднократно изменялся и эволюционировал в то, что вы видите. Обратите внимание на его читабельность и полное отсутствие явных секций Arrange/Act/Assert, все это скрыто за более высокоуровневыми методами.
При этом отдельное внимание стоит уделить и диагностическим сообщениям, чтобы они были четкими и понятными. Именно поэтому стандартный процесс TDD авторы предлагают модифицировать, включив явный шаг проверки сообщения упавшего теста:
Использовать соответствующие паттерны. В тестах существует проблема создания тестовых данных. Существует два распространенных подхода: Object Mother и Test Data Builders (вот пост от автора данной книги Нета Прайса Test Data Builders: an alternative to the Object Mother pattern). Первый паттерн содержит набор фабричных методов, возвращающих тестовые данные, а второй предназначен для создания тестовых данных с заданными характеристиками. И если в случае Object Mother вы получаете объект в предустановленном состоянии, то Test Data Builder позволяет задать лишь те атрибуты, которые интересны в данном случае.
Я довольно часто совмещаю использование Test Data Builder с параметризованными юнит-тестами. Так, при разработке Verification Fakes (оболочки вокруг Microsoft Fakes для упрощение тестирования поведения), я использовал этот подход для тестирования моков. Поскольку мок должен запомнить набор действий, которые над ним выполнили корректным образом, то проверку легко параметризировать, сохранив лямбда выражения со списком действий и еще одно лямбда-выражение с ожидаемым результатом. В результате получится код, который генерирует тест-кейсы довольно декларативным образом: [TestCaseSource('GetVerifyWithSequenceOfActionsTestCases')]public void Test_Verify_With_Sequence_Of_Actions( List<,Expression<,Action<,ILogWriter>,>,>, actions, Expression<,Action<,ILogWriter>,>, verificationExpression, Times times){ // Arrange var stub = new StubILogWriter(), var mock = new Mock<,ILogWriter>,(stub), // Act foreach (var a in actions) { a.Compile()(stub), } // Assert mock.Verify(verificationExpression, times),} public IEnumerable<,TestCaseData>, GetVerifyWithSequenceOfActionsTestCases(){ var builder = new Builder<,ILogWriter>,(), yield return builder.Do(lw =>, lw.Write(42)) .Do(lw =>, lw.Write(42)) .Expects(lw =>, lw.Write(42), Times.Exactly(2)),}
Исходный код билдера FluentTestCaseBuilder.cs, дополнительные примеры использования MockActionsParametrizedTests.cs.
Этот же подход может использоваться для создания тестовых данных, когда билдер по умолчанию создает валидный объект в некотором неитральном состоянии, а тестовый код переопределяет требуемые атрибуты.
О книге 'Growing Object-Oriented Software Guided by Tests'
Обилие цитат в этой заметке должно дать некоторое представление об этой книге и моего мнения о ней. Если этого недостаточно, то готов выразиться более четко: 'Growing Object-Oriented Software Guided by Tests' это одна из лучших книг об ООП и юнит-тестирования, которую я держал в руках. Она отлично дополняет пару других книг: 'The Art of Unit Testing' Роя Ошерова и 'Dependency Injection in .NET' Марка Симана, но является несколько более продуманной и четкой.
В книге есть все: начиная от связи ООП и ФП (да, авторы сочетают оба эти подхода, ООП на верхнем уровне, ФП и неизменяемость на уровне реализации), заканчивая перечнем типов зависимостей и тестированием асинхронного и многопоточного кода.
При этом авторы действительно знают, о чем пишут. Как никак, именно они изобрели понятие Mock Object и написали jMock. Примеры их кода весьма реалистичны за счет использования сквозного примера, и очень показательны. Любовь к мокам зачастую приводит к несколько необычному дизайну (с моей точки зрения), а попытка юнит-тестирования многопоточного кода несколько пугает своей сложностью, но, в целом, полезных мыслей множество.
ЦИТАТАОбъектно-ориентированная система является графом взаимодействующих объектов. Система строится путем создания объектов и последующего их объединения таким образом, чтобы они могли обмениваться сообщениями между собой. Поведение системы является возникающим свойством композиции объектов благодаря выбору объектов и способа их взаимодействия.
В целом же, это одна из лучших книг о дизайне и юнит-тестировании!
Оценка: must have!
З.Ы. Все примеры в книге на Java, но это нисколько не напрягает.
Дополнительные ссылки
Официальный веб-сайт книги 'Growing Object-Oriented Software Guided by Tests'.
Другие книги о дизайне и тестировании:
- 'The Art of Unit Testing' Роя Ошерова
- 'Dependency Injection in .NET' Марка Симана
- Объектно-ориентирование конструирование программных систем Бертрана Мейера
Другие посты по этой теме:
- Тестируемый дизайн vs. хороший дизайн
- Идеальная архитектура
- Как тестировать закрытые методы
- Параметризованные юнит-тесты