Статья
Версия для печати
Обсудить на форуме
«Hello World!» в embedded-исполнении. Часть 1

© Dale, 21.09.2011 — 14.10.2011.


Оглавление


Введение

Программирование встроенных систем в настоящее время — это спорная пограничная территория между двумя соседними, но не слишком дружественными областями — программирования и оборудования. Эта территория довольно слабо исследована и малолюдна, поскольку пребывание на ней требует одновременного сочетания навыков программирования и схемотехники в одной персоне, что бывает нечасто.
Программисты, с младых ногтей приученные к работе на готовом оборудовании и, как правило, с готовой операционной системой, пугаются паяльника и низкоуровневой среды, в которой все, от самого верхнего уровня (архитектуры системы) до самого нижнего (обработки прерываний и работы с регистрами устройств), придется реализовать самостоятельно. Специалисты в области электроники, не имеющие навыков профессионального программирования, обычно смелее (ибо предрассудок, что программировать может каждый, весьма распространен) и находят в себе достаточно мужества, чтобы взяться за разработку кода; однако, если задача нетривиальна, этот код оказывается весьма далеким от совершенства, поскольку программная инженерия — сложная дисциплина, овладеть которой без специальной подготовки невозможно.
По причине этих трудностей разработка встроенных систем (особенно в части программного обеспечения) долгое время оставалась в тени, и прогресс в области информатики обходил ее стороной. В настоящее время ситуация несколько улучшается, хотя и не слишком быстрыми темпами. Появляется инструментарий для поддержки процесса разработки firmware с учетом специфики предметной области. Появляются методики рационального использования этих инструментов. Наконец, появляется столь долгожданная литература по предмету; ее пока очень мало, зато она качественная.
На нашем форуме неоднократно поднималась тема разработки firmware. Мы рассматривали по отдельности ее различные аспекты:

  • в статьях [1], [2], [3] описывалось средство для модульного тестирования программного кода, написанного на языке C, под названием Unity;
  • в статье [4] рассказано, как применить Unity и TDD для разработки небольшого модуля по заданной спецификации;
  • в [5] описана обработка исключений в программах на ANSI C с использованием CException;
  • в [6] в общих чертах обозревается весь процесс разработки сложного интеллектуального устройства со встроенными микропроцессорами;
  • в [7] рассказывается о применении паттерна проектирования MCH (Model-Conductor-Hardware) для отделения бизнес-логики модуля от аппаратной части и, как следствие, получения кода, который пригоден как для легкого портирования на различные платформы, так и для тщательного тестирования;
  • в [8] показано, как с применением довольно известного трюка под названием «машина Даффа» можно реализовать выполнение сопрограмм на чистом ANSI C без использования плохо портируемых ассемблерных вставок;
  • в [9] тема сопрограмм развивается дальше — до реализации протопотоков (protothread, средства реализации кооперативной многопоточности).

Здесь перечислены не все, а только базовые источники информации по теме. Дополнительные материалы вы можете найти в обзоре литературы по проектированию встроенных систем [10].
Каждая из упомянутых выше статей рассматривает какой-либо отдельно взятый аспект процесса проектирования. Однако их прочтения недостаточно для того, чтобы получить представление о процессе в целом. Мало просто иметь инструмент в своем распоряжении; недостаточно даже знать его назначение и правила пользования. Для эффективного его использования нужно овладеть технологией в целом и понять место инструмента в общей цепочке. Этой цели и посвящена данная статья — собрать все ранее полученные отрывочные знания воедино и провести сквозной цикл разработки по методике Unity и TDD от постановки задачи до готового изделия.

Предварительные условия

Поскольку задача перед нами стоит большая и сложная, мы начнем не с нуля, а воспользуемся некоторым фундаментом, который благодаря предыдущим статьям к настоящему моменту у нас уже должен быть.
Перед прочтением данной статьи необходимо ознакомиться с тестовой средой Unity и методикой ее использования для модульного тестирования и TDD.
Предполагается, что читатель имеет навыки программирования на языке ANSI C. Уровень эксперта не потребуется, но уверенное владение языком необходимо.
В качестве основного инструментального средства реализации нашего проекта будет использоваться GCC. Сборку проекта будем производить утилитой GNU make. Поэтому необходимо иметь хотя бы общее представление о формате файлов make, а также параметрах командной строки GCC. Выбор данного инструментария продиктован отнюдь не склонностью автора к аскетизму, а исключительно тем фактом, что имеются доступные реализации GCC с возможностью генерации кода как для IBM PC, так и для микроконтроллеров семейства Mega фирмы Atmel, и мы этим в дальнейшем непременно воспользуемся.
Хоть это и не является обязательным условием, но наличие навыков TDD на каком-либо языке программирования высокого уровня (например, C# или Java) может существенно облегчить понимание материала данной статьи.
Приветствуется знакомство с основами тестирования [11] (в частности, использование подставных объектов), а также понимание принципов непрерывной интеграции [12]. Навыки рефакторинга [13] тоже не будут лишними.

Постановка задачи

При обучении программированию сложилась многолетняя традиция: первая программа должна быть как можно бесполезнее и бестолковее. В наибольшей степени этим строгим критериям соответствует знаменитая программа, выводящая на экран сообщение «Hello World!». Именно она и стала в итоге стандартом компьютерной педагогики де-факто.
Мы тоже не станем отступать от законов жанра. Правда, несколько осложняет дело тот факт, что мы будем разрабатывать программу для микроконтроллера, у которого нет дисплея. Но человека, который твердо решил сделать абсолютно бесполезное устройство, такие мелочи не должны пугать. Среди разработчиков встроенных систем есть своя, не менее славная традиция первой программы: мигать светодиодом (пруфлинки в количестве 198 тыс.). По степени бестолковости такая программа способна даже превзойти легендарную «Hello World!», так что с этой стороны все будет в порядке.
Размявшись на этой задаче, далее попытаемся ее несколько усложнить, но таким образом, чтобы максимально использовать наработанный ранее материал (не волнуйтесь, после усложнения устройство не станет полезнее ни на йоту, так что мы сумеем сохранить марку).
Особую изысканность программе придаст то, что светодиод будет мигать не абы как, а со скважностью, равной четырем, при частоте 0.5 Гц. Это будет выгодно отличать ее на фоне 198 тыс. имеющихся аналогов.

Процедурные замечания

Управление версиями

Если уж мы решили вести проектирование по всем правилам, имеет смысл задействовать какую-либо систему управления версиями (VCS). Если вы до сих пор не использовали VCS, самое время начать прямо сейчас.
Различных VCS в настоящее время существует не то чтобы великое множество, но некоторый выбор есть. Для нашей задачи (небольшой проект с одним разработчиком) сгодится, пожалуй, любая. Лично я в своей практике использую SVN [15].
Какую бы VCS вы ни выбрали, нужно помнить, что это всего лишь инструмент, который, хотя и помогает автоматизировать некоторую работу, но вовсе не собирается делать ее за нас полностью. Чтобы получить максимальную пользу от VCS, нужно придерживаться определенной дисциплины управления версиями.
Некоторые неопытные разработчики пытаются использовать VCS в качестве альтернативы системе резервного копирования. Они попросту через определенные интервалы времени сохряняют очередную версию файлов проекта. Безусловно, это гораздо лучше, чем вовсе никак не сохранять файлы, и при необходимости всегда можно откатиться к предыдущему состоянию проекта, но это все же слишком примитивное использование VCS.
«Гибкие» технологии разработки, к которым относится TDD, подразумевают реализацию проекта небольшими, но завершенными итерациями. После каждой итерации в проект либо добавляется некоторая функциональность, либо код подвергается рефакторингу. В результате каждой итерации должен получиться код, который как минимум компилируется без ошибок (лучше, если и без предупреждений, которые для получения действительно качественного кода не следует игнорировать). Результат успешной итерации следует зафиксировать в репозитории. Если при компиляции очередной итерации проекта возникают ошибки, такой код не следует помещать в репозиторий без очень на то уважительной причины. Многие команды используют более строгий подход: в них запрещается помещать в репозиторий код, который выдает ошибки при модульном тестировании. Я также предпочитаю по возможности придерживаться такого подхода.
Возникает резонный вопрос: какую итерацию считать небольшой, но при этом завершенной? В некоторых источниках рекомендуют привязываться к длительности итерации, при этом по разным мнениям итерация должна длиться от 10-15 минут до 2 часов. Мне представляется более конструктивным функциональный подход, когда в результате итерации создается некоторая единица проекта (метод, функция, класс и т.п.), обладающая логической завершенностью и имеющая самостоятельную ценность, а не выполняющая вспомогательные функции для другой единицы. Сложный рефакторинг (или несколько связанных простых) также может составлять отдельную итерацию. Разумеется, это определение также не обладает математической строгостью, но все же значительно лучше, чем хронометрический критерий.
Есть еще один простой, но неплохой критерий. Во многих VCS при сохранении версии предлагается ввести текстовый комментарий. Ни в коем случае не пренебрегайте этой возможностью, ведь эти комментарии так полезны в случае необходимости отката к предыдущим версиям. Комментарий должен одной-двумя фразами недвусмысленно передавать содержание итерации. Если вы затрудняетесь с написанием этого комментария, скорее всего, имеет место один из следующих случаев:

  • итерация слишком мала; ее результат столь незначителен, что о нем попросту нечего сказать;
  • итерация слишком велика; вы слишком разогнались и одним махом выдали столько кода, что потребуется целый трактат для его описания;
  • вы плохо представляете себе, что делаете и куда будете двигаться дальше.

Любой из перечисленных пунктов — повод призадуматься и поменять тактику разработки.
Если вы знакомы с базами данных, можете представить итерацию разработки кода как аналог транзакции обработки данных. Неспроста для сохранения версии в репозитории во многих VCS используется то же ключевое слово commit, что и для закрепления транзакции в языке SQL.
Итак, наш проект будет реализовываться небольшими шагами-итерациями по такой схеме:

  • выбираем цель итерации;
  • составляем набор модульных тестов, прохождение которых является критерием успешного завершения итерации;
  • выполняем набор тестов и убеждаемся, что они завершаютя с ошибкой. Хотя этот шаг может показаться бессмысленным, попытка сэкономить минуту-другую может обернуться впоследствии многочасовым сеансом кропотливой отладки. О том, что порой случается с такими «рационализаторами»-торопыгами, можно прочитать в статье [14]. Она написана человеком с большим опытом TDD, который тем не менее наступил на эти грабли;
  • пишем минимальный код, который проходит тестирование без ошибок;
  • тестируем вновь написанный код;
  • повторяем два предыдущих пункта до тех пор, пока все ошибки не будут устранены;
  • обследуем рабочий код, полученный в результате предыдущих действий, на предмет «запахов»; если «запахи» обнаружены, устраняем их посредством рефакторинга;
  • вновь выполняем тестирование, чтобы убедиться, что в процессе рефакторинга в код не были внесены ошибки;
  • повторяем два предыдущих пункта до полного истребления «запахов» кода;
  • сохраняем результат итерации в репозитории.

Сборка проекта

Наш будущий проект, конечно, не поражает воображение сложностью, но все же с десяток исходных файлов в нем наверняка наберется. Поэтому нам потребуются средства для управления сборкой проекта.
Разумеется, недостатка в таких средствах в настоящее время нет. Но не следует забывать, что наш проект будет реализован на GCC одновременно на двух платформах: IBM PC и Atmel AVR Mega. Фирма Atmel предоставляет для своих изделий интегрированную среду разработки на основе Microsoft Visual Studio V10. Для IBM PC тоже есть из чего выбрать: Bloodshed Dev-C++, Code::Blocks, Eclipse... В принципе любую из перечисленных сред для IBM PC можно настроить в качестве кросс-средства разработки программ для микроконтроллеров AVR, чтобы при смене платформы не приходилось переходить также на другую IDE.
Как мы увидим дальше, не весь код проекта будет создан вручную; некоторые вспомогательные модули будут генерироваться автоматически, причем они должны будут при определенных условиях перегенерироваться заново. Перечисленные IDE позволяют делать и это, но, к сожалению, у каждой из них собственные настройки. Если я выберу одну из них, пользователи других могут испытывать трудности при попытке загрузить и выполнить код, приложенный к статье.
Чтобы избежать подобных проблем, отвлекающих от сути статьи, в качестве средства сборки проекта, как я уже упоминал ранее, мы будем использовать старый добрый GNU make. Он реализован на множестве платформ, достаточно прост, является своего рода стандартом де-факто, документирован достаточно полно (оставим сомнительные литературные достоинства описания make на совести разработчиков, но, в конце концов, в нем все же можно разобраться), и в то же время достаточно гибок для того, чтобы организовать автоматическую генерацию модулей.
Для работы с исходными текстами проекта читатель может выбрать совершенно любой текстовый редактор по собственному вкусу (только не забываем, какую важную роль в make-файлах играет табуляция! Некоторые редакторы склонны заменять табуляции пробелами или наоборот; и то, и другое губительно для make-файлов) или же попросту загружать готовые тексты из вложений.

Немного теории

Я вынужден разочаровать тех, чьи руки уже тянутся к паяльнику. В нашем проекте дело до него дойдет еще не скоро. А вот некоторые знания по части теории тестирования понадобятся в самом ближайшем будущем.
Хотя в предыдущих статьях ([1], [2], [3], [4]) мы уже обсуждали модульное тестирование, но исключительно на нижнем, техническом уровне. Пришла пора подняться на уровень выше и поговорить об идеологии модульного тестирования.
Для начала следует сказать несколько слов о стратегии тестирования, которую мы будем применять в процессе TDD.
Безусловно, идеи, лежащие в основе TDD, весьма рациональны, и мы будем их активно использовать. Но при этом все же не будем забывать о чувстве меры и доводить идею до фанатизма. Несмотря на все наши усилия, некоторый код все же неизбежно останется непротестированным, и с этим придется смириться.

Тесты тестов тестов тестов...

Не будем забывать, что модульные тесты — это тоже код, который пишется людьми, а не небожителями. Следовательно, тесты вполне могут содержать ошибки. Чтобы уменьшить вероятность ошибок, тестовый код также следовало бы протестировать, но сначала протестировать тесты тестов и так далее. Мы впадаем в бесконечную рекурсию, выхода из которой нет.
Если из ловушки нет выхода, значит, в нее не следует соваться вовсе. Поэтому мы не будем тестировать тесты ввиду полной бесперспективности этого занятия. Помимо тестов, есть другое очень действенное средство борьбы с ошибками — простота. Если тестовый код будет прост до тривиальности, ошибкам будет будет крайне затруднительно среди него затеряться.
Конечно же, простота не дается даром, не зря она сопутствует гениальности. Для написания простого тестового кода придется основательно потрудиться. К счастью, Месарош в [11] проделал огромную работу, систематизировав «запахи», присущие тестовому коду, и проработав множество рецептов избавления от них. По ходу статьи я буду неоднократно упоминать эти рецепты, но если вы хотите добиться настоящего мастерства в программировании (не только встроенных систем), эту книгу обязательно следует тщательно проштудировать.
Мы будем использовать простые тесты, построенные по четырехфазной схеме ([1], [2], [3], [4]), при этом каждая фаза будет реализована по возможности очень коротким линейным кодом, без использования условных операторов.

Тестирование функции main()

При попытке протестировать нашу функцию main() посредством Unity мы также столкнемся с объективными техническими трудностями. Дело в том, что набор тестов Unity — это выполняемая программа, в которой есть собственная функция main(), а два main'а в одной программе никак не уживутся.
Решение в данном случае совпадает с приведенным в предыдущем пункте: если нечто нельзя (или неоправданно трудно) протестировать, нужно сделать это нечто столь тривиально простым, что оно прекрасно обойдется и без тестирования.
Мы воспользуемся этим и при помощи простого паттерна сведем нашу функцию main() к минимуму, так что она станет практически вырожденной, и тестировать в ней станет попросту нечего. Весь сложный код будет вынесен в обычный модуль без каких-либо аномалий, который может быть полноценно протестирован.

Сложные случаи тестирования

В большинстве случаев модульное тестирование не представляет большой проблемы, особенно в случае TDD, когда целевой код с самого начала проектируется с учетом необходимости тестирования. Если сначала пишется целевой код, а тестирование производится потом, когда он уже готов, тестирование становится более сложным и трудоемким, но все же остается вполне реальным. Физерс в [16] проанализировал сложности, присущие «позднему» тестированию, и предложил множество практичных рецептов их преодоления.
Однако в некоторых случаях тестирование сталкивается с более серьезными проблемами, для решения которых разработаны специальные средства. Пришло время познакомиться с ними (и проблемами, и средствами их решения) поближе.

Конфликт двух подходов

Как уже неоднократно упоминалось, разработка через тестирование (TDD) получила широкую известность среди программистов и хорошо зарекомендовала себя, особенно в сочетании с другими «гибкими» технологиями. В результате использования TDD получается хорошо протестированный, качественный код (при наличии адекватного набора тестов код можно смело подвергать рефакторингу, что способствует повышению его качества) и хорошая архитектура (поскольку плохая архитектура слабо поддается тестированию, разработчики просто вынуждены ее улучшать до приемлемого состояния); кроме того, сам проект получается гораздо более управляемым (хорошо протестированный код требует значительно меньше времени и усилий на стадиях интеграции и отладки, которые зачастую и являются причиной срыва сроков и превышения бюджета проекта).
Еще одной хорошей практикой разработки ПО является подход «сверху-вниз», при котором сначала проектируются и реализуются модули самого верхнего уровня, отвечающие за реализацию основных бизнес-требований приложения, затем следующего уровня, который обслуживает запросы верхнего, и так далее до самых тривиальных низкоуровневых операций. Расписывать здесь многочисленные достоинства такого подхода я не буду, поскольку по данному вопросу имеется масса литературы, часть из которой уже стала классикой.
Мы вправе ожидать, что совместное применение двух хороших практик даст замечательный результат. Попробуем разработать приложение сверху-вниз через тестирование. Увы, на первом же шаге нас ожидает тупик.
TDD требует написания набора тестов перед кодированием модуля верхнего уровня. Но как тестировать модуль, опирающийся на еще не существующие вспомогательные модули нижних уровней? Получается, что его реализацию придется отложить до лучших времен, когда нижние уровни будут реализованы и протестированы. С другой стороны, мы не имеем права писать новый код, не протестировав предыдущий. Получаем замкнутый круг: две хорошие сами по себе практики мешают и противоречат друг другу при совместном применении.
Самое очевидное решение — перейти от разработки «сверху-вниз» к разработке «снизу-вверх». Но это по многим причинам нельзя считать удачным решением проблемы. Более привлекательным выглядит компромиссный вариант, когда приложение проектируется «сверху-вниз», а реализуется «снизу-вверх»; при этом мы начинаем реализацию с самодостаточных модулей нижнего уровня, которые тестируются без проблем, а затем переходим к следующим уровням, опирающимся на уже готовые нижележащие, что также обычно достаточно просто (но не всегда, как будет показано далее).
Впрочем, на этот компромисс идти вовсе не обязательно. Есть другое решение — с использованием тестовых двойников (см. далее).

Модули, трудные для тестирования

Некоторые модули сами по себе создают трудности для тестирования, даже при отсутствии внешних помех, как в предыдущем случае. Можно назвать множество подобных примеров:

  • работа с оборудованием;
  • работа в реальном времени;
  • работа с базами данных;
  • работа с удаленными сервисами (например, WWW).

В чем же состоят особые сложности перечисленных случаев?
Предположим, наш микроконтроллер должен уметь работать с флеш-картами памяти (CF, SD, ...). Мы проектируем драйвер считывателя карт и добрались до обработки ошибок. Мы хотим протестировать поведение устройства в случае, если на карте появилась сбойная область, в которую не удается записать данные. Нам нужно убедиться, что устройство при этом не впадает в ступор, а ведет себя согласно спецификациям.
К сожалению, у нас есть целая пригоршня новеньких исправных карт, ни одна из которых не желает генерировать подобную ошибку. Конечно, можно построить симулятор, способный имитировать подобные ошибки; однако, если он не был вовремя заложен в бюджет проекта, его реализация может оказаться невозможной — на это нет ни времени, ни денег. Кроме того, ценность модульных тестов состоит в возможности их автоматического выполнения. Ручные манипуляции с внешним тестовым оборудованием плохо вписываются в эту концепцию.
Работа в реальном времени имеет свои сложности по части тестирования: непредсказуемые совпадения событий и «гонки» параллельных процессов трудно воспроизвести в тестовых условиях. Да и сама работа с временем тоже таит сюрпризы. Многие до сих пор помнят не столь давнюю «проблему 2000-го года», наделавшую такой переполох в среде IT. У Греннинга в [17] есть рассказ о конфузе, который произошел с одной известной игровой консолью не менее известной фирмы в високосном 2008-м году. Тестировать корректность обработки событий, связанных с часами реального времени и/или календарем, крайне непросто, если при проектировании программы не была учтена необходимость тестирования.
Программы, работающие с базами данных, зачастую становятся ночным кошмаром тестировщика. Многие тесты требуют приведения базы данных в определенное состояние, что, во-первых, требует времени, во-вторых, может помешать нормальной работе других приложений, если они используют ту же самую базу данных. Конечно, можно развернуть отдельный сервер/базу данных для целей тестирования, но это повлечет дополнительные расходы и к тому же не решает проблему, если несколько тестировщиков занимаются тестированием одновременно. Кроме того, тесты с доступом к БД выполняются на порядки медленнее обычных тестов, и их скорость может оказаться неприемлемо низкой.
При работе с удаленными ресурсами по реальным сетям передачи данных на большие расстояния возможны искажения или пропадания сообщений, нарушение их естественного порядка и т.п. Воспроизвести такие сбои на тестовом оборудовании без использования специальных средств может оказаться крайне непростой задачей.
Подобные примеры можно перечислять долго. Очень часто упомянутые трудности приводят к тому, что особо каверзные случаи просто не тестируются в надежде на «авось». Эта надежда оправдывается далеко не всегда, и по вине непротестированного кода происходит масса инцидентов, от курьезных до катастрофических.
К счастью, и в данном случае есть решение, причем такое же: тестовые двойники.

Когда инкапсуляция препятствует тестированию

Инкапсуляция — один из столпов, на которых базируется ООП. Инкапсуляция существенно улучшает дизайн программы, пряча внутренние детали реализации модуля от его клиента. В результате клиент, лишенный каких-либо предположений относительно устройства модуля, вынужден работать с ним исключительно через открытый интерфейс, что позволяет разработчику при необходимости свободно изменять внутреннее устройство модуля при сохранени его внешнего поведения или даже заменять одни модули другими, аналогичными.
Однако то, что хорошо для архитектуры приложения, может оказаться плохо для тестирования. Во-первых, мы лишаемся возможности простестировать вспомогательные (приватные) методы класса. Во-вторых, мы не можем полностью проверить внутреннее состояние класса, поскольку оно также надежно упрятано в приватных переменных. А иметь доступ к состоянию необходимо для тестирования, ведь именно по состоянию класса после выполнения проверяемых действий обычно судят об успешности теста.
К счастью, и эта проблема решаема. Иногда мы можем отойти от уже привычной четырехфазной схемы «инициализация теста — выполнение метода — оценка конечного состояния — очистка». В конце концов, если клиенты не видят деталей внутреннего строения объекта, зачем мы должны так стремиться увидеть эти детали? В конце концов, если клиентов интересует не состояние объекта, которое они все равно не видят, а выполняемые им операции, то давайте и мы будем проверять именно эти операции. Так мы переходим от идеи тестирования состояния к идее тестирования поведения.
В этой главе так уж повелось, что панацея от всех бед — тестовые двойники. Не является исключением и наш случай: тестовые двойники — незаменимые помощники при тестировании поведения.

Опосредованные ввод и вывод

Как мы уже знаем, типичное тестирование модуля производится по четырехфазному паттерну [1]:

  • подготовка;
  • выполнение;
  • оценка;
  • очистка.

Если тестируемый метод прост (входные параметры определяют результат), оценка результатов его тестов тривиальна: достаточно лишь вызвать метод с известным набором параметров, получить результат и сравнить его с ожидаемым для данного набора параметров значением. При хорошей архитектуре проекта, когда модули слабо связаны, обычно так и бывает. Такая архитектура получается автоматически при использовании TDD, поскольку это стимулирует к написанию легко тестируемого кода, а сильно связанный код тестировать трудно.
К сожалению, такая идеальная ситуация встречается не всегда. Результат вызова некоторого метода может зависеть не только от параметров, но и от предыстории (состояния системы на момент вызова метода). Например, если в системе имеется база данных и метод производит в ней поиск, результат зависит не только от параметров вызова, но и от того, есть ли искомая запись в базе.
Более того, сам результат вызова может быть неявным. Например, если задача метода — добавление записи в базу данных или запись данных в файл, мы не можем непосредственно оценить его результат; для оценки нам придется извлечь добавленную запись из базы или найти соответствующие данные в файле лога.
В некоторых случаях это может быть вполне приемлемым. Скажем, для приемочного теста вполне нормально выполнить контрольную задачу и затем убедиться, что состояние базы данных или файла соответствуют ожидаемым. А вот для модульного теста такое решение не подходит по множеству критериев.
Прежде всего, модульные тесты просто обязаны быть быстрыми, ведь в процессе регрессионного тестирования их выполняют сотнями и при этом ожидают получения результата в считанные секунды. Базы данных обычно работают не столь быстро и могут стать причиной запаха «медленный тест» и как следствие — отказ от тестирования.
В ходе тестирования в базу данных могут добавляться фиктивные данные, которые будут смешиваться с реальными и искажать общую картину. Следовательно, придется организовать отдельную базу данных для тестирования и постоянно следить за ее синхронизацией с рабочей базой, структура которой также может меняться в ходе жизненного цикла. На это потребуются дополнительные ресурсы проекта.
Наконец, если над проектом трудится более одного разработчика, возможна «война тестов», когда несколько человек одновременно модифицируют тестовый ресурс, мешая друг другу. Единственный способ решения данной проблемы — организовать для каждого разработчика отдельную «песочницу», полностью изолированную от других. Но это тоже довольно хлопотно.
Все эти сложности можно существенно уменьшить или даже вовсе устранить при помощи опосредованного ввода и опосредованного вывода.

Опосредованный ввод

Как следует из названия, опосредованный ввод — это альтернатива непосредственному вводу. Если тестируемый метод не использует входные параметры, а вместо этого обращается за входными данными к другому методу, мы должны заставить этот самый другой метод вернуть именно те данные, которые нужны для нашего теста. Такой способ ввода данных в тестируемый метод называется опосредованным.
К сожалению, иногда это значительно проще сказать, чем сделать. Вызываемый модуль может сам обращаться за данными к какому-то модулю, и эта цепочка зависимостей может простираться достаточно далеко. Обычно такая сильная и сложная связанность между модулями — верный признак плохой архитектуры, и это повод для глубокого рефакторинга [13]. В случаях же, когда такая связанность — вынужденная мера, применяется другой метод. Модуль, вызываемый нашим тестируемым модулем, подменяется другим, специально спроектированным для опосредованного ввода, — тестовым двойником.

Опосредованный вывод

Тестируемый метод может обращаться к другим модулям не только за входными данными. Иногда результат его работы также передается другим модулям для дальнейших действий. Типичный пример: данные передаются модулю интерфейса базы данных с целью их записи в таблицу. Тестируемый метод не имеет выходных параметров, но имеет побочный эффект, который мы не можем проверить непосредственно (опосредованный вывод). Разумеется, мы можем обратиться к базе даных с целью проверки результата; однако это может оказаться неприемлемым по ряду причин, которые мы уже неоднократно обсуждали.
В данном случае также может оказаться полезным применение тестовых двойников. Для проверки опосредованного вывода двойники устроены гораздо более сложно, да и пользоваться ими также сложнее.

Тестовые двойники

Англоязычный оригинал термина «тестовые двойники» — Test Double, что можно перевести еще и как «дублер». Такой вариант великолепно объясняет суть дела.
Кто такие дублеры в кино? Это люди, обладающие специфическими навыками в довольно специфической области. Они способны подменить главного героя, когда нужно выполнить некоторый сложный трюк, недоступный среднестатистическому человеку: спрыгнуть с крыши небоскреба, обуздать необъезженного мустанга, пробежаться по крышам вагонов мчащегося поезда...
В этой аналогии следует отметить два ключевых момента: 1) дублер легко выполняет трюки, на которые не способен главный герой (при этом обычно дублеры не универсальны, для каждого вида трюков требуется свой узко специализированный дублер: один скачет на лошадях, другой прыгает с высоты и т.д.); 2) дублер не сможет заменить главного героя в остальных сценах, где требуется актерское мастерство. Другими словами, дублер имеет ограниченную применимость в особых случаях, которые не являются основными в сюжете.
Подобно своим кинематографическим коллегам, тестовые дублеры подменяют «главных героев» (реальные модули) в особых случаях, когда они (реальные модули) по каким-либо причинам неприменимы (как правило, это какой-то особый трюк). Точно так же тестовые дублеры не способны заменить реальные модули при нормальной работе приложения.
И еще одна немаловажная деталь, вытекающая из киношной аналогии. Дублер должен быть похож на героя, которого он заменяет, настолько, чтобы доверчивый зритель не заметил подмены. Если герой — коротышка с пивным брюшком, а его дублер — долговязый верзила, сразу же потеряется иллюзия достоверности увиденного. SUT также не должна обнаруживать подмену, когда ей подсовывают тестового двойника. Следовательно, тестовый двойник обязан предоставлять тот же самый интерфейс, что и оригинальный модуль; разумеется, поведение его может отличаться от оригинала коренным образом.
Поскольку основная наша цель сейчас — научиться, а не спроектировать бесполезное устройство, я немного расскажу о тестовых двойниках. Буду очень краток, чтобы статья не разрасталась чрезмерно. Тем, кто пожелает досконально разобраться в предмете, рекомендую обратиться к [11], где двойники описаны гораздо подробнее, с многочисленными иллюстрациями и примерами кода.
Можно выделить следующие варианты тестовых двойников:

  • Тестовая заглушка;
  • Процедурная тестовая заглушка;
  • Тестовый агент;
  • Подставной объект;
  • Поддельный объект;
  • Объект-заглушка.

В зависимости от возможностей настройки тестовые двойники можно также подразделить на следующие варианты:

  • Ненастраиваемый тестовый двойник;
  • Фиксированный тестовый двойник;
  • Настраиваемый тестовый двойник.


Тестовая заглушка

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

Генератор ответов

Генератор ответов — это разновидность тестовой заглушки, которая поставляет SUT «хорошие» данные, которые должны корректно обрабатываться в штатном режиме. Генератор ответов обычно используется в тестах «счастливого маршрута» (основного сценария прецедента), то есть в тестах успешности.

Диверсант

Вопреки грозному названию, задача диверсанта — не испортить проектируемую систему безвозвратно, а лишь имитировать аварийную ситуацию (например, генерируя исключение) или поставлять SUT «плохие» данные, которых не должно быть в штатном режиме. Диверсанты полезны при тестировании отказоустойчивости модуля.

Временная тестовая заглушка

Основное назначение временной тестовой заглушки — предоставить возможность тестирования модуля, который зависит от еще не реализованных модулей. Это основной метод решение проблемы разработки «сверху-вниз», о которой упоминалось ранее.
В дальнейшем, когда дело дойдет до заглушенного модуля, он постепенно будет обрастать рабочим кодом и в конечном итоге перестанет быть заглушкой, в этои и состоит его временность. Этим временная тестовая заглушка отличается от генератора ответов, который остается в тестовой системе навечно для опосредованного ввода.

Процедурная тестовая заглушка

При программировании на процедурных языках мы лишены возможности воспользоваться объектами-заглушками. Но программы, написанные в процедурном стиле, тем не менее тоже нуждаются в тщательном тестировании. Процедурная тестовая заглушка в этих условиях может послужить заменой объекту-заглушке.
При всем моем уважении к объектно-ориентированным языкам следует признать, что они несколько тяжеловаты для программирования микроконтроллеров начального уровня; во всяком случае, это мнение широко распространено в среде разработчиков встроенных систем, и я до сих пор был с ним солидарен. Впрочем, Джеймс Греннинг в статье [18] утверждает, что это мнение предвзято, и что использование C++ не тянет за собой автоматическое возрастание прожорливости программы к и без того скудным ресурсам микроконтроллера. Поскольку Греннинга я отношу к тем людям, к мнению которых обязательно следует прислушиваться, я намерен при наличии свободного времени в требуемом количестве обязательно изучить этот вопрос глубже; но пока что это вопрос будущего.
Итак, поскольку в области программирования встроенных систем в настоящий момент превалирует C, который не является объектно-ориентированным языком программирования, поэтому процедурная тестовая заглушка — весьма ценный инструмент тестирования модулей, написанных на C.
Примечание.Следует отметить, что, хотя С и не является объектно-ориентированным языком программирования, тем не менее при соблюдении определенной дисциплины на нем вполне успешно можно программировать в объектном стиле. Правда, это потребует дополнительных усилий, поскольку придется вручную выполнить часть работы, которую компилятор C++ делает автоматически. Подробнее с объектно-ориентированным подходом к программированию на языке C можно познакомиться в работе [19].
Следует отметить, что подход к реализации процедурной тестовой заглушки, предложенный в [11], приводит к появлению запаха «логика теста в продукте», что, вообще говоря, крайне нежелательно. В нашем проекте будет использован более «чистый» подход, основанный либо на статическом (подмена объектного модуля при компоновке), либо на динамическом (использование указателей на функции) полиморфизме.

Тестовый агент

Подобно своим коллегам — агентам (и суперагентам), тестовый агент занимается шпионажем. Как и в реальной жизни, в программировании существует единственный способ узнать, какие секреты спрятаны за непроницаемым для любопытных глаз забором инкапсуляции, — шпионаж.
Как работают шпионы? Во-первых, они маскируются, чтобы не выделяться на фоне прочих людей, находящихся на секретном объекте. Во-вторых, они оснащены всем необходимым для достоверной фиксации всех интересующих их действий, происходящих на объекте. В ход идут фото- и видеотехника, подслушивание, радиоперехват, установка «жучков»...
Методы тестовых агентов особенно не отличаются от вышеперечисленных. Для начала они маскируются под благопристойные объекты, с которыми должен взаимодействовать тестируемый модуль, обещают предоставить ему требуемые сервисы, а когда легковерный модуль начинает этими сервисами пользоваться, тщательно записывают все детали, чтобы потом предоставить полный отчет наверх боссу — тестовой системе, которая анализирует эти данные и принимает решение об успешности теста.
Поскольку тестовый агент способен лишь собирать информацию, ничего не предоставляя тестируемому модулю взамен, он используется для контроля опосредованного вывода.

Подставной объект

Подставной объект соединят в себе функциональность тестового агента и тестовой заглушки. Он может использоваться для контроля как опосредованного ввода, так и опосредованного вывода.
Хотя назначение подставного объекта по части контроля опосредованного вывода аналогично назначению тестового объекта, применяются они совершенно по-разному. Если тестовый агент практически не нуждается в настройке и играет лишь роль своеобразного самописца, показания которого затем расшифровываются тестовой системой, то подставной объект намного сложнее, интеллектуальнее и мощнее. Перед применением подставного объекта он подвергается настройке, в ходе которой задается ожидаемый сценарий его взаимодействия с тестируемым модулем. Задается последовательность вызовов методов подставного объекта, ожидаемые значения их входных аргументов, а также (внимание!) выходные значения (если есть), которые будут переданы тестируемому модулю. Таким образом, подставной объект способен как контролировать опосредованный вывод тестируемого модуля, так и управлять его опосредованным вводом, что делает его воистину неоценимым инструментом для тестирования межмодульного интерфейса (или интеграционного тестирования).
Примечание. Термин «подставной объект» в оригинале звучит как mock object. В программистской среде есть тенденция называть любые тестовые двойники «мок-объектами». Вообще говоря, это столь же неправильно, как и называть, к примеру, все копировальные аппараты «ксероксами» или все автомобили повышенной проходимости «джипами». Как мы видим, подставные объекты — это вполне определенная разновидность тестовых двойников; помимо них, имеются и другие.
При такой сложной функциональности подставные объекты было бы чересчур сложно реализовывать вручную. К счастью, существует инструментальное средство под названием CMock, которое избавит нас от этого труда. CMock способен сам генерировать подставные объекты по их интерфейсам, и в дальнейшем мы не раз этим воспользуемся.

Поддельный объект

В сложных случаях настройка подставного объекта может оказаться чересчур трудоемкой. Например, если мы попытаемся контролироать с их помощью сложную транзакцию с базой данных, нам придется фактически жестко закодировать всю последовательность запросов к СУБД и ответов от нее. В таких случаях может оказаться целесообразнее использование поддельных объектов.
Поддельный объект — это облегченная реализация настоящего объекта, которая предоставляет клиенту сходную функциональность. Например, «поддельная СУБД» может сохранять небольшое количество данных прямо в оперативной памяти, выполнять поиск и другие операции над этими данными. Естественно, что быстродействие такой подделки будет на несколько порядков выше, чем у реальной СУБД, которая вынуждена сохранять данные на жестких дисках, вести журнал транзакций и делать массу других необходимых, но весьма накладных операций.
Поддельный объект не обладает возможностью управления опосредованным вводом/выводом. Впрочем, это от него и не требуется.
Для наиболее типичных случаев (СУБД, Web-серверы и т.п.) существуют готовые реализации поддельных объектов. В нестандартных ситуациях их придется реализовать самостоятельно, никакой готовой инструментальной поддержки (наподобие CMock для подставных объектов) мной найдено не было.

Объект-заглушка

Это вырожденный вариант тестового двойника.
Предположим, что наш тестируемый метод имеет 5 аргументов. Мы пишем модульный тест, который проверяет, что недопустимое значение первого аргумента выдает ошибку. В данном случае значение пятого параметра для нас не существенно, до него дело все равно не дойдет. Нам достаточно передать методу что угодно, лишь бы только оно соответствовало сигнатуре метода.
Иногда, если объект передается по ссылке (либо по указателю, если выбранный вами язык программирования не поддерживает ссылок, как в случае C), достаточно передать пустую ссылку/указатель. Но этот подход не сработает, если пустой параметр не допускается спецификацией и при этом используется защитное программирование; в этом случае проверка параметра на непустоту производится в самом начале, и выполнение метода будет немедленно прервано, не дойдя до проверяемого кода. В этих случаях использование пустого параметра отпадает.
Можно инстанцировать экземпляр параметра наименее хлопотным способом, выбрав подходящий конструктор. Но нужный класс может вовсе не иметь конструкторов (например, экземпляры объекта могут только загружаться из реляционной БД посредством объектно-реляционного отображения или создаваться некоей фабрикой классов). В этом случае ситуация существенно усложняется.
Конечно, ситуация не безвыходная. Можно занести запись в тестовую базу данных и создать на ее основе экземпляр объекта; можно также обратиться к фабрике и потребовать экземпляр от нее. Проблема в том, что для всех этих действий нужен код. Фаза подготовки нашего теста разрастется, а это чревато двумя последствиями. Во-первых, большой код, выполняющий несколько относительно сложных действий, сам по себе чреват ошибками и требует тестирования (мы ранее условились, что не будем городить тесты тестов и конструкции более высокого порядка, поскольку этот процесс бесконечен). Во-вторых, громоздкий код труднее для чтения и понимания, а это значит, что тест утрачивает ценность в качестве средства документирования.
Наиболее разумное решение в данном случае — использовать простейший объект, который имеет такой же интерфейс, как и требуемый в качестве параметра, но который легко создать (в идеале — конструктором по умолчанию без параметров). Это и есть объект-заглушка.

Настройка тестовых двойников

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

Ненастраиваемый тестовый двойник

К двойникам, не нуждающимся в предварительной настройке перед использованием, относятся:

  • поддельные объекты: они используются SUT точно так же, как используются их «подлинные» оригиналы, и их настройка фактически определяется последовательностью их использования;
  • объекты-заглушки: они никогда не вызываются, поэтому и их настройка лишена смысла.

Фиксированный тестовый двойник

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

Настраиваемый тестовый двойник

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



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

Продолжение: часть 2.



Литература

[ 1]. Модульное тестирование ПО встроенных систем в среде Unity. Часть 1.
[ 2]. Модульное тестирование ПО встроенных систем в среде Unity. Часть 2.
[ 3]. Модульное тестирование ПО встроенных систем в среде Unity. Часть 3.
[ 4]. Разработка на языке C, управляемая тестированием.
[ 5]. Обработка исключений на языке C.
[ 6]. Развитие в направлении разработки встроенных систем.
[ 7]. Эффективная разработка встроенного ПО через тестирование.
[ 8]. Сопрограммы в языке программирования C.
[ 9]. Using Protothreads for Sensor Node Programming.
[10]. Книжная полка разработчика систем со встроенными микропроцессорами.
[11]. Месарош Дж. Шаблоны тестирования xUnit.
[12]. Дюваль П. Непрерывная интеграция.
[13]. Фаулер М. Рефакторинг. Улучшение существующего кода.
[14]. Mark VanderVoord (contributed by Matt Wilbur). When Bad Code Runs Green.
[15]. Mike Mason. Pragmatic Version Control using Subversion (2nd edition).
[16]. Майкл К. Физерс. Эффективная работа с унаследованным кодом. «Вильямс», 2009.
[17]. James W. Grenning. Test Driven Development for Embedded C. The Pragmatic Bookshelf, 2011.
[18]. James W. Grenning. Why are you Still Using C?
[19]. Schreiner A. Object-oriented programming with ANSI C.

Версия для печати
Обсудить на форуме