Автор: James O Coplien
Перевод: Епишев Александр
1.1 Наши дни
Во времена FORTRAN, когда функция была функцией, иногда заслуживающей функциональных проверок, юнит-тестирование было одним из главных составляющих. Компьютеры производили вычисления, в то время как функции и процедуры представляли собой вычислительные блоки. В те времена доминирующий подход в дизайне предполагал создание комплексной внешней функциональности из более мелких кусков, которые, в свою очередь управляли еще более мелкими, и так далее, вплоть до уровня хорошо понятных примитивов. Каждый слой поддерживал находящийся над ним слой. В целом, у вас были большие шансы отследить, как функциональность на самом дне, так называемые функции и процедуры, были связаны с требованиями, выраженными в доступном человеку интерфейсе. Можно было рассчитывать, что хороший дизайнер поймет бизнес цель той или иной функции. Такими же возможными для понимания были и взаимосвязи в дереве вызовов, как минимум в хорошо структурированном коде. Вы могли мысленно смоделировать выполнение кода во время код-ревью.
Постепенно объектное ориентирование штурмом захватывает мир, а мир проектирования, и вовсе, переворачивает вверх дном. Во-первых, проектируемые блоки превратились из вычислительных единиц в маленькие гетерогенные композиты, называемые объектами и совмещающие в себе несколько программных артефактов, включая и функции, и данные внутри одной обертки. Объектная парадигма использовала классы для оборачивания нескольких функций вместе с общими для этих функций спецификациями данных. Подобный класс становился шаблоном, из которого в ран-тайме создавались объекты. В таком вычислительном контексте, необходимая функция определяется во время выполнения и не может быть вызвана из исходного кода, как это было возможно с FORTRAN. Это привело к невозможности разобраться во взаимосвязях поведения кода в ран-тайме лишь за счет анализа. Теперь вам необходимо запустить программу для того, чтобы получить малейшее представление о том, что же в ней происходит.
Итак, тестирование снова вступает в игру. На этот раз - юнит-тестирование на стероидах. Объектно-ориентированное сообщество обнаружило ценность ранней обратной связи, обусловленной возрастающей скоростью техники, а также появлением всё большего числа персональных компьютеров. Дизайн становится более сфокусированным на данных, так как объекты всё больше конструируются исходя из структур этих данных, чем каких-либо свойств своих же методов. Нехватка явно выраженной структуры вызовов усложнила возможность поместить выполнение какой-либо атомарной функции в контекст своего же выполнения. И насколько бы малым ни был шанс для подобной возможности, и тот был отнят полиморфизмом. В результате, интеграционное тестирование оказывается вне, а первоочередную роль принимает на себя юнит-тестирование. Где-то на фоне всё ещё виднелось системное, но, скорее уже как проблема кого-то другого, или, что еще более опасно, как один из вариантов более зрелой версии юнит-тестирования, которое проводилось самими же создателями кода.
Классы превратились в объекты анализа, и, в определенной степени, проектирования. Популярной техникой дизайна стали CRC-карты (обычно представляющие Классы, Ответственности и Кооперацию), каждый класс в которых представлен отдельным человеком. Объектная ориентация начала ассоциироваться с антропоморфным дизайном. Классы, к тому же, превратились в единицы администрирования, дизайна и программирования, а их антропоморфная суть усилила стремление каждого создателя класса его протестировать. Поскольку у некоторых методов класса сохранилась такая же контекстуализация, как и у функции FORTRAN, у программистов возникла необходимость предоставлять контекст перед выполнением метода (помните, что мы не тестируем классы и, даже, не тестируем тестовые объекты, единицей функционального теста является метод). Юнит тесты обеспечивали выполнение сценариев драйверами. Моки - контекст состояния окружения (энва) и других методов, от которых зависел тестируемый метод. При подготовке к тесту, тестовые окружения поставляли необходимые средства для создания каждого объекта в его правильном состоянии.
1.2 Лекарство хуже болезни
Конечно же, юнит-тестирование не является проблемой исключительно объектно-ориентированного программирования, de rigueur (лат. "крайней необходимостью"), скорее всего, его сделала комбинация объектной-ориентированности, эджайла, разработки программного обеспечения, а также рост инструментов и вычислительных мощностей. Как консультант, я часто слышу вопросы о юнит-тестировании, включая следующий от одного из своих клиентов, Ричарда Якобса (Richard Jacobs) из Sogeti (Sogeti Nederland B.V.):
Второй вопрос касается юнит-тестов. Если я правильно припоминаю, вы говорили, что юнит-тесты - это пустая трата времени. Во-первых, я был удивлен. Тем не менее, сегодня моя команда сообщила, что их тесты сложнее, чем сам код. (Это не та команда, которая изначально написала код и юнит тесты. Поэтому некоторые тесты застают их врасплох. Текущая команда более высоко квалифицирована и дисциплинирована.) В таком случае, по моему, это пустая трата времени... Когда я ежедневно программировал, то создавал действительно тестируемый код, однако, почти никогда не писал никакие юнит тесты. При этом я заслуживал признание за свой качественный код и почти безошибочное программное обеспечение. Мне хотелось бы разобраться, ПОЧЕМУ такой вариант работал в моем случае?
Вспомните из своего университетского образования, что любую программу можно смоделировать как ленту Тьюринга, и то, на что именно способна эта программа, каким-то образом связано с количеством битов на данной ленте перед её выполнением. Если вы хотите тщательно протестировать такую программу, вам понадобится тест, по крайней мере, с таким же объемом информации: то есть другая лента Тьюринга, как минимум, с таким же количеством бит.
На практике же, особенности языка программирования затрудняют достижение подобной компактности выражения в тесте, поэтому для проведения полного тестирования, количество строк кода в юнит тестах должно было бы быть на порядок выше, чем в самом тестируемом юните. Немногие разработчики признают, что проводят только выборочное или частичное тестирование, многие же остальные скажут вам, что делают полное тестирование, исходя из некоего своего предполагаемого представления о том, что значит полное. К таким представлениям относится заявление типа Каждая строка кода покрыта, что, с точки зрения теории вычислений, является откровенной чепухой, особенно в понимании того, действительно ли код делает, что должен. Более детально мы обсудим эту проблему ниже. Всё же, большинство программистов, понимают юнит тестирование именно так, изначально обрекая его на провал.
Будьте скромны относительно способностей ваших юнит тестов, кроме тех случаев, когда для тестируемого юнита у вас есть оракул в виде внешних требований. Сомнительно, что юнит тесты способны протестировать более одной триллионной от функциональности любого предоставленного метода в рамках разумного цикла тестирования. Придите в себя.
(Здесь, триллион - не риторический прием, а цифра, основанная на различных возможных состояниях, с учетом того, что средний размер объекта представляет собой четыре слова, и, по консервативной оценке, вы используете 16-битные слова).
1.3 Тесты ради тестов и спроектированные тесты
У меня был клиент из Северной Европы, разработчики которого должны были предоставить 40% покрытия кода, для, так называемого, 1-го уровня зрелости программного обеспечения, 60% для 2-го уровня и 80% для 3-го, хотя были и стремящиеся к 100%. Без проблем! Как вы могли бы предположить, достаточно сложная процедура с ветвлениями и циклами стала бы вызовом, однако, это всего лишь вопрос принципа divide et impera (разделяй и властвуй). Большие функции, для которых 80% покрытие было невозможным, разбивались на множество более мелких, для которых 80% уже было тривиальным. Такой подход повысил общий корпоративный показатель зрелости команд всего лишь за один год, потому как вы обязательно получаете то, что поощряете. Конечно же, это также означало, что функции больше не инкапсулировали алгоритмы. Невозможным оказалось понимание контекста выполняемой строки, точнее тех, которые предшествуют и следуют за ней во время выполнения, поскольку эти строки кода больше не имеют прямого отношения к той, которая нас интересует. Такой переход в последовательности теперь происходил благодаря вызову полиморфной функции - гипер-галактической GOTO. Даже если всё, что вас беспокоит, - это покрытие решений (branch coverage), это больше не имеет значения.
Если вы обнаруживаете, что тестировщики дробят функции для поддержки процесса тестирования, вы разрушаете свою системную архитектуру, а, вместе с ней, и понимание кода. Тестируйте на более грубом уровне детализации.
Речь идет о простых объемах кода. Вы можете их уменьшить, однако такой код будет содержать циклы, обходящие стороной теорию информации, и заворачивать множество строк кода в маленькое пространство. Это означает, что тесты, по крайней мере, должны быть такими же вычислительно сложными, как и код. Помимо множества тестов, последние еще и очень время затратные. Для проверки любой разумной комбинации индексов в цикле простой функции могут потребоваться столетия.
Задумайтесь на секунду о вычислительной сложности этой задачи. Под 100% покрытием, я подразумеваю проверку всех возможных комбинаций всех возможных ветвлений, проходящих через все методы класса, которые воспроизводят все возможные конфигурации битов данных, доступные этим методам, в каждой инструкции машинного языка во время выполнения программы. Все остальное - это эвристика, о корректности которой нельзя сделать никаких формальных заявлений. Число возможных путей выполнения с помощью функции невелико: скажем, 10. Перекрестное произведение этих путей с возможными конфигурациями состояний всех глобальных данных (включая данные экземпляра, которые для области видимости метода являются глобальными) и формальных параметров в действительности же очень велико. Перекрестное произведение этого числа с возможной последовательностью методов внутри класса представляется счетно-бесконечным. Если вы возьмете несколько типичных чисел, то быстро осознаете, насколько вам повезло, если получите покрытие лучше, чем 1 из 1012.
Одна из брут-форс атак такой проблемы - непрерывный прогон тестов. Люди смешивают понятие автоматизированных тестов с юнит тестами: иногда настолько, что, когда я критикую юнит-тестирование, они упрекают меня за критику автоматизации.
Если вы пишете тест с целью покрытия как можно большего количества возможных сценариев, тогда можете уже выделять ферму машин для прогона тестов и отслеживания последних результатов 24/7.
Помните, однако, что автоматизированный хлам - это всё ещё хлам. И те из вас, у кого есть корпоративная Lean-программа, могли заметить, что основы производственной системы Toyota, которые лежали в основе Scrum, очень сильно противились автоматизации интеллектуальных задач (http://personeltest.ru/away/www.computer.org/portal/web/buildyourcareer/Agile Careers/-/blogs/autonomation). Более эффективно - это постоянно удерживать человека процессе, что становится еще более очевидным при исследовательском тестировании. Если вы собираетесь что-то автоматизировать, автоматизируйте что-нибудь ценное. Автоматизировать необходимо рутинные вещи. Возможно даже, вы получите еще больше прибыли от инвестиций, если автоматизируете интеграционные тесты, тесты для проверки регрессионных багов, а также системные, вместо того, чтобы заниматься автоматизацией юнит тестов.
Более разумный подход уменьшает объем тестового кода за счет формального проектирования тестов: то есть, формальной проверки граничных условий, большего количества тестов белого-ящика и т.д. Для этого необходимо, чтобы программный юнит проектировался как тестируемый. Вот как это делают инженеры по аппаратному обеспечению: разработчики предоставляют контрольные точки, способные считывать значения c J-Tag микросхем, для доступа к внутренним значениям сигналов микросхем - это равносильно доступу к значениям между промежуточными вычислениями, содержащимися в вычислительном юните. Я настоятельно рекомендую делать подобное на системном уровне, на котором должно быть сосредоточено основное внимание тестирования; я никогда не видел, чтобы кто-то достигал подобного на уровне юнита. Без таких приемов вы ограничиваете себя юнит-тестированием черного ящика.
Я мог бы поверить в формализованный дизайн юнит теста, если бы такое поведение можно было формализовать, то есть, если бы существовал некий абсолютный, формальный оракул корректности, на основе которого можно было бы создать тест. Подробнее об этом ниже. В противном случае это лишь догадка программиста.
Тесты следует создавать с огромной осторожностью. Большинство функциональных тестов должны скорее разрабатываться представителями бизнеса, чем программистами. Юнит тесты должны же ограничиваться теми, которые можно сопоставить с определенными сторонними приемочными критериями.
1.4 Убеждение, что тесты умнее кода, говорит о скрытом страхе или плохом процессе
У программистов существует негласное убеждение о том, что они способны мыслить более ясно (или, скорее, догадываться), во время создания тестов, чем при написании исходного кода, или что в тесте, каким-то образом, содержится больше информации, чем в самом коде. Это просто формальная ерунда. Психология здесь поучительна и важна, потому что именно она, а не какое-то рассчитываемое свойство, больше всего руководит поведением разработчика.
Если ваши кодеры предоставляют в юнит-тестах больше строк, чем в исходном коде, это, вероятно, означает одно из следующего. У них может быть параноидальное отношение к корректности; паранойя вытесняет ясное мышление и инновации, которые служат предзнаменованием высокого качества. Им может не хватать аналитических ментальных инструментов или дисциплины мышления, и хотелось бы, чтобы машина думала за них. Машины хорошо справляются с повторением механических задач, однако дизайн тестов по-прежнему требует тщательного обдумывания. Или может случиться так, что в вашем процессе частая интеграция невозможна из-за плохо налаженных процессов разработки или неподходящих инструментов. Программисты делают всё возможное, чтобы компенсировать подобную ситуацию за счет тестов там, где они хотя бы в какой-то степени обретают контроль над собственной судьбой.
Если у вас большой объем юнит-тестов, оцените обратную связь в процессе разработки. Интегрируйте код чаще; сократите время сборки и интеграции; сократите количество юнит тестирования и перейдите больше к интеграционному.
Проблема также может быть в другом: разработчики не обладают достаточно развитыми навыками проектирования, или же процесс не стимулирует архитектурное мышление и добросовестный подход к дизайну. Возможно, требования настолько плохи, что разработчики даже и не поняли бы что нужно протестировать, если бы им пришлось это сделать, поэтому всё, чем они руководствуются - это их лучшие догадки. Исследования программной инженерии показали, что наиболее экономически эффективным для исправления ошибок является момент перехода от анализа к проектированию, сам дизайн, а также дисциплина кодирования. Намного проще избежать ошибок, чем их устранять.
Если у вас есть комплексные юнит тесты, но по-прежнему наблюдается высокий процент ошибок в системных тестах или общее низкое качество, нет необходимости автоматически винить в этом тесты (ни юнит-тесты, ни системные). Тщательно проанализируйте свои требования и процесс проектирования, а также его связь с интеграционными тестами и системными тестами.
Тем не менее, будем честны, ошибки будут всегда. Тестирование никуда не денется.
1.5 У тестов с низким уровнем риска низкая (даже потенциально отрицательная) отдача
Как то я озвучил своему клиенту предположение о том, что множество их тестов могут быть тавтологическими. Предположим, задача какой-то функции - это присвоение X значения 5, и я готов поспорить, что существует соответствующий тест для данной функции, который, после запуска, проверяет, равняется ли X 5. Снова же, хорошее тестирование, основывается на тщательном размышлении, а также базовых принципах управления рисками. Управление рисками строится на статистике и теории информации; если тестировщики (или, по крайней мере, менеджер по тестированию) не обладают хотя бы элементарными навыками в этой области, вы, с большой вероятностью, создаете множество бесполезных тестов.
Разберем тривиальный пример. Цель тестирования - предоставить информацию о вашей программе. (Тестирование само по себе не повышает качество; это делают программирование и проектирование. Тестирование лишь сообщает об упущениях команды в создании правильного проектирования и соответствующей реализации.) Большинство программистов хотят услышать информацию о том, что их программный компонент работает. Поэтому, как только в проекте трехлетней давности была создана первая функция, тут же для нее был написан и юнит тест. Тест ни разу не падал. Вопрос: Много ли информации содержится в этом тесте? Другими словами, если 1 - это успешно выполненный тест, а 0 - упавший, тогда сколько будет информации в следующей строке результатов:
11111111111111111111111111111111
Существует несколько возможных ответов, обусловленных видом применяемого формализма, хотя большинство из них не верны. Наивный ответ - 32, однако, это биты данных, а не информации. Возможно, вы информационный теоретик и скажете, что количество битов информации в однородной двоичной строке равносильно двоичному логарифму длины этой строки, которая в данном случае равна 5. Однако это не то, что я хочу знать: в конце концов хотелось бы понять, сколько информации можно получить после одноразового прогона такого теста. Информация основывается на вероятности. Если вероятность успешного прохождения теста равняется 100%, тогда, по определению теории информации, этой информации нет вообще. Ни в одной из единиц указанной выше строки не содержится почти никакой информации. (Если бы строка была бесконечно длинной, то в каждом тестовом прогоне было бы ровно ноль битов информации.)
Далее, сколько бит информации в следующей строке тестовых прогонов?
1011011000110101101000110101101
Ответ... намного больше. Вероятно, 32. Это означает, что в каждом тесте содержится намного больше информации. Если мы изначально не способны предсказать, пройдет ли тест успешно или нет, тогда каждый запуск теста содержит полный бит информации, и добиться чего-то лучшего вы не сможете. Видите ли, разработчики любят поддерживать тесты, которые проходят успешно, потому что это подогревает их эго и уровень комфорта. Однако, информация поступает от упавших тестов. (Конечно же, мы могли бы взять другую крайность:
00000000000000000000000000000000
в которой, фактически, нет никакой информации, в том числе, даже о процессе улучшения качества.)
Если вы хотите сократить объем тестов, первое, что следует сделать, - это обратить внимание на те, которые ни разу за год не упали, и рассмотреть вопрос об их удалении. Такие тесты не предоставляют вам никакой информации, или, по крайней мере, очень мало информации. Ценность производимой ими информации может не оправдывать затрат на поддержку и выполнение таких тестов. Эти тесты - первые претенденты для удаления, и не имеет значения, это юнит тесты, интеграционные или системные.
У другого моего клиента так же было слишком много юнит тестов. Я отметил, что подобная ситуация замедлит скорость разработки, потому как каждое изменение функции потребует изменения соответствующего теста. В ответ мне сообщили, что тесты написаны таким образом, чтобы их не нужно было обновлять при изменении функциональности. Разумеется, это означает, что тесты не проверяли функциональность, поэтому всё, что не тестировалось бы такими тестами, не имело особой ценности.
Не стоит недооценивать интеллект своих людей, как и не стоит недооценивать коллективную глупость множества совместно работающих людей в сложном домене. Возможно, вы подумали, что никогда не допустили бы ошибок описанной выше команды, но я всегда нахожу все больше и больше таких случаев, в которые сложно поверить. Скорее всего и у вас в шкафу есть какие-то из этих скелетов. Отыщите их, посмейтесь над собой, исправьте их и двигайтесь дальше.
Если у вас есть подобные тесты - это второй претендент на удаление.
Третий набор для удаления - тавтологические тесты. Я сталкиваюсь с ними чаще, чем вы можете себе представить, особенно среди последователей, так называемой, разработки через тестирование (TDD). (Кстати, проверка this на ненулевое/не пустое (non-null) значение при входе в метод, не является тавтологической, и может быть очень информативной. Однако, как и в случае с большинством юнит тестов, лучше сделать ассершн, чем пичкать свой тестовый фреймворк подобными проверками. Подробнее об этом ниже.)
Во многих компаниях, единственные тесты с бизнес-ценностью - это те, в основании которых лежат бизнес-требования. Большинство же юнит тестов основываются на фантазиях программистов о том, как должна работать функция: на их надеждах, стереотипах, а иногда и желаниях, как все должно было бы быть. У всего этого нет подтвержденной ценности. В 1970-х и 1980-х годах существовали методологии, опирающиеся на прослеживаемость (tracebility), и стремящиеся сократить системные требования вплоть до уровня юнитов. В общем, это NP-трудная (нелинейная полиномиальная) задача (если только вы не выполняете чисто процедурную декомпозицию), поэтому я очень скептичен в отношении всех, кто говорит, что способен её решить. В итоге, единственный вопрос, который следовало бы задавать каждому тесту: Если тест упадет, какое из бизнес-требований будет нарушено? В большинстве случаев, ответ: Я не знаю. Если вы не понимаете ценность теста, тогда, теоретически, он может иметь нулевую ценность для бизнеса. У теста есть стоимость: поддержка, время вычислений, администрирование и так далее. Значит, у теста может быть чистая отрицательная ценность. И это четвертая категория тестов, которые необходимо удалять. Такие тесты, не смотря на их способность что-то проверять, в действительности ничего не проверяют.
Если вы не можете объяснить, как сбой какого-либо юнит теста отражается на рисках, следует подумать о возможном удалении такого теста. При отсутствии формальных критериев корректности, существуют более эффективные техники устранения изъянов в качестве, такие как исследовательское тестирование и методы Монте-Карло. (Они великолепны, и я рассматриваю их как отдельную категорию в отличие от того, о чем рассказываю здесь.) Не используйте юнит тесты для подобных проверок.
Обратите внимание, существуют определенные модули, а также тесты, которые дают четкий ответ на вопрос о ценности для бизнеса. Одни из таковых - это регрессионные тесты; они, однако, редко пишутся на уровне модульных тестов, больше - на системном. При падении регрессионного теста, в силу его внутреннего устройства, мы точно знаем об особенности ошибки. Кроме того, в определенных системах существуют ключевые алгоритмы, типа алгоритмов сетевой маршрутизации, которые можно протестировать за счет API. Как я уже отмечал выше, для таких API существует формальный оракул, на основании которого можно создавать тесты. Такие юнит тесты имеют ценность.
Подумайте, должна ли основная часть ваших юнит тестов проверять ключевые алгоритмы, для которых существует оракул успешности, предоставленный третьей стороной, а не той же командой, которая пишет код. Понятие успешности здесь должно отражать бизнес-видение, а не, скажем, мнение члена команды, называемого тестировщиком, которое ценится только лишь потому, что оно независимо. Хотя, конечно же, независимая оценка тоже важна.
1.6 Сложное - сложно
Существует следующая дилемма: большая часть интересных показателей о качестве определенных программ находиться в распределении результатов тестирования, несмотря на то, что традиционные подходы к статистике, всё же, предоставляют ложную информацию. Так, в 99,99% всех случаев тест может быть успешным, но однажды упав за десять тысяч раз, он убьет вас. Опять же, заимствуя аналогию из мира железа, для уменьшения вероятности ошибки до сколь угодно низкого уровня, вы можете всё проектировать с учетом заданной вероятности отказа или же провести анализ наихудшего случая (WCA). Специалисты по аппаратному обеспечению обычно используют WCA при проектировании асинхронных систем для защиты от сбоев в сигналах, выходящих за пределы проектных параметров: один сбой на 100 миллионов раз. В области аппаратного обеспечения, сказали бы, что коэффициент качества (FIT rate) такого модуля равняется 10 - десять отказов на триллион (Failures In a Trillion).
Клиент, упомянутый мной в начале этой статьи, был озадачен причиной отсутствия положительных результатов тестирования в своей текущей команде, по сравнению с предыдущей работой. Я отправил ему более раннюю версию данной статьи, на что он ответил:
Приятно читать статью, проливающую свет на причину моего успеха (и остальной части моей команды). Возможно, Вы уже знаете, что я инженер по авионике, чья карьера началась с разработки встраиваемого программного обеспечения, и, отчасти, разработки оборудования. И вот с таким образом мышления, ориентированным на особенности работы оборудования, я начал тестировать свое программное обеспечение. (Команда состояла из четырех человек: 3-х инженеров-электриков из Делфтского университета (включая меня, в качестве специалиста по авионике) и одного инженера-программиста (из Гаагского университета). Мы были очень дисциплинированы в разработке систем безопасности для банков, пенитенциарных учреждений, пожарных, полицейских участков, служб экстренной помощи, химических заводов и т.д. В каждом из случаев всё должно было правильно заработать с первого раза.)
Обычно, при наличии разумных предположений, вы можете провести WCA аппаратного обеспечения, из-за легко прослеживаемых причинно-следственных связей: можно взять схему и разобраться в причинах изменения состояний в элементах памяти. Состояния в машине фон Неймана изменяются в результате побочных эффектов (side effects) выполнения функции, и, как правило, отследить причину такого изменения нереально, даже если некое состояние является достижимым. Объектная ориентация еще больше усугубляет ситуацию. Невозможно понять, какая из инструкций последней изменила состояние, используемое программой.
Большинство программистов убеждены, что построчное покрытие исходного кода, или, по крайней мере, покрытие ветвлений является вполне достаточным. Нет. С точки зрения теории вычислений, покрытие наихудшего случая означает анализ всевозможных комбинаций в последовательностях работы машинного языка, при котором гарантируется достижение каждой инструкции, а также - воспроизведение каждой возможной конфигурации битов данных в каждом из значений счетчика команд выполняемой программы. (Недостаточна и симуляция состояния среды выполнения только лишь для модуля или класса, содержащего тестируемую функцию или метод: как правило, любое изменение в каком-либо месте может проявиться в любом другом месте программы, а поэтому, потребует повторного тестирования всей программы. Формальное доказательство предложено в статье: Перри и Кайзера (Perry and Kaiser), Адекватное тестирование и объектно-ориентированное программирование (Adequate Testing and Objectoriented Programming), Журнал объектно-ориентированного программирования 2 (5), январь 1990 г., стр. 13). Даже взяв небольшую программу, мы уже попадаем в такое тестовое окружение, количество комбинаций в котором намного превышает количество молекул во Вселенной. (Мое определение понятия покрытие кода - это процент всех возможных пар, {Счетчик команд, Состояние системы}, воспроизводимых вашим набором тестов; все остальное - эвристика, которую, очевидно, вам сложно будет как-либо обосновать). Большинство выпускников бакалавриата смогут распознать проблему остановки (Halting Problem) в большинстве вариантов подобных задачах и поймут, что это невозможно.
1.7 Меньше - это больше или вы не шизофреник
Вот еще одна проблема, которая имеет особое отношение к первоначальному вопросу моего клиента. Наивный тестировщик пытается извлечь множество данных из результатов тестирования, при этом постоянно поддерживая все существующие тесты или даже добавляя новые; это приводит к точно такой же ситуации, в которой оказался мой клиент, когда сложность тестов (объемы кода или какие-только-хотите-метрики) начинает превосходить сложность исходного кода. Тестируемые классы - это код. Тесты - это код. Разработчики пишут код. Когда разработчики пишут код, они допускают около трех ошибок, непосредственно влияющих на систему, на каждые тысячу строк кода. Если бы мы случайным образом выбрали участки кода с подобными ошибками у моего клиента, включая тесты, то обнаружили бы, что в тестах содержиться код, который приводит к неправильным результатам чаще, чем реальный баг, останавливающий выполнение кода!
Некоторые мне говорят, что подобное не имеет к ним отношения, поскольку они уделяют значительно больше внимания тестам, чем исходному коду. Во-первых, это просто вздор. (Меня действительно смешат утверждающие, что, с одной стороны, они способны забывать о своих ранее сделанных предположениях во время создания изначального кода, и, с другой, те, кто может привнести свежий и независимый взгляд во время тестирования. Как первые, так и вторые должны быть шизофрениками.) Посмотрите, что делают разработчики при запуске тест-сьютов: они их запускают, но не думают (кстати, это же относится и к большей части Agile манифеста). На моей первой работе в Дании был проект, в значительной степени построенный на XP методологии и юнит тестировании. Я всячески пытался собрать билд на своей локальной машине, и после долгой борьбы с Maven и другими инструментами, наконец-то, мне это удалось. Каким же было разочарование, когда я обнаружил, что юнит-тесты не проходят. Пришлось обратиться к своим коллегам, которые сказали: О, так тебе нужно запустить Maven с вот этим флагом, он отключает вот эти тесты - из-за изменений эти тесты уже не работают, поэтому их необходимо отключить.
Если у вас 200, 2000, или 10 000 тестов, вы не будете тратить время на тщательное исследование и (кхе-кхе) рефакторинг каждого из них каждый раз, когда тест падает. Самая распространенная практика, которую я наблюдал, работая в стартапе еще в 2005 году, - это просто переписать результат старых тестов (ожидаемый результат или результаты вычислений такого теста) новыми результатами. С психологической перспективы, зеленый статус - это вознаграждение. Современные быстрые машины создают иллюзию возможности замены мышления программиста; их скорость намекает на исключение моей необходимости мыслить. Ведь, в любом же случае, если клиент сообщит об ошибке, я, в свою очередь, сформулирую гипотезу о ее действительной причине, внесу изменения, исправляющие поведение системы, и, в результате, с легкостью смогу себя убедить, что функция, в которую я добавил исправление, теперь работает правильно. То есть я просто переписываю результат выполнения этой функции. Однако, подобное - просто лженаука, основанная на колдовстве, связь с которым - причинность. В таком случае, необходимо повторно запустить все регрессионные и системные тесты.
Во-вторых, даже если бы правдой оказалось утверждение о том, что тесты гарантируют качество лучше, чем исходный код, из-за более совершенного процесса или повышенной к ним доли внимания, я бы посоветовал такой команде улучшить свой процесс за счет принятия пилюль, повышающих умственные способности именно во время создания кода, а не тестов.
1.8 Вы платите за поддержку тестов и качество!
Суть в том, что код - это часть вашей системной архитектуры. Тесты - это модули. Тот факт, что кто-то может не писать тесты, не освобождает его от ответственности заниматься проектированием и техническим обслуживанием возрастающего количества модулей. Одна из методик, которую часто путают с юнит-тестированием, но использующая последнее в качестве техники - это разработка через тестирование (TDD). Считается, что она улучшает метрики сцепления и связности (coupling and coherence), хотя, эмпирические данные свидетельствуют об обратном (одна из статей, опровергающих подобное представление на эмпирических основаниях принадлежит Янзену и Саледиану (Janzen and Saledian), Действительно ли разработка через тестирование улучшает качество проектирования программного обеспечения? IEEE Software 25(2), март/апрель 2008 г., стр. 77 - 84.) Еще хуже то, что таким образом, в качестве запланированного изменения, вы уже вводите связанность (coupling) между каждым модулем и сопровождающими их тестами. У вас появляется необходимость относиться к тестам так же как и к системным модулям. Даже если вы удаляете их перед релизом, это никак не сказывается на необходимости их обслуживать. (Подобное удаление может быть даже достаточно плохой идеей, но об этом дальше.)
Я обратил внимание, что подавляющая часть юнит-тестов, особенно созданных с помощью JUnit, представляет собой замаскированные утверждения (assertions). Когда же я создаю какое-нибудь восхитительное программное обеспечение, то засыпаю его подобными утверждениями, описывая ожидаемые мной результаты, и по отношению к тем, кто вызывает мои функции, и в отношении самих функций, которые обязаны что-то предоставить своим клиентам. Такие проверки дорабатываются в том же артефакте, где находится и остальная часть моего кода. Множество энвайерментов предоставляют средства административной стерилизации этих тестов во время релиза.
Более профессиональный подход - оставить эти асершены в коде даже после релиза, автоматически отправлять отчеты об ошибках от имени конечного пользователя и, возможно, пытаться перезапускать приложение каждый раз, когда подобная проверка провалена. В одном из ранее упомянутых стартапов, мой босс настаивал, чтобы мы такого не делали. Я указал, что отрицательный результат проверок означает: в программе что-то пошло совсем не так, и, скорее всего, данная программа выдаст неправильный результат. Даже мельчайшая ошибка в создаваемом нами продукте, может обойтись клиенту в 5 миллионов долларов дополнительных доработок. На что он ответил: Для компании намного важнее избегать видимости чего-то неправильно сделанного, чем останавливаться еще до получения ошибочных результатов. Я ушел из этой компании. Возможно, сегодня вы один из её клиентов.
Превратите юнит-тесты в утверждения (assertions). Используйте их для обеспечения отказоустойчивости архитектуры высокодоступных систем. Это решает проблему поддержки множества дополнительных программных модулей, которые оценивают ход выполнения (программы) и проверяют правильность (её) поведения; это одна часть юнит-тестирования. Другая - это драйвер, выполняющий сам код: в этом отношении, рассчитывайте, на свои стресс-, интеграционные и системные тесты.
Почти последнее, существуют такие юнит-тесты, которые повторяют системные, интеграционные или другие виды тестов. На заре вычислений, когда компьютеры были медленными, вместо того, чтобы дожидаться запуска системных тестов, юнит-тесты предоставляли разработчику более быструю обратную связь о том, сломало ли их изменение код. Сегодня, когда появились более дешевые и мощные компьютеры, этот аргумент кажется менее убедительным. Каждый раз, внося изменения в свое приложение Scrum Knowsy, я тестирую его на системном уровне. Разработчики должны непрерывно интегрироваться и, так же непрерывно проводить тестирование системы, а не сосредотачиваться на своих юнит-тестах и откладывать интеграцию, даже на час. Так что избавляйтесь от юнит-тестов, которые дублируют то, что уже делают системные тесты. Если системный уровень обходится слишком дорого, создайте наборы интеграционных тестов. Рекс (Rex) считает, что следующим большим скачком в тестировании будет разработка таких юнит, интеграционных и системных тестов, которые устраняют случайные упущения и дублирование.
Проверьте свои тестовые наборы на предмет репликации; вы же способны профинансировать это в рамках своих гибких (Lean) программ. В первую очередь, создавайте системные тесты с хорошим функциональным покрытием (а не покрытием кода) - помните, что правильная реакция на неправильные входные данные или другие непредвиденные условия является частью ваших функций.
Последнее: однажды я от кого-то услышал оправдание в необходимости создания (особого) юнит-теста, из-за невозможности проверить некий блок кода с помощью внешнего интерфейса инструмента для тестирования. Если учесть, что подобные интерфейсы хорошо спроектированы и способны воспроизводить различное, наблюдаемое в реальном мире, поведение программы, и вы нашли такой код, к которому не достучаться из вашей тестировочной системы, тогда... удалите этот код! Серьезно, размышление о коде с позиции системных тестов может быть отличным способом для обнаружения мертвого кода. Это даже более ценно, чем поиск ненужных тестов.
1.9 Это процесс, глупец или лихорадка зеленого статуса
Возможно, самая серьезная проблема юнит-тестов заключается в том, что они нацелены на исправление ошибок, а не улучшение системы. Я довольно-таки часто встречал программистов, которые, склонив головы, пытались добиться успешного прохождения и, тем самым, повысить зеленые показатели. Тестировщик формулирует гипотезу, но, находясь в своей изолированной среде, он не может с легкостью получить достаточно информации, чтобы ее можно было подтвердить или опровергнуть. Поэтому он или она просто пытаются сделать хотя бы что-то, дабы понять, приближает ли это вас к зеленому статусу - и насколько это вообще возможно.
Тестирование преследует две потенциальные цели. Одна из них - обучение: для большего ознакомления с программой и тем, как она работает. Вторая - использовать его как оракул. Обычно, люди сталкиваются с ошибками во время обращения именно к этой цели: тест - это оракул, а цель - правильный результат. Они упускают из виду тот факт, что цель определяет обширное понимание, а понимание дает ключ к исправлению ошибки.
Именно поэтому необходимо ненадолго отрываться от терминала. Так вы уходите от рефлекса Павлова завязанного на появлении зеленого статуса и, основываясь на тестах, можете начать выстраивать свое общее понимание шаг за шагом. Когда его будет достаточно, у вас появится костяк общей картины. Если и он будет достаточно полным, ошибка станет очевидной.
Системные тесты почти сразу же погружают вас в подобную позицию размышления. Разумеется, вам все еще нужна будет более подробная информация, для этого на помощь приходит отладка (debugging). Отладка - это использование инструментов и устройств, помогающих изолировать ошибку. Отладка - не тестирование. Во-первых, она представляет собой ad-hoc (интуитивную активность) и, во-вторых, выполняется на основании последовательного перехода от ошибки к ошибке. Юнит тесты могут быть полезным инструментом отладки. На собственном опыте я обнаружил, что лучше всего работает комбинация различных инструментов, среди которых наиболее эффективными являются наборы с невалидными данными и доступ к глобальному контексту, включающий все значения данных и случайную трассировку стека.
1.10 Подводим итоги
Вернемся к моему клиенту из компании Sogeti. Вначале, я упоминал его высказывание:
Когда я ежедневно программировал, то создавал действительно тестируемый код, однако почти никогда не писал никакие юнит тесты. При этом я заслуживал признание за свой качественный код и почти безошибочное программное обеспечение. Мне хотелось бы разобраться, ПОЧЕМУ такой вариант работал в моем случае?
Возможно, Ричард - это один из тех редких людей, которые знают, как думать самому, вместо того, чтобы позволять компьютеру думать за него - будь то проектирование системы или дизайн более низкого уровня. Я чаще наблюдаю подобную ситуацию в Восточноевропейских странах, где отсутствие общедоступного компьютерного оборудования заставляло людей мыслить. Просто не хватало компьютеров. Когда я впервые посетил Сербию в 2004 году, студенты из ФОН (факультета информатики) могли получить доступ к компьютеру для выхода в интернет один раз в неделю. Расплата за ошибку высока: если запуск вашего кода не отработает, придется ждать еще неделю, чтобы повторить свою попытку.
К счастью, я вырос именно в такой культуре программирования, мой код записывался на перфокартах, которые отдавались оператору для установки в очередь машины, а затем, через сутки, собирались результаты. Такой формат действительно заставлял вас или же задуматься - или же, потерпеть неудачу. У Ричарда из Sogeti было аналогичное воспитание: у них была неделя на подготовку кода и всего один час на его запуск. Всё должно было делаться правильно с первого раза. В любом случае, обдуманный проект должен оценивать возможные риски, связанные с затратами, и устранять их по одному в каждой итерации, уделяя особое внимание постоянно растущей ценности. Одна из моих любимых циничных цитат: Я считаю, что недели программирования и тестирования могут сэкономить мне часы планирования. Что меня больше всего беспокоит в культуре раннего провала (fail-fast), так это не столько понятие провала, сколько слово раннее. Много лет назад мой босс Нил Халлер мне сказал, что отладка - это не то, что вы делаете, сидя перед своей программой с отладчиком; это то, что вы делаете, откинувшись на спинку стула и глядя в потолок, или обсуждение ошибки с командой. Однако многие, якобы ярые приверженцы эджайл методологий, ставят процессы и JUnit выше людей и взаимодействий.
Лучший пример, услышанный мной в прошлом году, был от моей коллеги, Нэнси Гитинджи (Nancy Githinji), управлявшей вместе со своим мужем IT-компанией в Кении; сейчас они оба работают в Microsoft. Последний раз, посещая свой дом (в прошлом году), она познакомилась с детьми, которые проживают в джунглях и пишут программы. Они могут приезжать раз в месяц в город, чтобы получить доступ к компьютеру и апробировать свой код. Я хочу нанять этих детей!
Мне, как стороннику эджайла (да и просто из принципа), немного больно признавать, что Рекс оказался прав, как, впрочем-то это было и ранее , достаточно красноречиво сказав: В этой культуре раннего провала (fail fast) есть нечто небрежное, она побуждает швырнуть кучу спагетти на стену, особо даже не задумываясь отчасти, из-за чрезмерной уверенности в заниженных рисках, предоставляемых юнит-тестами. Культура раннего провала может хорошо работать при очень высокой дисциплине, подкрепленной здоровым скептицизмом, однако редко можно встретить такое отношение в динамичном IT-бизнесе. Иногда ошибки требуют обдумывания, а последнее требует больше времени, чем результаты, достигаемые ранним провалом. Как только что напомнила моя жена Гертруда: Никто не хочет, чтобы ошибки затягивались на долго
Если вы наймете профессионального менеджера или консультанта по тестированию, он сможет помочь вам разобраться в вопросах более масштабной картины тестирования: интеграционным и системным тестированием, а также подходящим для них инструментарием и процессами. Это важно. Но не забывайте о позиции продакт овнера, бизнес-аналитика или менеджера в Scrum: управление рисками находится в центре их деятельности, и, возможно, именно поэтому Джефф Сазерленд говорит, что заказчик должен разрабатывать (а в лучшем случае и проектировать) системные тесты для использования в качестве входных данных до или во время планирования спринта.
Что касается Интернета: грустно и откровенно страшно, что там так много всего. Много советов, но очень мало из них подкреплено теорией, данными или даже моделью того, почему необходимо довериться тому или иному совету. Хорошее тестирование немыслимо без скептицизма. Относитесь скептически к себе: измеряйте, доказывайте, делайте повторные попытки. Ради всего святого, отнесись ко мне скептически.
Пишите мне свои комментарии на jcoplien@gmail.com с копией Рексу вначале этого письма.
В заключение:
-
Сохраняйте регрессионные тесты до года, большинство из них должны быть тестами системного уровня, а не юнит-тестами.
-
Оставляйте юнит-тесты, проверяющие ключевые алгоритмы, для которых существует широкий, формальный, независимый оракул правильности и, которому можно приписать бизнес-ценность.
-
Исключая предыдущее заявление, если у X есть определенная бизнес-ценность и вы можете протестировать X системным или же юнит-тестом, используйте системный: контекст - это всё.
-
Разрабатывайте тест более тщательно, чем код.
-
Превратите большинство юнит-тестов в утверждения (assertions).
-
Удалите тесты, которые за год ни разу не падали.
-
Тестирование не способно заменить хорошую разработку: высокий показатель падающих тестов говорит о том, что вам следует сократить интервалы разработки, возможно, радикально, а также убедиться, что у вашей архитектуры и дизайна есть зубы.
-
Если вы обнаруживаете, что определенные тестируемые функции тривиальны, еще раз перепроверьте способы стимуляции производительности разработчиков. Одобрение за покрытие или другие бессмысленные метрики могут привести к быстрому разрушению архитектуры.
-
Будьте скромны в отношении способностей тестов. Тесты не улучшают качество: это делают разработчики.