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

Данная подглава — галопом по Европам. Тема тестирования настолько обширна, что на английском языке существует целая книга — 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 для автоматизации.


Изменено:

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.