Последние пару недель я работал над написанием модуля 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);
}