Достоинства автоматического функционального тестирования

Tim Sutherland, “Benefits of automated functional testing”, public translation into Russian from English More about this translation.

Translate into another language.

За последние несколько лет разработки "промышленного" ПО на Java я занимался написанием и поддержкой тысяч модульных тестов, часто работая на проектах, требующих покрытия 80 и более процентов кода. Множество маленьких автоматических тестов, устраняющих зависимости для выделения одного модуля кода, обеспечивающих минимальный уровень дефектов и позволяющих нам уверенно делать рефакторинг кода не боясь всё сломать. Ха! Я пришёл к выводу, что лучше бы удалили 90% этих тестов и сэкономили бы кучу времени и денег

Основная проблема заключается в том, что большинство "модулей" кода в проектах бизнес-ПО тривиальны. Мы не реализуем хитрые алгоритмы или библиотеки, которые могут быть использованы неожиданным образом какими-нибудь третьими лицами. Если вы берёте большинство Java методов и изолируете используемые ими сервисы или компоненты, оказывается, что вы на самом деле тестируете очень немногое. Фактически модульный тест будет выглядеть очень похоже на код, который он тестирует. Он будет иметь те же ошибки, а при изменении метода необходимо будет соответствующим образом измененять тест. Как часто вы находите ошибку благодаря модульным тестам? Как часто вы тратите полдня на исправление ошибочных тестов из-за совершенно невинных изменений кода?

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

Должны ли мы отказаться от автоматического тестирования совсем? Нет. На самом деле я считаю чрезвычайно полезными "функциональные" полностью автоматические тесты. Функциональными я называю тесты, которые проверяют ПО в той или иной степени аналогично тому, как это делает тестировщик-человек, принимающий во внимание общее состояние приложения без проверки результатов каждого возможного отклонения.

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

Достоинства автоматических функциональных тестов

Первоначальное тестирование (smoke testing)

Если мы внедряем какой-то релиз приложения, должны ли из него сыпаться скрепки или исходить ядовитые газы?

Как-то я работал на проекте где при приближении как нам казалось "финального" релиза мы в конце каждого дня собирали релиз для ручного тестирования. На следующее утро тестировщики пытались проверить насколько простых сценариев и приложение падало при первом же запуске. Некорректные правки базы данных, ошибки конфигурирования, NullPointerExceptions в критических путях и прочее тому подобное.

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

Новые разработчики

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

Итак, что вам нужно сделать, чтобы получить рабочее и работающее приложение на вашем компьютере? Если повезёт, у вас будут какие-нибудь свежие инструкции о том, как сделать так, чтобы привести приложение к какому-то более или менее работчему состоянию. Затем вам потребуется помощь одного из разработчиков, чтобы он показал вам как выполнить некоторые вещи. Он скажет: "Гм, давай я покажу тебе это на моей машине". Оказывается тебе надо вот так вот подправить справочник в базе данных, взять этот XML файл, который у тебя получится если поменять это, это, и еще это поле, вставить его в эту таблицу с этой дюжиной значений, а затем... оно заработает. Удачи вам, если хотите повторить это самостоятельно.

Однако, если у вас есть некоторые автоматические функциональные тесты, вы можете сказать "запусти этот тест, реализующий простой сценарий, когда всё работает правильно". Чтобы понять, как всё устроено, они могут просмотреть базу данных и выяснить, какая информация была записана, или даже поставить точки останова в отладчике для пошагового прогона приложения.

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

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

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

Для начала необходимо подготовить собственные справочные данные таким образом, чтобы новый разработчик мог сидеть и работать с ними, не внося изменений в базу данных. На самом деле первое, что должен делать любой тест - это очистить содержимое справочников и заполнить их абсолютно минимальным объёмом данных, который необходим для успешного запуска этого теста. Это помогает людям понять важность установки различных значений справочных данных (к сожалению, эти моменты часто очень плохо отражены в документации). К тому же имея независимые данные при добавлении нового теста нам не придётся прыгать через обручи, стараясь не поломать остальные тесты системы. (Кстати, у меня есть удобный способ заполнения справочных данных из кода, возможно я как-нибудь позже поделюсь им.)

Далее, если мы хотим предоставить людям возможность проходить по тесту с помощью отладчика и останавливать его в точках останова для просмотра состояния приложения, мы не можем использовать внутри тестов задерку с помощью вызовов sleep(fixedTime). Вы не можете сказать "приложение должно завершить обработку к текущему моменту" если на самом деле кто-нибудь приостановил её на полчаса. Тесты, использующие sleep медленны и неустойчивы в любом случае.

Эксперименты и конкретные требования

Если клиент спрашивает вас "как приложение будет вести себя в таком жутко фантастическом случае?", сколько времени вам потребуется для того, чтобы дать ответ? Задокументированные требования возможно не достаточно детализированы, и в любом случае вопрос заключается в том как поведёт себя приложение на самом деле.

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

Специфические случаи

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

Итак, я вставил в код небольшой "хак" и теперь всё работает правильно. Что может помешать другому разработчику придти и снова изменить этот участок кода? Очевидных причин, почему код ведётся именно таким образом, нет - они стали историей. Я могу снабдить код комментариями, написать требования в документации, однако документацию всё равно никто не читает ;-) Более того, хотя мне пришлось сделать эти небольшие изменения, это случилось только благодаря совпадению некоторых особенностей текущей реализации других участков кода.

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

Теперь эти требования не так легко проигнорировать. Бонусные очки!

Исправление ошибки, честно-честно

Тестировщик сообщает о дефекте. "Когда я делаю это, приложение выдает ошибку". Вы проверяете код и обнаруживаете, что ой! Мы совершили великую глупость. Затем следует быстрое исправление, коммит, дефект помечается как исправленный. Через несколько недель тестировщики перепроверяют его и заново открывают дефект, т.к. та же последовательность действий вызывает ошибку в том же месте. Однако, сообщение об ошибке иное! Как разработчик, вы можете попробовать сказать, что исходный дефект был исправлен, что приложение развивается, и ошибка вызвана другой проблемой. Но это никого не волнует.

Если вы напишите функциональный тест для проверки этого сценария (даже если это тест, написанный специально для этого случая, и который есть только у вас), вы cможете быть уверенным, что тестировщик согласится с тем, что ошибка исправлена.

Недостатки автоматических функциональных тестов

На самом деле недостаток всего один - они МЕДЛЕННЫ. Тогда как, при использовании большого числа объектов-заглушек, вы можете легко выполнять сотни модульных тестов в секунду, один простой функциональный тест может работать и пару минут. Это большая проблема.
Всякий раз, когда я говорю о написании специальных тестов, которые принесли пользу, даже если вы не сделали соответствующий коммит, я намекаю, что полезно писать функциональные тесты для всех обнаруженных ошибок, но запускать их все сразу после каждого коммита, или даже ежедневно как средство защиты от ошибок, - не особенно практично.

Т.к. я считаю, что функциональные тесты имеют огромное количество преимуществ, я также считаю, что стоит потратить время на то, чтобы сделать их как можно более быстрыми. Под этим понимается, например, параллельный запуск тестов на кластере (эй, кажется я нашел способ применения "cloud computing" многими компаниями). Мне также кажется, что лучше добавить больше проверок в существующие тесты, чем создавать новые независимые. Вы можете сделать тесты, которые будут зависить от данных, таким образом, что отдельному тесту будет сопоставлена таблица с дюжиной различных вариантов, которые он будет перебирать по очереди, без необходимости запускаться вновь с самого начала.

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

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

(Еще один подход заключается в использовании главной особенности Rails, позже ставшей популярной и в других фреймворках, "интеграционных тестов", работающих на более низком уровне, чем функциональные тесты, но выше чем модульные. Такой тест, к примеру, может запрашивать определённый URL и проверять, что запрос в действительности перенаправлен на другой URL, не используя для этого реальный веб-сервер или браузер. Выбирать уровень, на котором выполнять тестирование, следует в зависимости от типа приложения и простоты имитации действий пользователя.)

На блоге RunCodeRun есть интересная запись, которая называется "Испортить сборку - это нормально". Там утверждается, что для разработчиков бессмысленно запускать полный цикл медленных тестов перед каждым коммитом. Я согласен и убедился в том, что, запуск нескольких "уместных" функциональных тестов перед коммитом чаще всего уберегает от возникновения поломок в остальных тестах. Изменения можно будет быстро откатить назад, если на основном билд-сервере будут выявлены тесты с отрицательными результатом. (Возможно существуют какие-нибудь технологии контроля версий, полезные в данном случае?)

"Непрерывное внедрение в IMVU: мы делаем невозможное 50 раз в день" - еще одна очаровательная статья. В ней описывается проект, насчитывающий 4.4 часа автоматических тестов, в том числе час тестов запуска копий Internet Explorer'а с симуляцией пользовательского ввода и кликов. Распределив тесты среди десятков серверов, участники проекта могут выполнить все тесты за 9 минут. Более того, они настолько доверяют этим тестам, что их код автоматически внедряется в жизнь после проходждения тестов. Это происходит приблизительно 50 раз в день. Блестяще!

Резюме в заключение

Несмотря на то, что я отбросил преимущества наличия большого числа модульных тестов в проектах, над которыми недавно работал, что делать в том случае, если ваш проект отличается от моих?

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

Вы пишите библиотеку или фреймворк, которые пользователь может применять в тех случаях, про которые вы даже не догадываетесь? У вас большое число "generic"-кода со строгими интерфейсными ограничениями? Чем чревато обнаружение ошибки? Если ваша разработка сосредоточена внутри одной команды, обнаружив ошибку в какой-то части кода, написать функциональный тест и исправить её достаточно легко (кроме случаев, когда даже разработчики, нашедшие ошибку и занимающиеся её исправлением, практически не надеются написать такой тест).

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

Еще одна сторона вопроса, которую необходимо рассмотреть, - это скорость изменениния документально оформленных требований к системе (спецификаций).

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

Я хотел бы продолжить, сказав, что в определённых условиях хорошие тестировщики должны уметь сами, на основании выскоуровневых спецификаций, писать автоматические функциональные тесты с применением предметно-ориентированного языка программирования (domain-specific-language, DSL), реализованного разработчиками. Бизнес-аналитики, тестировщики и разработчики могут использовать такой язык в общении для более чёткого взаимопонимания. Представьте, что вместе с отчётом об ошибке вы получаете соответствующий тестовый пример, который вы можете сразу воспроизвести на своей системе, не гадая о чём речь. При достаточной квалификации большой набор функциональных тестов совместно с высокоуровневыми спецификациями может использоваться для формирования системы с хорошо документированным поведением. К сожалению, "определённые условия", о которых я говорю, называются "мир мечты Тима". В реальном мире вам понадобится как минимум большой первоначальный набор тестов, написанных неплохим разработчиком с хорошим вкусом.

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

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

Original (English): Benefits of automated functional testing

Translation: © rusxg, grab .

translated.by crowd

Like this translation? Share it or bookmark!