Тестируемая архитектура. Часть 1
Второй закон термодинамики гласит: «Невозможен процесс, единственным результатом которого являлась бы передача тепла от более холодного тела к более горячему». Это связано с тем, что вселенная стремится к повышению энтропии или меры неопределенности (равновесия, другими словами). Если, например, не убирать в квартире хотя бы неделю, то поверхности покроются пылью, накопятся разбросанные вещи и прочие результаты жизнедеятельности.
Удивительно, но в исходном коде программ происходят аналогичные процессы. Любой разработчик знает, что со временем над проектом становится все сложнее работать, реализация новых требований начинает даваться с большим трудом. А с каким страхом мы вносим изменения в то, что уже сделано? С каким отвращением и горечью иногда приходится работать с функцией, написанной разработчиком-неумехой (а иногда оказывается, что сам когда-то все это и написал)! Как исправление одного дефекта порождает несколько новых… Какие решения иногда порождает воображение лишь бы случайно, чего лишнего не сломать, но реализовать то что требуется. Смысла продолжать дальше нет. Любой сколько-нибудь опытный разработчик и так поймет, о чем речь.
Является ли данная негативная тенденция неизбежной частью жизни любого проекта и программиста? Или, быть может, инженер способен взять ситуацию в свои руки, замедлить устаревание проекта или даже повернуть данный процесс вспять? Для того чтобы ответить на данный вопрос, необходимо начать с самых основ, а именно из того из чего состоит любая программа.
Две ценности
Любую программу можно разбить на две составляющие:
Поведение — это наблюдаемые пользователем эффекты (изображение на дисплее, звуки, осязательные ощущения и т. п.), то, как воспринимается приложение, то, за что прежде всего платит заказчик, то, на что заводят дефекты при тестировании и что требуется реализовать согласно аналитике.
Структура — это скрытые от наблюдателя детали реализации, паттерны, библиотеки, структуры данных, фреймворки, другими словами это все то, что формирует поведение.
Отсюда можно вывести следующие категории сложности возникающие при разработке ПО:
Доменная — естественная сложность, присущая моделируемой области.
Структурная — приобретенная или искусственная сложность, исходящая от выбранной разработчиком структуры ПО.
Естественная сложность — неотъемлемый результат работы программиста. Если в требованиях написано, что должно быть условие с тремя исходами, значит так оно и будет, как бы инженер не пытался спрятать данный факт за различными конструкциями. Разработчик редко имеет возможность как-то повлиять на доменную сложность так как не определяет требования к поведению, которые естественную сложность и формируют. К счастью, домен редко становится основной причиной усложнения ПО.
Обычно главной причиной быстрого устаревания проектов, является приобретенная сложность. Иногда она вырастает настолько, что начинает контролировать домен, подминая требования под себя. Например, могут отклоняться куски функционала по причинам: «архитектура под такое не проектировалась», «это не планировалось» и «на перенос кнопки уйдет больше месяца работ». Насколько последнее является шуткой — решать вам.
Причин и точек влияния структуры программы на ее сложность может быть масса, при всем старании нельзя перечислить их все. Но для наглядности вот некоторые из них:
избыточность паттернов, парадигм, состояний, сущностей и структур;
хрупкость системы, где изменение, скорее всего, породит массу не очевидных дефектов, как в runtime, так и во время компиляции;
ошибки в моделировании предметной области;
несоответствие используемых подходов и компетенций разработки на проекте.
Инструмент изменения ПО
Единственный способ борьбы с ростом искусственной сложности — модификация её единственного источника, а именно структуры. Данная операция встречается настолько часто, что даже получила отдельное название — рефакторинг.
Отметим что, в случае рефакторинга наблюдаемое поведение не должно быть изменено, ведь это может привести к нежелательным отклонениям — дефектам. Ситуация, при которой ранее работоспособное поведение вдруг ломается, называется регрессом. Именно регресс делает рефакторинг непростым в применении инструментом.
Представим на секунду, что существует специальная программа с одной большой кнопкой «check». При ее нажатии на экране одним из двух цветов загорается индикатор: зеленый, если система обладает корректным наблюдаемым поведением, и красный, если что-то сломалось. Такой инструмент под рукой существенно упростил бы процесс рефакторинга и как следствие процесс упрощения программы. Многие уже догадались что у такого помощника есть специальное имя — тесты. Давайте попробуем понять, какими свойствами они должны обладать, чтобы приносить разработчикам пользу.
4 свойства
Защита от регресса. Одной из основных функций тестов является защита от регресса. Именно она избавляет нас от страха случайно изменить поведение приложения при модифицировании деталей реализации. Вся польза от упрощения структурной ценности сведется на нет, если система при этом потеряет в поведении.
Сопротивляемость рефакторингу. Тесты не должны мешать (увеличивать время) основному методу упрощения ПО, а именно рефакторингу. Рефакторинг для тестов (как и для обычного пользователя) должен быть незаметным.
Поддерживаемость. Тесты не должны требовать больших ресурсов на своё написание и изменение, ведь в противном случае вся работа в направлении оптимизации труда потеряет смысл, так как проще будет не писать такие тесты вовсе.
Быстродействие. Если проверка требует много времени для выполнения, то она будет запускаться редко. Чем реже запуск, тем выше вероятность появления дефекта и, как следствие, тем сложнее его отыскать. В каких-то случаях запуски будут игнорироваться вовсе, сводя все усилия на нет. Это также оказывает негативное влияние на динамику и скорость рефакторинга.
Каждый из показателей можно измерить, это означает что они в сумме могут использоваться как средство оценки качества выбранного метода тестирования на проекте.
Давайте немного отвлечемся и отметим одну достаточно интересную взаимосвязь.
Связь ценности ПО и тестирования
Написание теста, на первый взгляд, имеет очевидную цель — верификация наблюдаемого поведения. На самом деле цель зависит от того, что принимается как наблюдаемое в конкретном случае. Так, например, unit-тесты отличаются достаточно сильной привязанностью к структуре системы, потому что исполняют ее в сильно ограниченном окружении и, таким образом, верифицируют не только поведение, но и саму возможность (и простоту) использования компонента в отрыве от его зависимостей. Но не все так просто, существуют программы, часть наблюдаемого поведения которых выражена в их структуре! Они будут представлены немного позднее.
Если подняться выше и задействовать больше элементов системы в проверочных кейсах, абстрагироваться от большинства элементов структуры, то получится уже менее открытая программа, больше напоминающая полупрозрачный ящик, где соотношение связи тестов со структурой и поведением будет в пользу последнего. Это характерно для интеграционных тестов.
Крайней точкой являются тесты, полностью использующие целевую среду выполнения, средства взаимодействия с программой и её восприятия. Таким образом, тесты полностью или в большей части закрываются от структуры ПО и концентрируются исключительно на его поведении.
Из этого можно сделать вывод что классическое разделение тестов на категории есть ничто иное как попытка перечислить разные соотношения ценностей ПО в стратегиях верификации (что проверяется больше, структура или поведение).
Привет, мир
В качестве отправной точки возьмем экстремум, а именно тесты, не зависящие от структуры и поэтому целиком и полностью проверяющие исключительно наблюдаемое поведение (так называемые E2E тесты). Как следствие, они не требуют ничего конкретного от структуры программы, воспринимая ее как один атомарный компонент (монолит):
Зеленым цветом будут выделяться тестируемые элементы системы (в данном случае тестируется вся система).
При таких условиях сопротивляемость рефакторингу будет максимальной, равно как и протекция от регрессии. К сожалению, есть и минусы: скорость выполнения тестов и высокая сложность их написания.
На диаграмме, чем выше столбец, тем больше «очков» оцениваемый метод тестирования набирает в данной категории. Так, E2E предоставляют отличную защиту от регресса (высокий столбец » Защита от регресса»), но являются сравнительно медленными (низкий столбец «Быстродействие»).
Вот некоторые из причин:
Прямое использование всех или большинства компонентов системы естественным образом увеличивает время выполнения проверок, что может негативно сказаться на частоте запусков, а в некоторых случаях и вовсе тесты будут игнорироваться.
В целом встаёт проблематика методов верификации наблюдаемого поведения. Каким образом проверить что приложение издает правильный звук? Что оно заставляет телефон вибрировать в точности как и когда нужно? Запускает специальные световые индикаторы у устройств? Правильно общается со специальной периферией? Да даже кажущееся простым визуальное тестирование и то имеет множество нюансов, которые разработчики должны учитывать. Всё это негативно влияет на показатель поддерживаемости тестов.
Факт зависимости от внешних компонентов (например, сервера) также усложняет дело. Ввиду отсутствия прямого контроля, поведение таких систем не детерминировано: нет никакой гарантии, что она будет функционировать на момент запуска теста. Даже в случае работоспособности, отсутствует гарантия прогнозируемого времени и формы ответа.
В сумме данные факторы могут сделать тестирование губительным процессом с точки зрения затрат на разработку, поэтому выбор должен быть тщательно обдуман. В противном случае это может вылиться в большие затраты — и по времени, и по бюджету. Тесты станут скорее очередным источником проблемы, чем методом её решения.
Оптимизация
Сам собой встает вопрос:, а каким образом можно изменить подход к тестированию так, чтобы поправить отстающие показатели, но сохранить при этом основную пользу тестов? Низкая поддерживаемость и медленная скорость выполнения — основные недостатки E2E. Соответственно, усилия разработчика должны быть направлены в первую очередь в этом направлении.
Удивительно, но данная проблема может быть решена путем разработки подходящей архитектуры приложения, но обо всем по порядку.
Источники данных
Первым и одним из самых очевидных элементов системы, тяжело поддающихся тестированию, являются запросы к данным. Это не только запросы к «удаленному серверу» в классическом понимании. Здесь подойдут любые функции, которые обладают двумя свойствами: наличие скрытых аргументов и/или внешних эффектов.
Скрытые аргументы
Функция считается зависимой от скрытых аргументов (скрытых — значит не передаваемых в аргументы функции) в случаях, например, чтения данных из БД, состояние которой может отличаться в зависимости от времени запуска тестов. Другим примером будет процедура, возвращающая текущую дату. Она также зависит от состояния внешнего окружения. С точки зрения тестов, такая функция является сложной в верификации, так как отсутствуют очевидные средства контроля результата её выполнения.
Обратным здесь можно выделить процедуры, которые являются идемпотентными. Т. е. такими, результат которых полностью предсказывается их внешними видимыми аргументами.
Внешние эффекты
Внешний эффект в общем случае это любой наблюдаемый результат работы функции, существующий за пределами её возвращаемого значения. Это может быть выброшенное исключение, изменение данных в БД, изменение читаемой глобальной переменной и так далее. Возьмем, к примеру, local storage. Запись значения в это хранилище может повлиять на работу системы, тем самым усложняя процесс верификации поведения, но при этом функция, исполняющая саму запись может вернуть просто undefined (т. е. сигнатура функции не отражает её полного поведения).
Возьмем два сценария: добавления пользователя и отображения списка пользователей. Очевидно, что результат будет разным в зависимости от порядка выполнения этих тестов, так как первый выполняет сайд эффект, влияющий на результат второго.
Важно отметить что функции, содержащие сайд эффекты, являются конечным звеном во всех процессах, результатом которых является наблюдаемое поведение. Наблюдаемое поведение — это именно то, что должно верифицироваться при тестировании, это означает что мы не можем в полной мере избавиться от таких функций при тестировании, иначе сам процесс станет бессмысленным.
Влияние на тестирование
Оба признака в совокупности формируют общее (или глобальное) состояние в системе, что делает невозможным параллельное или селективное выполнение тестов. Порядок и содержание всегда задается строгим образом. Это негативно влияет на время выполнения сценариев тестирования и увеличивает стоимость их поддержки. Также усложняются и сами методы верификации поведения, так как результат тестового сценария может отличаться время от времени (не идемпотентность).
План А
Проблемы, вызываемые грязными (не идемпотентными и/или содержащими сайд эффекты) функциями можно попытаться решить, реализуя механизм ручной установки и сброса целевого состояния (setup и teardown) функции. При этом глобальное состояние необходимо изолировать в рамках конкретного потока выполнения тестов. Это требует полного контроля над таким состоянием, что не всегда возможно. Примером могут служить rand-функции или неизменяемые данные (например, история изменений бизнес-сущности).
Реализация предикатов на корректность поведения может отличаться от случая к случаю. Зачастую, поведение верифицируется не полностью чтобы избежать проблем с не детерминированным поведением. Вместо честной визуальной верификации, например, в виде снимка, используются низкоуровневые проверки наличия элементов на странице, основанные на селекторах UI дерева, что выражается в сниженной защите от регресса и, зачастую, повышенной хрупкости таких тестов.
Такие методы решают только часть проблем при этом слабо маскируя остальные. Множество остальных недостатков, связанных с использованием реальных компонентов системы, остаются по-прежнему не решенными и выражаются в негативных свойствах, рассмотренных ранее.
План Б
Можно ли каким-то образом ограничить негативное влияние грязных функций на процесс тестировании, но при этом сохранить тесты полезными? Для этого попробуем выделить не идемпотентные или обладающие внешними эффектами функции во внешний компонент системы и назовем его Источники данных (также известные как репозитории). Далее можно сказать, что, все то, что находится в этой области структуры не должно покрываться тестами.
Обратите внимание что блок не окрашен в зеленый, так как намеренно не покрывается автоматизированными проверками.
Humble object
То, что источники данных не покрываются тестами, неизбежно приводит к пониженной защите от регресса (ведь кода теперь выполняется меньше). Это можно частично нивелировать, упрощая нетестируемые компоненты. Такая техника имеет особое название — Humble object (скромный объект).
Под скромностью здесь понимается отсутствие комплексной логики внутри компонента. Чаще всего такие компоненты характеризуются своей конкретностью (тяжело поддаются расширению, так как ссылаются на конкретные реализации) и простотой (содержат только тривиальные элементы, в основном обычное делегирование внешним функциям). Они тривиальны и у разработчика нет причин изменять их структуру, это и делает бессмысленным их тестирование.
Таким образом, программист должен обратить особое внимание на отсутствие управляющих конструкций в репозиториях и убедиться, что внутри нет ничего, что потребовало бы дополнительного тестирования.
Прежде чем продолжить определимся с некоторыми понятиями.
Зависимость от конкретной реализации
Зависимость в общем случае выражается в коде как явная ссылка на компонент. Компонент может быть обычной переменной, классом, функцией, типом, библиотекой и так далее.
В примере ниже функция UserList напрямую ссылается на конкретную реализацию getAllUsers. getAllUsers скрыто ссылается на изменяемый источник данных, а значит не является идемпотентной.
Заставить компонент использовать иной источник данных (например, более удобный для тестирования), нельзя не меняя при этом исходного кода UserList
Таким образом, можно сказать, что компонент UserList не является расширяемым с точки зрения данного требования. Это и определяет зависимость от конкретной реализации. В данном случае конкретной функции getAllUsers.
На схеме такая связь отображается следующим образом:
Зависимость от интерфейса
Теперь рассмотрим тот же компонент, но уже с несколько иной структурой:
Теперь функция UserList ссылается не на конкретную реализацию функции, а на ее интерфейс. Это позволяет подменять поведение getAllUsers без изменения исходного кода UserList. Компонент становится расширяемым в данной плоскости.
Изобразить это можно следующим образом:
getAllUsers (часть блока источников данных) реализует (или удовлетворяет) интерфейс GetAllUsers (часть блока интерфейса источников данных), который в свою очередь используется в UserList (часть блока программы).
Заглушки
Расширяемость тестируемого компонента можно использовать следующим образом: можно создать отдельную функцию getAllUsersMock, которая вместо реального запроса к api будет брать данные из локального, неизменяемого источника. Функция легко поддается тестированию, так как лишена проблем, перечисленных ранее. Она является «чистой».
Таким образом у нас образуется два отдельных слоя или группы методов:
Реальные источники данных — методы, работающие с настоящими компонентами/хранилищами в системе, тем самым обладающие негативными свойствами, от которых мы стараемся избавиться. Они используются в реальном окружении (компонент Main на диаграмме).
Источники данных подделки — чистые функции (с некоторыми оговорками, которые мы рассмотрим позднее), которые реализованы таким образом, чтобы максимально упростить процесс тестирования, путем улучшения показателей поддерживаемости и скорости выполнения.
Какими свойствами обладает получившаяся структура? Являются ли источники данных конечным звеном в формировании наблюдаемого поведения, иными словами, каким образом они должны тестироваться и должны ли вообще? Что насчет верификации так называемой бизнес логики? На эти и многие другие вопросы мы ответим с последующих частях постепенно приближаясь к той самой тестируемой архитектуре.