*** ВНИМАНИЕ: Блог переехал на другой адрес - demin.ws ***

воскресенье, 1 марта 2009 г.

Unit-тестирование в языке С

По роду работы мне приходится работать с огромным количеством кода на С, причем чаще всего — это старый код, написанный много лет назад, и написан он без каких-либо намеков на тестирование, увы.

Исправляя в таком коде ошибки, внося какие-то изменения, хочется какой-то гармонии с самим собой, а именно — иметь возможность тестировать, тем самым уменьшить вероятность повторного внесения ошибок. Пусть уж полностью старый код остается без тестов, но раз уж я что-то меняю, я хочу подкрепить свои изменения тестами.

Мир языка С++ не такой дружественный к тестированию, как например, мир Java, C# или мир интерпретаторов. Главная причина — крайне слабый механизм интроспекции, то есть возможности исследования двоичного кода в плане получения информации о структуре исходных текстов. В Java, например, есть "The Reflection API", с помощью которого можно прямо на основе скомпилированных классов создать тестовую среду (понять иерархию классов, типа аргументов и т.д.). В С++ приходится многое закладывать в исходный текст на этапе его создания, чтобы облегчить будущее тестирование.

А что же мы имеем в С? Тут, как мне кажется, разрыв в удобстве тестирования по отношению к С++ в разы больше, чем между С++ и Java, например. Причин море: процедурная модель вместо объектно-ориентированной, отсутствие интроспекции вообще, крайне слабая защита при работе с памятью и т.д.

Но шансы все же остались. Я начал поиск готовых библиотек для unit-тестирования в С. Например, есть библиотека MinUnit, длиной в четыре строки. Вполне жизненно. Следующий вполне себе вариант — это CUnit. Тут даже есть продвинутый консольный интерфейс.

Перебрав еще несколько вариантов, я остановился на гугловской библиотеке cmockery. Мне понравилось, что библиотека, несмотря на весьма сложный код, успешно компилируются не только в Visual Studio и GNU C, но и “родными” компиляторами AIX, HP-UX, SunOS и некоторых других экзотических зверей. Также библиотека умеет отлавливать утечки памяти, неправильную работу с распределенными кусками памяти (так называемые buffer over- и under- run). Еще в cmockery есть зачатки mock-механизмов, то есть когда задаются предполагаемые сценарии выполнения тестируемого блока, и потом результаты тестового прогона сверяются с предполагаемым сценарием. Mock-возможности я не буду пока рассматривать в данной статье. Про это стоит написать отдельно.

На текущий момент актуальной версией cmockery является 0.1.2. Из всего архива реально нужны только два файла: cmockery.c и cmockery.h. Можно, конечно, собрать библиотеку как положено, в двоичном виде, но я предпочитаю работать всегда с исходными текстами, благо компилируется очень быстро (это ж не С++).

Желающие, могут скачать мою сборку cmockery. В этом архиве только необходимые два файла cmockery.c и cmockery.h. Также в файл cmockery.h я внес небольшое изменение, связанное к тем, что функция IsDebuggerPresent() почему-то явно объявлена в заголовочных файлах только в Visual Studio 2008. Для студии 2003 и 2005 надо вручную объявлять прототип, иначе при линковке вылезает сообщение:

error LNK2019: unresolved external symbol _IsDebuggerPresent referenced in function __run_test

Я отрапортовал об этом досадном недочете авторам, и пока новый релиз cmockery не вышел, можно пользоваться моей сборкой, которая без предупреждений компилируются в любой студии.

Теперь пример реального использования cmockery.

Я долго выбирал то, на чем можно хоть как-то наглядно продемонстрировать unit-тестирование в С. В итоге я остановился на библиотеке для работы со строками. Эта библиотека реализует так называемые строки с длинной. То есть надо для кода на С дать более менее удобный интерфейс для манипулированию строками, которые хранят внутри себя длину.

Основа библиотеки была написана весьма давно, и много раз переписывалась практически с нуля, но я все еще использую ее в некоторых проектах.

Естественно, я не буду приводить всю библиотеку. Во-первых, она весьма тривиальна и вся ее "фишка" состоит в удобности работы, нежели в какой-то особо хитрой и заумной реализации. Во-вторых, полный ее исходный текст весьма объемен. Я выбрал небольшой ее фрагмент, но его тестирование позволяет почувствовать дух тестирования в С.

Итак, библиотека cstring. Тут можно создавать в некоторые "объекты", реализованные через структуры, которые представляют собой "строки". Такая "строка" может создаваться либо в стеке (автоматическая переменная), либо в куче. Также предоставляется набор разнообразных базовых функций: определение длины, копирование, склейка, интерфейс со строками языка С (char *) и т.д. Как я уже сказал, для демонстрации системы тестирования я оставил только несколько функций.

Заголовочный файл cstring.h:

#ifndef _CSTRING_H
#define _CSTRING_H

#define _decl_string_t(N) \
struct { \
int sz; \
char data[N]; \
}

typedef _decl_string_t(1) string_t;

/**
* Объявление строки в форме автоматической переменной в стеке.
* Длина строки инициализируется нулем.
*/
#define decl_string_t(name, size) _decl_string_t(size) name = { 0 }

/**
* Создание новой строки в куче.
*/
string_t* string_new(int sz);

/* Трюк с дублированием имен функций, начинающихся с символа '_'
* требуется для подавление предупреждений компилятора о преобразовании
* типов.
*/

/**
* Удаление строки из кучи.
*/
#define string_delete(str) _string_delete((string_t*)str)
void _string_delete(string_t* str);

/**
* Текущая длина строки.
*/
#define string_length(str) _string_length((const string_t*)str)
int _string_length(const string_t* str);

/**
* Изменение длины строки.
*/
#define string_resize(str, sz) _string_resize((string_t*)str, sz)
int _string_resize(string_t* str, int sz);

/**
* Копирование строки из строки С, завершающейся нулем.
*/
#define string_from_c_str(dst, src) _string_from_c_str((string_t*)dst, src)
string_t* _string_from_c_str(string_t* dst, const char* src);

/**
* Добавление символа в строку.
*/
#define string_append_ch(str, ch) _string_append_ch((string_t*)str, ch)
string_t* _string_append_ch(string_t* str, char ch);

/**
* Превращение строки в строку С без добавления нуля на конце.
*/
#define string_data(str) str->data

/**
* Превращение строки в строку С с нулем на конце.
*/
#define string_c_str(str) _string_c_str((string_t*)str)
char* _string_c_str(string_t* str);

#endif
Файл cstring.c:
#include <stdlib.h>

#include "cstring.h"

/**
* Подготовительная площадка для тестирования.
* Если задан макрос UNIT_TESTING, то функции работы с кучей подменяются
* на тестовые.
*/
#if UNIT_TESTING
extern void* _test_malloc(const size_t size, const char* file, const int line);
extern void* _test_calloc(const size_t number_of_elements, const size_t size,
const char* file, const int line);
extern void _test_free(void* const ptr, const char* file, const int line);

#define malloc(size) _test_malloc(size, __FILE__, __LINE__)
#define calloc(num, size) _test_calloc(num, size, __FILE__, __LINE__)
#define free(ptr) _test_free(ptr, __FILE__, __LINE__)
#endif // UNIT_TESTING

/**
* Создание новой строки в куче. Трюк "sizeof(string_t)" используется, чтобы
* правильно отработать ситуацию, если из-за выравнивания между элементами
* структуры string_t 'sz' и 'data' вдруг появится промежуток.
*/
string_t* string_new(int sz) {
return malloc(sizeof(string_t) + sz - 1);
}

/**
* Удаление строки из кучи.
*/
void _string_delete(string_t* str) {
free((void *)str);
}

/**
* Текущая длина строки.
*/
int _string_length(const string_t* str) {
return str->sz;
}

/**
* Изменение длины строки.
*/
int _string_resize(string_t* str, int sz) {
return str->sz = sz;
}

/**
* Копирование строки из строки С, завершающейся нулем.
*/
string_t* _string_from_c_str(string_t* dst, const char* src) {
int sz = strlen(src);
memcpy(dst->data, src, sz);
dst->sz = sz;
return dst;
}

/**
* Добавление символа в строку.
*/
string_t* _string_append_ch(string_t* str, char ch) {
str->data[str->sz++] = ch;
return str;
}

/**
* Превращение строки в строку С с нулем на конце. Фактически,
* в тело строки добавляется ноль и возвращается указатель на данные.
*/
char* _string_c_str(string_t* str) {
str->data[str->sz] = 0;
return string_data(str);
}
Как вы заметили, в коде есть специальный блок, ограниченный макросом UNIT_TESTING. Ничего не поделаешь, в языке С приходится "готовить" код к потенциальному тестированию и вставлять фрагменты, позволяющие тестовой среде работать с этим кодом. Этот блок, если задан макрос UNIT_TESTING, переопределяет функции работы с кучей, чтобы можно было перехватывать их вызовы. Подменяющие функции _test_malloc(), _test_calloc() и _test_free() предоставляются библиотекой cmockery.

Теперь файл тестов cstring_unittest.c:

#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmockery.h>

#include "cstring.h"

/**
* Тестируем декларацию строки длиной 20 в виде автоматической
* переменной, добавляем в нее два символа, обрезаем строку
* до длины в один байт и проверяем, добавился ли 0 при преобразовании
* в строку С.
*/
void string_c_str_test(void **state) {
decl_string_t(a, 20);
a.data[0] = 'a';
a.data[1] = 'b';
a.sz = 1;
assert_memory_equal("a\0", string_c_str(&a), 2);
}

/**
* Тестируем изменение длины строки.
*/
void string_resize_test(void **state) {
decl_string_t(a, 20);
a.sz = 2;
string_resize(&a, 1);
assert_int_equal(1, string_length(&a));
}

/**
* Тестируем добавление символа путем сравнения со строками С
*/
void string_append_ch_test(void **state) {
decl_string_t(a, 20);
assert_string_equal("", string_c_str(&a));
assert_string_equal("a", string_c_str(string_append_ch(&a, 'a')));
assert_string_equal("ab", string_c_str(string_append_ch(&a, 'b')));
}

/**
* Тестируем декларацию строки в виде автоматической переменной.
* Длина строки сразу после декларации должна быть нулевой.
*/
void string_declare_test(void **state) {
decl_string_t(a, 20);
assert_int_equal(0, string_length(&a));
}

/**
* Тестируем размещение новой строки в куче и ее удаление из нее.
*/
void string_heap_allocation_test(void **state) {
string_t* a = string_new(20);
string_delete(a);
}

/**
* Тестируем копирование строки из строки С с нулем на конце.
*/
void string_from_c_str_test(void **state) {
string_t* a = string_new(8);
string_from_c_str(a, "12345678");
assert_int_equal(8, string_length(a));
string_delete(a);
}

/**
* Создаем список тестов и запускаем их.
*/
int main(int argc, char* argv[]) {
const UnitTest tests[] = {
unit_test(string_declare_test),
unit_test(string_c_str_test),
unit_test(string_append_ch_test),
unit_test(string_heap_allocation_test),
unit_test(string_from_c_str_test),
unit_test(string_resize_test),
};
return run_tests(tests);
}
Схема очень похожа на любое другое xUnit тестирование: каждый тест проверяет какой-то один функциональный элемент, тесты объединяются в группы и запускаются автоматически все вместе. Правда, из-за ограничений языка С каждый тест приходится вручную добавлять в список запуска, увы.

Как я уже сказал, для компиляции потребуются файлы cmockery.c и cmockery.h (см. выше). Эти файлы можно положить в текущий каталог.

Компилируем в Visual Studio:

cl /DUNIT_TESTING /I. cstring_unittest.c cstring.c cmockery.c
Если все скомпилировалось нормально, то запускаем файл cstring_unittest:
string_declare_test: Starting test
string_declare_test: Test completed successfully.
string_c_str_test: Starting test
string_c_str_test: Test completed successfully.
string_append_ch_test: Starting test
string_append_ch_test: Test completed successfully.
string_heap_allocation_test: Starting test
string_heap_allocation_test: Test completed successfully.
string_from_c_str_test: Starting test
string_from_c_str_test: Test completed successfully.
string_resize_test: Starting test
string_resize_test: Test completed successfully.
All 6 tests passed
Все тесты отработали правильно.

Но неинтересно, когда все работает. Внесем в тест библиотеки "случайные ошибки". Каждую из них можно спокойно допустить непреднамеренно. Строки с ошибками я пометил комментариями со словом "ОШИБКА (!)". Посмотрим, как cmockery справится с этим.

Файл cstring.c с "ошибками":

#include <stdlib.h>

#include "cstring.h"

/**
* Подготовительная площадка для тестирования.
* Если задан макрос UNIT_TESTING, то функции работы с кучей подменяются
* на тестовые.
*/
#if UNIT_TESTING
extern void* _test_malloc(const size_t size, const char* file, const int line);
extern void* _test_calloc(const size_t number_of_elements, const size_t size,
const char* file, const int line);
extern void _test_free(void* const ptr, const char* file, const int line);

#define malloc(size) _test_malloc(size, __FILE__, __LINE__)
#define calloc(num, size) _test_calloc(num, size, __FILE__, __LINE__)
#define free(ptr) _test_free(ptr, __FILE__, __LINE__)
#endif // UNIT_TESTING

/**
* Создание новой строки в куче. Трюк "sizeof(string_t)" используется, чтобы
* правильно отработать ситуацию, если из-за выравнивания между элементами
* структуры string_t 'sz' и 'data' вдруг появится промежуток.
*/
string_t* string_new(int sz) {
return malloc(sizeof(string_t) + 1 - 1); // (ОШИБКА!) "Неверная" длина.
}

/**
* Удаление строки из кучи.
*/
void _string_delete(string_t* str) {
// (ОШИБКА!) "Забыли" вызвать free().
}

/**
* Текущая длина строки.
*/
int _string_length(const string_t* str) {
return str->sz;
}

/**
* Изменение длины строки.
*/
int _string_resize(string_t* str, int sz) {
return str->sz; // (ОШИБКА!) "Забыли" уменьшить длину строки.
}

/**
* Копирование строки из строки С, завершающейся нулем.
*/
string_t* _string_from_c_str(string_t* dst, const char* src) {
int sz = strlen(src);
memcpy(dst->data, src, sz);
// (ОШИБКА!) "Забыли" присвоить длине новое значение.
return dst;
}

/**
* Добавление символа в строку.
*/
string_t* _string_append_ch(string_t* str, char ch) {
str->data[str->sz] = ch; // (ОШИБКА!) "Забыли" увеличить длину.
return str;
}

/**
* Превращение строки в строку С с нулем на конце. Фактически,
* в тело строки добавляется ноль и возвращается указатель на данные.
*/
char* _string_c_str(string_t* str) {
// (ОШИБКА!) "Забыли" добавить 0 в конец.
return string_data(str);
}
Компилируем и запускаем:
string_declare_test: Starting test
string_declare_test: Test completed successfully.
string_c_str_test: Starting test
difference at offset 1 0x00 0x62
1 bytes of 0x0040f014 and 0x0012fe7c differ
ERROR: cstring_unittest.c:19 Failure!
string_c_str_test: Test failed.
string_append_ch_test: Starting test
"ab" != "b"
ERROR: cstring_unittest.c:39 Failure!
string_append_ch_test: Test failed.
string_heap_allocation_test: Starting test
Blocks allocated...
0x00326ee0 : cstring.c:27
ERROR: string_heap_allocation_test leaked 1 block(s)
string_heap_allocation_test: Test failed.
string_from_c_str_test: Starting test
Blocks allocated...
0x00326ee0 : cstring.c:27
Guard block of 0x00326f18 size=8 allocated by cstring.c:27 at 0x00326f20 is corrupt
ERROR: cmockery.c:1379 Failure!
string_from_c_str_test: Test failed.
string_resize_test: Starting test
0x1 != 0x2
ERROR: cstring_unittest.c:29 Failure!
string_resize_test: Test failed.
5 out of 6 tests failed!
string_c_str_test
string_append_ch_test
string_heap_allocation_test
string_from_c_str_test
string_resize_test
Blocks allocated...
0x00326ee0 : cstring.c:27
Guard block of 0x00326f18 size=8 allocated by cstring.c:27 at 0x00326f20 is corrupt
ERROR: cmockery.c:1379 Failure!
Бам! 5 из 6 тестов сломаны. Проанализируем полученное.

Тест string_c_str_test выявил, что функция string_c_str не добавила 0 в конец строки, хотя должна была:

string_c_str_test: Starting test
difference at offset 1 0x00 0x62
1 bytes of 0x0040f014 and 0x0012fe7c differ
ERROR: cstring_unittest.c:19 Failure!
string_c_str_test: Test failed.
Тест string_append_ch_test выявил, что функция добавления символа в конец строки не работает:
string_append_ch_test: Starting test
"ab" != "b"
ERROR: cstring_unittest.c:39 Failure!
string_append_ch_test: Test failed.
Тест string_heap_allocation_test выявил, что у нас имеется неосвобожденный блок памяти (утечка?). Конечно, мы же "забыли" освободить память в функции string_delete():
string_heap_allocation_test: Starting test
Blocks allocated...
0x00326ee0 : cstring.c:27
ERROR: string_heap_allocation_test leaked 1 block(s)
string_heap_allocation_test: Test failed.
Тест string_from_c_str_test выявил, что мы "вылезли" за границы выделенного куска памяти. Мы записали что-то мимо. Это болезненная ошибка. Конечно, cmockery не всегда может находить такие ляпы. Например, если переменная выделена с стеке, а не в куче, то проблема не вскроется. Тут уже помогут только динамические отладчики типа valgrind:
string_from_c_str_test: Starting test
Blocks allocated...
0x00326ee0 : cstring.c:27
Guard block of 0x00326f18 size=8 allocated by cstring.c:27 at 0x00326f20 is corrupt
ERROR: cmockery.c:1379 Failure!
string_from_c_str_test: Test failed.
Тест string_resize_test показал, что функция изменения размера строки не работает как положено:
string_resize_test: Starting test
0x1 != 0x2
ERROR: cstring_unittest.c:29 Failure!
string_resize_test: Test failed.
В целом, очень неплохие результаты.
Теперь представьте, что вы решили переписать реализацию библиотеки под новый процессор, чтобы работало в десять раз быстрее. Но как проверить результат? Элементарно. Запустите старые тесты. Если они работают, то по крайней мере с большой вероятностью вы не сломали старую функциональность. И, кстати, чем более тщательно написаны тесты, тем более ценны они. Чем более критична какая часть системы для стабильности системы в целом (например, библиотека строк или каких-то базовых контейнеров), тем более тщательно они должны быть покрыты тестами.
Конечно, уровень комфорта при написании тестов на С и их отладке очень далек даже от С++, но это не может быть оправданием для отказа от тестирования. Честно могу сказать, часто результатом работы "сломанного" теста в С, который неверно работает с памятью, например, может является просто зависание, а не красивый отчет, что тест "не работает". Но даже такой "знак" очень важен и дает понять, что что-то сломано. Пусть лучше повиснет тест, нежели готовый продукт у заказчика.

Под занавес приведу список основных функций-проверок (assert-фукнции), которые доступны в cmockery:

  • assert_true(), assert_false() — проверка булевых флагов
  • assert_int_equal(), assert_int_not_equal() — сравнение для типа int
  • assert_string_equal(), assert_string_not_equal() — сравнение для типа char* (для С-строк, заканчивающихся нулем)
  • assert_memory_equal(), assert_memory_not_equal() — сравнение кусков памяти
  • assert_in_range(), assert_not_in_range() — проверка нахождения числа в указанном интервале
  • assert_in_set(), assert_not_in_set() — проверка нахождения строки (char*) среди заданного набора строк
  • fail() — безусловное завершения теста с ошибкой
Вывод

Unit-тестирование в С порой сопряжено с трудностями, но оно возможно. И нет причин от него отказываться.

15 комментариев:

  1. Отличная статья, спасибо. Появилось несколько вопросов по ней, однако я сначала хочу поиграться с кодом.

    И раз уж я раньше говорил о своей методе тестирования с помощью питона, то постараюсь представить вам свой вариант тестов для кода из этой статьи. Сразу оговорюсь -- отслеживание утечек памяти я раньше не делал, да и не питоновская это фишка, так что тут однозначно будет проигрыш.

    ОтветитьУдалить
  2. спасибо за статью. выходит что действительно без #if UNIT_TESTING
    #endif
    не обойтись...

    ОтветитьУдалить
  3. Denis Gorodetskiy: Да, приходится из-за несовершенства некоторого языкового так делать.

    ОтветитьУдалить
  4. Этот комментарий был удален автором.

    ОтветитьУдалить
  5. Этот комментарий был удален автором.

    ОтветитьУдалить
  6. Александр, а пробовали ли Вы фреймворк "check" (http://check.sourceforge.net/). Что можете про него сказать?

    ОтветитьУдалить
  7. Не, до этого не пробовал. Но сейчас глянул. После пятиминутного осмотра мне показалось, что там практически не сказано, работает ли это под Windows. Можно, конечно, просто исходники в кучу свалить и скомпилить, но там все разбито на множество файлов, так что процесс несколько усложняется. Поэтому я пока прекратил осмотр.

    Если будет личный опыт использования, то будет очень интересно узнать подробности.

    Пока я пришел к личному выводу, что ооочень удобно, когда библиотеку тестирования можно собирать прямо с проектом, а не компилить заранее. Поэтому удобно, когда сборка ее _максимально_ упрощена.

    ОтветитьУдалить
  8. На странице загрузки написано: linux, mac, windows, bsd, solaris, others

    Наверное пощупаю, если смогу запустить на windows, напишу комментарий.

    ОтветитьУдалить
  9. > Пока я пришел к личному выводу, что ооочень удобно, когда библиотеку тестирования можно собирать прямо с проектом, а не компилить заранее.

    Это накладывает определенные требования на лицензию фреймворка: нужны фреймворки с BSD/MIT лицензией, если у вас проприетарный код.

    ОтветитьУдалить
  10. Хм, я как-то об этом не думал. Я как-то был уверен, если библиотека используется в _неизмененном_ виде, то я не обязан открывать остальные исходники.

    ОтветитьУдалить
  11. Стандартная отмазка: IANAL (я не юрист).

    Тут про LGPL написано немного мутновато: http://ru.wikipedia.org/wiki/LGPL

    Однако чистый GPL -- это так называемая "вирусная" лицензия, потому что она "заражает" код с которым линкуется и принуждает открывать его сырцы под GPL.

    Поэтому я лично предпочитаю использовать продукты с BSD лицензией для рабочих проектов.

    ОтветитьУдалить
  12. (написал комментарий -- а блоггер упал с ошибкой)

    Стандартная отмазка: IANAL (я не юрист).

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

    А BSD/MIT вообще просто песня. Поэтому предпочитаю использовать только BSD/MIT.

    ОтветитьУдалить
  13. извините за дублирование. не мой день.

    ОтветитьУдалить
  14. Александр, подскажите, если сталкивались: существует ли более-менее кросс-платформенная библиотека для эмуляции файловой системы, которую бы можно было использовать в тестах вместо реального доступа к диску?

    ОтветитьУдалить