Статья
Версия для печати
Обсудить на форуме
Модульное тестирование ПО встроенных систем в среде Unity
Часть 1


(C) Dale, 12.07.2011 — 13.07.2011.

Тестирование систем со встроенными микропроцессорами и в частности программного обеспечения для них – очень неудобная тема, которую старательно обходят подавляющее большинство руководств по разработке, обходясь в лучшем случае общими фразами.
Попробуйте провести небольшой эксперимент. Если у вас есть знакомый разработчик (не работающий на космос или военных), спросите его при случае, тестирует ли он свои продукты. Уверен, что в ответ вы услышите: «А как же иначе???», произнесенное тоном оскорбленной добродетели. Но не сдавайтесь. Попросите показать, как же выглядит этот самый процесс тестирования. Готов поспорить на крупную сумму, что ничего конкретного вы не услышите. Будут общие невразумительные фразы, что это процесс исключительно творческий  и в каждом конкретном случае проходит по-своему, что все и так наглядно видно: вот моторчик крутится, зеленая лампочка горит, а красная – авария – не горит, и что еще надо? Если вы совсем уж безжалостный человек и хотите окончательно нокаутировать собеседника, попросите его показать тесты…
Конечно, это шутка. Не все и не везде так уж плохо: появляются доступные инструменты для тестирования встроенных систем, публикуются статьи и книги о тестировании, да и заказчики постепенно умнеют и начинают озадачиваться вопросами качества (проверено на личном опыте, когда удалось выиграть тендер на небольшую, но выгодную «шабашку» за счет того, что заказчика впечатлил план тестирования, включенный в график работ; подозреваю, что и в дальнейшем он будет требовать его от исполнителей). Но, как и во всякой шутке, в ней есть лишь доля шутки, причем в данном случае не такая уж большая.
В данной статье речь пойдет об использовании среды модульного тестирования Unity, разработанной специально для тестирования программ, написанных на языке C. Но поскольку, помимо знания самого инструмента, нужно еще и разобраться с технологией его использования, сначала рассмотрим несколько общих вопросов, а затем перейдем собственно к тестированию.

Цель тестирования

Тестирование – процесс, который требует определенной доли выделенных на проект ресурсов: денег, времени, труда и т.д. Для того, чтобы сознательно пойти на эти затраты, нужна мотивация: что мы получим взамен? Естественно, что любые затраты имеют смысл в том случае, если полученная в итоге выгода превысит потери. Итак, какие же выгоды дает нам тестирование?
Прежде всего, тестирование оказывает непосредственное и очень сильное влияние на качество конечного продукта. Впрочем, «качество» вообще и применительно к программным продуктам в частности – понятие довольно расплывчатое, и каждый вкладывает в него свой смысл. Поскольку к вопросам качества мы будем возвращаться еще неоднократно, имеет смысл уточнить это понятие. Например, так оно определяется в Википедии:

Цитата
Качество программного обеспечения — характеристика программного обеспечения (ПО) как степени его соответствия требованиям. При этом требования могут трактоваться довольно широко, что порождает целый ряд независимых определений понятия. Чаще всего используется определение ISO 9001, согласно которому качество есть «степень соответствия присущих характеристик требованиям».

Итак, будем считать программу качественной, если ее характеристики полностью или с незначительными отклонениями соответствуют требованиям, которые пользователи программы предъявляют к ней. Правда, такое определение заводит нас в ловушку: пытаясь определить одно понятие («качество»), мы воспользовались другим («требования»), которое тоже допускает вольные толкования.
О требованиях к ПО написаны целые тома, поэтому в данной статье упомяну о них лишь вскользь. Прежде всего, требования имеют смысл лишь в том случае, если их можно формально проверить. Например, не имеют никакого смысла требования типа «программа должна быть надежной» или «отчет должен выполняться быстро» – эти оценки субъективны. Более конкретные требования «суммарное время простоя не должно превышать 10 минут в сутки» или «формирование отчета должно укладываться в 5 минут без учета времени вывода на печать» элементарно проверяются с часами в руках, и по результатам проверки можно вынести однозначный вердикт.
Требования могут быть функциональными (то есть непосредственно относящимися к выполнению программой определенных функций) и нефункциональными, определяющими прочие свойства системы; к последним обычно относятся общие требования к надежности, безопасности, быстродействию и т.п.
Итак, подведем итог: для контроля качества продукта нам следует проверить соответствие действительных характеристик, которыми обладает данный продукт, желаемым характеристикам, которые сформулированы в виде требований. Разумеется, все это имеет смысл лишь в том случае, когда в рамках проекта ведется тщательная работа с требованиями – их собирают от пользователей будущей системы, анализируют, систематизируют и оформляют в виде, пригодном для дальнейшего использования. Например, для этого можно воспользоваться рекомендациями IEEE по разработке требований к ПО (стандарт STD 830-1998, мой вариант перевода документа на русский язык можно почитать здесь). В процессе анализа и формализации требований могут также оказаться полезными различные нотации: IDEF0, IDEF3, UML (применение UML для данной цели хорошо описано в книге Леффингуэлла и Уидрига ).
Следует подчеркнуть, что в данном контексте речь идет о качестве с точки зрения конечного пользователя продукта. В данный момент мы не оцениваем другие показатели качества продукта, такие как стиль написания программы, понятность кода и прочие, не видные потребителю.
И еще напоследок один важный момент. Нужно сразу же определиться с основной целью тестирования. Тестирование нужно не для того, чтобы продемонстрировать работоспособность продукта. На самом деле обычно представляющие практический интерес программы настолько сложны, что проверить все ветви их выполнения и все комбинации параметров и состояний попросту невозможно: их перебор на самом быстродействующем процессоре занял бы время, превышающее возраст Вселенной.
На самом деле цель тестирования – обнаружить ошибки в программе как можно раньше, желательно до того, как на них наткнется потребитель и составит не слишком лестное мнение о продукте и его авторах. Тест, завершившийся ошибкой, – не катастрофа, а удача, ведь это позволит найти и устранить ошибку в программе. Поэтому следует бороться с искушением писать тесты, подтверждающие правильность работы программы, ибо они не выполняют свое основное предназначение и практически бесполезны.

Разновидности тестов

Существует множество вариантов классификации тестов по разным признакам: проверяемым характеристикам, степени автоматизации, степени осведомленности тестирующего о подробностях устройства системы и т.д. Я считаю целесообразным в контексте данной статьи выделить четыре разновидности тестов:
  • Приемо-сдаточные тесты, демонстрирующие заказчику работу продукта.
  • Функциональные тесты, проверяющие основные функции продукта (если функциональные требования были оформлены на UML, то проверяются отдельные USE CASE из соответствующей модели).
  • Интеграционные тесты, проверяющие правильность взаимодействия основных модулей системы через интерфейсы.
  • Модульные (Unit) тесты, детально проверяющие функциональность отдельных модулей.
При движении по этому списку сверху вниз обычно возрастает степень формализованности, оснащенность инструментальными средствами и, как следствие, уровень автоматизации. В применении к области системам со встроенными микропроцессорами растет также степень моей осведомленности в предмете: 1 – еще даже не приступал; 2 – ведется сбор данных; 3 – в теории более-менее понятно, инструменты подготовлены, осталось попрактиковаться; 4 – вопрос изучен, «белых пятен» не осталось.

Место тестирования в общем процессе разработки

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


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

TDD – разработка, управляемая тестированием

Традиционно тестирование рассматривается как вспомогательный процесс. Поэтому нередко к нему относятся как к неизбежному злу, которое отвлекает ресурсы от основной деятельности – написания кода – и лишь тормозит процесс. При таком подходе тестирование стараются отложить как можно позже, в тайной надежде, что в конце проекта будет аврал и тестирования удастся избежать вовсе.
Однако есть и другой подход, в котором тестированию, особенно модульному, отведена центральная роль. Он известен под названием Test Driven Design (разработка, управляемая тестированием), или TDD. При этом подходе сначала пишутся модульные тесты, а уже потом сам код модуля.
На первый взгляд это кажется несколько нелогичным: какой смысл тестировать то, чего еще нет? Но это лишь на первый взгляд. На самом деле резон в таком подходе есть,  и немалый. На самом деле задача тестов – проверить, что модуль делает именно то, что от него требуется, то есть соответствует спецификации. Значит, на самом деле для составления тестов сам модуль не нужен, нужна лишь его спецификация, а уж она обязательно должна быть к началу работы над кодом модуля (иначе непонятно, как этот самый модуль писать).
Сторонники так называемого «экстремального программирования» идут еще дальше. Их логика такова: если модульные тесты являются воплощением спецификации модуля в коде, почему бы вообще не отождествить спецификации с модульными тестами? В этом случае набор тестов и есть спецификация модуля, он же и является документацией. Лично я не сторонник экстремизма в любых проявлениях, и подобный радикализм мне не очень нравится. Все-таки спецификации в виде старого доброго текста (а еще лучше в виде UML) и сгенерированная Doxygen'ом документация впридачу кажутся мне гораздо нагляднее, чем набор текстов (но это мое личное мнение).
Программирование по методике TDD производится таким образом. Сначала по спецификации пишется хотя бы один модульный тест (можно несколько или даже все сразу). Затем он выполняется (подразумеваем, что к этому моменту все ошибки компиляции позади, и тестируемый модуль оснащен заглушками-«пустышками», которые ничего не делают). Убеждаемся, что тест завершился с ошибкой (иначе и быть не могло, ведь «пустышки» никак не могут удовлетворить требованиям из спецификации). Затем добавляем в «пустышку» самое минимальное количество кода, которое позволило бы тесту успешно пройти, и ни строчки больше.
После того, как очередной тест прошел, при необходимости производится рефакторинг модуля, и все тесты прогоняются заново, чтобы убедиться, что в процессе рефакторинга в модуль не было внесено никаких новых ошибок.
Процесс повторяется до тех пор, пока не пройдут успешно все модульные тесты, и при этом из кода модуля устранены все «запахи». После этого работу над модулем можно считать временно завершенной (пока не изменятся требования или тесты более высоких уровней не обнаружат ошибку в модуле).

Модульное тестирование

Остальная часть данной статьи посвящена исключительно модульному тестированию. На то есть достаточно веские как объективные, так и субъективные причины. Объективные: именно на модульное тестирование опирается мощная  технология TDD; без него невозможен полноценный рефакторинг; как видно из V-модели, это первые тесты, на которых фокусируются усилия разработчиков в ходе проекта. Субъективные: из всех видов тестирования освоить модульное, пожалуй, проще всего; для него существует большой набор инструментов для множества языков и платформ; он лучше всех освещен в литературе; наконец, лично я знаком с ним лучше других видов тестирования.
Как следует из названия, объект модульного тестирования – отдельные модули. Предполагается, что мы уже проанализировали задачу, разбили ее на более простые подзадачи, распределили подзадачи между модулями и определились с требованиями к каждому из этих модулей. Цель модульного тестирования – проверить, что модуль действительно удовлетворяет предъявляемым к нему требованиям.
Следует четко осознавать эти ограничения. Модульные тесты могут подтвердить, что модуль способен выполнять возложенные на него обязанности. Из этого не следует, что другие модули будут вызывать его правильным образом; что задача правильно разбита на подзадачи, а те правильно распределены между модулями; наконец, что задача была понята правильно. Проверка этих аспектов – задача тестов других уровней.
Итак, что же представляет собой модульный тест? Он выглядит, как обычная подпрограмма, не имеющая параметров и не возвращающая результата. Хорошей практикой считается писать очень короткие тесты, каждый из которых проверяет один из вариантов поведения модуля. Конечно, никто не мешает писать монструозные тесты, проверяющие сразу множество вариантов поведения, но это весьма непрактично, поскольку интерпретировать результаты таких тестов будет затруднительно.
Если научиться писать «правильные» тесты, можно фактически обходиться без отладчика. Нет необходимости проходить программу по шагам, устанавливать контрольные точки, отслеживать состояние переменных, – хороший тест делает причину отказа очевидной; остается лишь исправить ее и убедиться, что ошибка исчезла.

Четырехфазная структура теста

По мере использования модульных тестов постоянно возникают одни и те же типовые задачи, для которых были найдены стандартные типовые решения – паттерны тестирования. Этих паттернов довольно много, и их изучение может принести большую пользу, избавляя разработчика от необходимости изобретения велосипеда. Один из таких основных паттернов – четырехфазная структура теста.
Согласно этому паттерну, модульный тест состоит из четырех фаз:
  • Подготовка. В этой фазе подготавливаются исходные данные, которые будут задействованы в процессе тестирования.
  • Выполнение. Проверяемая функция выполняется над данными, подготовленными в фазе 1.
  • Оценка. Результат фазы 2 оценивается и сравнивается с ожидаемым.
  • Очистка. Если в процессе выполнения теста были задействованы какие-то системные ресурсы (выделялась оперативная память, создавались/открывались временные файлы, добавлялись записи в базу данных и т.п.), в этой фазе все возвращается в исходное состояние. Это важно, ведь «мусор», оставшийся от данного теста, может повлиять на выполнение других тестов.

Инструментальные средства тестирования.

В принципе модульные тесты можно было бы писать «с нуля», вручную реализуя все 4 фазы. Но при этом пришлось бы постоянно выполнять массу рутинной работы, которой можно избежать при использовании специальных инструментов.
Стандартом де-факто в области модульного тестирования стало семейство инструментов, известное под общим названием xUnit, где «x» меняется в зависимости от целевой среды тестирования. Имеются реализации xUnit для всех мыслимых объектно-ориентированных языков программирования, при этом наличие в языке средств рефлексии является огромным плюсом.
В языке C, как известно, встроенных объектно-ориентированных средств не имеется, не говоря уж о рефлексии. Поэтому напрямую портировать xUnit на C было бы довольно затруднительно. Поэтому поиск инструментов модульного тестирования для C «без плюсов» отнял у меня некоторое время, пока на глаза не попалась Unity. Разработчики Unity решили трудности, связанные с автоматизированным запуском тестов, при помощи скриптов на языке Ruby.
Одна из основных задач среды тестирования – оценка результата (фаза 3). Для этого имеется большой набор операций типа assert, их задача – сравнить фактический результат с ожидаемым и при несовпадении завершить тест по ошибке с выдачей соответствующей диагностики. Как правило, имеются версии для всех примитивных типов. В Unity эти операции реализованы в виде макросов.
Имеются также специальные функции, которые выполняются перед выполнением и после выполнения каждого теста. Они могут быть использованы на фазах 1 и 4 соответственно, если семейство тестов использует аналогичные наборы данных. В Unity эти функции называются setUp() и tearDown() соответственно.
Unity предлагает разработчику также вспомогательные скрипты на Ruby для автоматического создания инфраструктуры запуска тестов, а также для создания стандартной структуры самого модуля. При этом можно выбрать несколько паттернов проектирования, разработанных специально для встроенных систем, например, уже знакомый нам паттерн MCH. Это уменьшает объем ручного труда при написании кода.

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