Последние пару недель я работал над написанием модуля Python на C. Будучи новичком в C, я изо всех сил пытался выразить свою реализацию в тестах, и сначала я винил язык. Я хочу поделиться шаблоном, который я использовал, который помог мне смоделировать функции.
Я надеюсь на эту статью, что кто-то еще, желающий начать писать макеты в своих тестах C, найдет это полезной отправной точкой.
Начнем с более ранней версии функции из моего проекта:
QtbColumn *qtb_column_new_many(size_t n) { QtbColumn *columns; columns = (QtbColumn *)malloc(sizeof(QtbColumn) * n); if (columns == NULL) return NULL; for (size_t i = 0; i < n; i++) { columns[i].name = NULL; columns[i].type = NULL; } return columns; }
Вкратце, эта функция создает n QtbColumns в куче и инициализирует некоторые элементы каждого столбца значением NULL.
При написании этой функции я игрался с тестовым фреймворком под названием cmocka. Один из первых написанных мной тестов выглядел примерно так:
static void test_qtb_column_new_succeeds(void **state) { QtbColumn *columns; columns = qtb_column_new_many(10); assert_non_null(column); for (size_t i = 0; i < 10; i++) { assert_null(columns[i].name) assert_null(columns[i].type) } free(columns); }
Это испытание счастливого пути. Я утверждал, что когда эта функция завершается успешно, она возвращает значение, отличное от NULL, и имя и тип каждого из 10 базовых столбцов имеют значение NULL.
Моей следующей мыслью было: «Отлично, а теперь, что произойдет, если malloc не сработает?», за которой сразу же последовало: «Как я могу заставить malloc не проверять такое поведение?». В этот момент я находился на незнакомой территории.
Погуглив, я обнаружил, что компоновщик может заменить реализацию данной функции с помощью флага. Это позволило бы мне написать поддельный malloc, который всегда дает сбой, и заставить компоновщика заменить настоящий malloc после компиляции. С точки зрения реализации это будет выглядеть примерно так:
bool MALLOC_FAILS;
void *__real_malloc(size_t bytes);
void *__wrap_malloc(size_t bytes) {
if (MALLOC_FAILS == true)
return NULL;
return __real_malloc(bytes);
}
static void test_qtb_column_new_succeeds(void **state) {
QtbColumn *columns;
MALLOC_FAILS = false;
columns = qtb_column_new_many(10);
assert_non_null(column);
for (size_t i = 0; i < 10; i++) {
assert_null(columns[i].name)
assert_null(columns[i].type)
}
free(columns);
}
static void test_qtb_column_new_fails(void **state) {
QtbColumn *columns;
MALLOC_FAILS = true;
columns = qtb_column_new_many(10, &failingMalloc);
assert_null(column);
}
Тогда компиляция будет выглядеть примерно так:
gcc -Wl,--wrap=malloc ...
Хотя это работало, было несколько недостатков, которые я посчитал достаточно значительными, чтобы не хотеть его использовать:
malloc заменяется глобально и требует более интеллектуальной подделки, чтобы иметь возможность выборочно подделывать только некоторые malloc.
Компоновщик OSX не поддерживает обертку, и выполнение этих тестов потребует использования докера для запуска этих тестов (помните, что я создаю модуль Python, поэтому хотел бы поддерживать запуск тестов в Windows, Linux и OSX).
Компоновщик знает макеты тестов, и это кажется странным.
Мне нужно было лучшее решение — ввести указатели на функции. Я повторно реализовал функцию, чтобы она выглядела так:
typedef void *(*mallocer)(size_t size); QtbColumn *qtb_column_new_many(size_t n, mallocer m) { QtbColumn *columns; columns = (QtbColumn *)(*m)(sizeof(QtbColumn) * n); if (columns == NULL) return NULL; for (size_t i = 0; i < n; i++) { columns[i].name = NULL; columns[i].type = NULL; } return columns; }
Мне это нравится больше, чем использование обертки; он работает на моих целевых платформах, компоновщику не нужно знать о специфике моих тестов, а malloc можно заменить очень целенаправленным образом. Здесь, конечно, есть проблема — я изменил сигнатуру функции. Давайте посмотрим, как эта функция используется сейчас по сравнению с тем, что было раньше:
// Before qtb_column_new_many(10); // After qtb_column_new_many(10, &malloc);
Это на самом деле довольно грубо, теперь вызывающие функции должны подчиняться прихотям деталей реализации. Чтобы обойти эту новую проблему, я позаимствовал идею у Python.
Разработчики Python согласны с тем, что любая функция, начинающаяся с символа подчеркивания, не должна использоваться или рассматриваться как часть контракта. Я мог бы использовать ту же концепцию в C вместе с макросом, чтобы восстановить прежнюю славу сигнатуры функции:
typedef void *(*mallocer)(size_t size); QtbColumn *_qtb_column_new_many(size_t n, mallocer m) { QtbColumn *columns; columns = (QtbColumn *)(*m)(sizeof(QtbColumn) * n); if (columns == NULL) return NULL; for (size_t i = 0; i < n; i++) { columns[i].name = NULL; columns[i].type = NULL; } return columns; } #define qtb_column_new_many(n) _qtb_column_new_many(n, &malloc);
Теперь пользователям этой функции не нужно беспокоиться о том, следует ли предоставлять malloc, и тестировщики могут использовать версию этой функции с подчеркиванием, чтобы изменить поведение malloc:
void *failing_malloc(size_t bytes) { return NULL; } static void test_qtb_column_new_succeeds(void **state) { QtbColumn *columns; columns = qtb_column_new_many(10); assert_non_null(column); for (size_t i = 0; i < 10; i++) { assert_null(columns[i].name) assert_null(columns[i].type) } free(columns); } static void test_qtb_column_new_fails(void **state) { QtbColumn *columns; columns = _qtb_column_new_many(10, &failing_malloc); assert_null(column); }