Модульное тестирование
Данная подглава — галопом по Европам. Тема тестирования настолько обширна, что на английском языке существует целая книга — Test Driven Development for Embedded C (далее TDDFEC), объёмнее, чем вся эта. Если вы владеете языком, обратите внимание на неё. Здесь мы заявим о проблеме и рассмотрим базовые подходы к решению.
31 декабря 2008 года все mp3-плееры Zune (1-го поколения) от Microsoft замолчали. Почему? Как оказалось, код в модуле календаря при определённых условиях падал в вечный цикл. Изучите код ниже (взят с сайта bit-player.org):
Данный случай широко известен и приведён в самом начале книги TDD. Детальное рассмотрение с решением бага можно найти в блоге bit-player.org.
// Dec 31, 2008 => days = 366, year = 2008
while (days > 365) {
if (IsLeapYear(year)) { // true
if (days > 366) { // false
days -= 366;
year += 1;
} // did nothing
} else {
days -= 365;
year += 1;
}
// let's check again...
}
На первый взгляд может показаться, что всё хорошо, но если вы подставите число 366 в переменную days
, а год будет високосным (скажем, 2008), то после входа в первое условие переменная days
не изменится, и программа уйдёт на следующую итерацию. Так будет продолжаться, пока не наступит следующий день (если days
обновляется асинхронно). Через утверждения такую ошибку можно было быстро выявить, написав пару строк кода.
Проблема в коде Zune вполне безобидна. Да, конечно, неприятно, что устройство не работает один день в четыре года, но от него не зависит человеческая жизнь, да и цена устройства относительно копеечная. Ранее приводились другие примеры из космической отрасли, где отсутствие тестирования приводило к потере дорогостоящей аппаратуры. Стоит ли рисковать, материально и репутационно, не предпринимая никаких дополнительных действий для поиска и исправления ошибок?
Для простоты предположим, что days
и year
— это глобальные переменные, а функция, которая содержит приведённый выше код, — назовём её calculate()
— ничего не принимает и ничего не возвращает.
Мы точно знаем, как должна вести себя функция при переходе: за 31 декабря 2008 должно идти 1 января 2009 года. Напишем тестовую функцию.
void test_calendar_transition(void) {
year = 2008; days = 366; // initial values
calculate();
uint32_t expected_year = 2009, expected_days = 0;
assert(year == expected_year);
assert(days == expected_days);
}
К сожалению, это не лучший пример, ведь до assert()
код не дойдёт, застряв в calculate()
, но сам факт того, что мы запустили код с данными, которые могут привести к ошибке, — это хорошо. Проверить нужно не только момент перехода, но и некоторые промежуточные значения: високосный год, days
больше 366; високосный год, days
меньше 366; и т.д. Перебирать все возможные пары входных и выходных данных неправильно и невозможно. Если функция возвращает true
или false
, тест как минимум должен содержать одну проверку правильности получения true
и одну для получения false
(зависит от внутренней логики и входных данных).
Код календаря, однако, не был написан так, чтобы его было удобно тестировать. Приведём синтетический пример и напишем функцию сложения двух чисел с ошибкой.
int sum(int a, int b) {
if (a < 0) { //
reutrn a + b + 1; // our bug
} //
return a + b;
}
Такой код проще протестировать, у него понятные входные и выходные данные, и он изолирован от других функций и переменных.
void test_sum(void) {
assert(sum(2, 2) == 3); // O.K.
assert(sum(2, 0) == 2); // O.K.
assert(sum(-2, 2) == 0); // bug will reveal here
// ...
}
Такое тестирование называется модульным, или юнит-тестированием (англ. unit testing); его цель — разбить программу на кусочки и проверить их работоспособность. Будут ли они все работать вместе — задача интеграционного тестирования.
Желание писать удобный для тестирования код накладывает ограничения. Во-первых, программу следует декомпозировать, т.е. разбивать на небольшие функциональные блоки. Их проще проверить (читай придумать тесты), чем большие составные куски кода. Во-вторых, тестируемые блоки (функции наподобие test_sum()
) должны быть изолированы друг от друга, т.е. последовательность их выполнения не должна влиять на результат. Если в тестах используются глобальные переменные, то их значения нужно задать явно перед запуском.
Вокруг тестов возникла целая методология — разработка через тестирование (англ. Test-Driven Development, TDD). Тесты пишутся до реализации необходимой функциональности. Таким образом, весь код будет протестирован просто по факту его наличия. Однако со встраиваемыми системами есть проблемы. Первая — это ресурсы, они ограничены. Введение дополнительного проверяющего кода занимает место в памяти, т.е. его может не хватить. Кроме того, если вы используете assert()
, запуск теста не будет удобным: а) код упадёт при первой же ошибке и не покажет другие проблемы; б) у вас не будет удобного текстового вывода (конечно, можно выводить через UART) для анализа. Вторая проблема в том, что программа взаимодействует с железом и реальным миром. Решив первую проблему, перенеся тестирование на хост-устройство (компьютер), мы лишаемся возможности читать и писать в регистры.
В среде от IAR в меню отладчика можно выбрать симулятор, который позволяет загрузить прошивку в виртуальный микроконтроллер и отлаживать программу. Вы можете читать и писать в регистры. Также можно использовать эмулятор QEMU, но он поддерживает ограниченное количество устройств.
При тестировании часто применяют так называемые «заглушки», или mock-объекты (в ООП), т.е. временные сущности (объекты или функции), симулирующие реальное поведение. В книге TTDFEC при написании теста модуля светодиода предлагается создать «виртуальный порт», т.е. простую 16-битную переменную, в которую можно записывать биты, как в регистр. Такая переменная — это mock-объект (он может быть намного сложнее).
Может показаться, что написание «виртуального порта» — чушь. Это не совсем так. Возможно, пример не самый лучший. Представьте себе лучше следующую ситуацию (прим. автора: в которой я как-то оказался): вам нужно написать драйвер для микросхемы flash-памяти, работающей по SPI. Если с SPI вам всё более или менее понятно, то вот по поводу организации данных во flash у вас есть вопросы. Вы не можете записывать 1
в произвольную ячейку, её можно только устанавливать в 0
. Для записи единицы нужно затереть страницу целиком (блок памяти, например 1 Кб) — так работает данный тип памяти. Само устройство к вам придёт только через неделю, а срок реализации — неделя плюс один день. Можно созерцать потолок и думать, как всё успеть за 24 часа, а можно написать симулятор микросхемы — это просто массив с определёнными правилами работы с его ячейками. Через неделю, когда в ваших руках окажется устройство, код драйвера будет практически готов (и протестирован!), останется лишь заменить пару функций, отвечающих за запись и чтение по SPI.
Имея тесты, можно заниматься рефакторингом без задних мыслей. Если новая реализация какой-нибудь функции имеет ошибку, вы сразу же об этом узнаете.
Приведённый в самом начале главы макрос, assert_param(expr)
, довольно хорош, так как использует __FILE__
и __LINE__
. Передав их в printf()
, в обработчике можно вывести название файла и строчку, где была замечена проблема. Однако это не самый информативный вывод. Тест будет не один, к тому же узнать, что получилось в expr
, можно будет только в режиме отладки.
К счастью, для языка Си уже написан не один фреймворк. Для встраиваемых систем хорошо подходят Unity и CPPUnit (используется в книге TDDFEC). Мы рассмотрим только первый.
Unity состоит всего из трех файлов (unity.c
, unity.h
и unity_internals.h
) и содержит множество предопределённых утверждений на все случаи жизни.
TEST_ASSERT_EQUAL_UINT16(0x8000, a);
TEST_ASSERT_EQUAL_FLOAT( 3.45, pi );
TEST_ASSERT_EQUAL_INT_MESSAGE( 5, val, "Not five? Not alive!" );
// and so on
Для создания теста пишется функция с префиксом test_
или spec_
. Перепишем ранее созданный тест для функции sum()
.
На самом деле это необязательно. Префикс используется Ruby-скриптом для автоматической генерации функции
main()
с вызовом всех тестов. Мы рассмотрим ручной режим формирования.
void test_sum(void) {
TEST_ASSERT_EQUAL_INT32( 4, sum( 2, 2));
TEST_ASSERT_EQUAL_INT32( 2, sum( 2, 0));
TEST_ASSERT_EQUAL_INT32( 0, sum(-2, 2));
TEST_ASSERT_EQUAL_INT32(-4, sum(-2, -2));
}
Это далеко не всё, что умеет делать данный фреймворк. По идее, каждый модуль нужно тестировать отдельно. Поэтому вам стоит создать отдельный файл для его тестирования. В этом файле, помимо тестовых функций, содержатся setUp()
и tearDown()
, которые выполняются перед и после каждого теста. Опять же, если используются глобальные переменные, задать их значение можно в этих функциях. Далее идут сами тесты, а в самом конце функция main()
. Таким образом, каждый тестовый модуль автономен и может компилироваться без основного проекта, т.е. не вносит в него никаких накладных расходов.
Для запуска теста, однако, нужно вызвать не саму функцию, а передать указатель на неё в макрос RUN_TEST()
. Это нужно для того, чтобы фреймворк смог запустить функции setUp
и tearDown
, а также знал, из какого __FILE__
и __LINE__
она была вызвана. Макросы UNITY_BEGIN()
и UNITY_END()
выводят дополнительную информацию (например, сколько тестов было запущено, сколько из них удачных и т.д.)
#include "unity.h"
#include "sum.h"
void setUp(void) {
// set stuff up here
}
void tearDown(void) {
// clean stuff up here
}
void test_sum(void) {
TEST_ASSERT_EQUAL_INT32( 4, sum( 2, 2));
TEST_ASSERT_EQUAL_INT32( 2, sum( 2, 0));
TEST_ASSERT_EQUAL_INT32( 0, sum(-2, 2));
TEST_ASSERT_EQUAL_INT32(-4, sum(-2, -2));
}
// not needed when using generate_test_runner.rb
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_sum);
return UNITY_END();
}
Скомпилируем наш тест и посмотрим, что получилось.
gcc testSum.c sum.c unity.c -o testSum
./testSum
testSum.c:15:test_sum:FAIL: Expected 0 Was 1
-----------------------
1 Tests 1 Failures 0 Ignored
FAIL
В большом проекте много модулей, а значит, и тестовых файлов. Компилировать и запускать их вручную неудобно. Используйте утилиту make
для автоматизации.