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

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

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

А что же мы имеем в С? Тут, как мне кажется, разрыв в удобстве тестирования по отношению к С++ в разы больше, чем между С++ и 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-тестирование в С порой сопряжено с трудностями, но оно возможно. И нет причин от него отказываться.


Оригинальный пост | Disclaimer

Комментарии