Привет, Хабр.

Скорее всего то, о чем я сейчас расскажу, уже было реализовано и не единожды.

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

Итак, проблема

Каждый, уважающий себя проект, проводит юнит‑тестирование (оно же модульное тестирование), а также покрытие кода. В данной статье я рассчитываю, что читатель знаком со словами «юнит‑тестирование» и «покрытие кода» и не буду разбирать эти понятия в рамках данной статьи. Для последнего, я использую программу gcov из семейства GCC.

GCC — это GNU Compilers Collection, набор компиляторов проекта GNU. Прежде всего это компиляторы C/C++ (gcc и g++). GCC входит в тройку лидирующих C/C++ компиляторов: GCC, Clang, MSVC.

В качестве библиотеки для проведения юнит‑тестирования в данной статье я буду использовать check (который ‑lcheck ‑lsubunit), потому как и сам код проекта (совершенно открытого и академического, ссылка будет внизу), на базе которого я буду приводить примеры, написан на языке C.

Но с тем же успехом вы можете использовать вашу любимую библиотеку, который вы обычно используете, например Google C++ Testing Framework (который ‑lgtest ‑lgmock) для C++.

Те, кто применяет line‑based и branch‑based покрытие, знает как тяжело поднять процент покрытия на этих самых branch'ах (далее — ветках). Особенно, это касается файловых дескрипторов (об этом возможно, когда‑нибудь выйдет отдельная статья) и динамического выделения памяти. Бывает, уже перебрал в тестах все доступные тебе «плохие» варианты, но зеленой зоны покрытия (90%) так и не достиг. Хочу привести пример кода такой функции:

Line‑based покрытие в зеленой зоне (91.3%), а branch‑based — как всегда нет (53.8%). Давайте рассмотрим, что здесь происходит.

Функция динамически выделяет в памяти структуру и матрицу в ней. Код построен блоками так, что каждый последующий блок не будет выполняться, если работа предыдущего блока завершилась с ошибкой. Первый блок (в строке 22) запрашивает память размера структуры, второй (в строке 28) — массива указателей на строки матрицы и третий (в строке 36) — пытается аллоцировать сами строки матрицы. Беда в том, что у них это всегда получается.

  1. Как видно из строки 15, ветки плохих аргументов мы проверили.

  2. Ветки строки 42 не реализованы, потому что не наступает такой ситуации, когда результирующий указатель уже получил адрес, но при этом возникла бы ошибка в дальнейшей работе функции. Другими словами, чтобы первый блок отработал успешно, а ошибки возникли в следующих.

И даже если бы мы нашли способ (а такие способы есть) запретить выделение памяти при работе теста вообще — это не принесло бы нам сколько‑нибудь значительного результата. Было бы примерно так:

Лучше. Но недостаточно лучше. Покрытие ветвей выросло до 76.9%. Что именно произошло?

  1. Тест не смог выделить память в первом же блоке и поднял покрытие ветки в строках 23–24. Также, случилось нужное нам ветвление в строках 26, 33 и 42, где следующие блоки поняли, что то‑то идет не так. В остальном все осталось так же. Блок самоочистки так и не наступил, потому как результирующий указатель все еще пуст. Что дальше? Запрашивать какой‑то гигантский размер памяти через аргумент size? Возможно у нас и получится удивить систему, запрашивая матрицу размером 65 535×65 535 (максимальное число, которое влезет в аргумент, который uint16_t). Но давайте поищем способ, дающий более точный результат, чем «возможно».

Решение

Нам нужен способ ограничивать выделение памяти, причем:

  1. Не во время компиляции, а «на ходу».

  2. Точечно, в тех блоках, которые мы хотим протестировать.

Как нам это сделать? А вот как:

Мы напишем свои, кастомные функции‑обертки над функциями malloc() и calloc() из стандартной библиотеки:

#include <dlfcn.h>

void *malloc(size_t __size) {
  void *result = NULL;
  void *(*libc_malloc)(size_t) = NULL;
  *(void **)(&libc_malloc) = dlsym(RTLD_NEXT, "malloc");
  if (!memory_locked(__size, 0)) result = libc_malloc(__size);
  return result;
}

void *calloc(size_t __nmemb, size_t __size) {
  void *result = NULL;
  void *(*libc_calloc)(size_t, size_t) = NULL;
  *(void **)(&libc_calloc) = dlsym(RTLD_NEXT, "calloc");
  if (!memory_locked(__size, 0)) result = libc_calloc(__nmemb, __size);
  return result;
}

Что здесь происходит? Мы взяли библиотеку для работы с динамически подключаемыми библиотеками (простите за тавтологию) dlfcn (которая -ldl) и используем её функцию dlsym() для получения адреса оригинальной функции в стандартной библиотеке libc.

После этого мы используем некоторый хедтрик, для того чтобы превратить полученный адрес в адрес функции и можно приступать к главному — написанию функции‑дистрибьютора, которая будет поставлять нам решение, можно ли пустить запрос на выделение памяти, пришедший из нашего теста дальше, к оригинальной функции стандартной библиотеки. Вот она:

int memory_locked(size_t size, int locked) {
  static size_t value = 0;
  int result = 0;
  if (locked == 1) value = size;
  if (locked == -1) value = 0;
  if (locked == 0 && value == size) result = 1;
  return result;
}

На всякий случай, проговорю: Функция хранит размер в байтах, который выдавать нельзя.

  1. Функция сохранит значение size если ключ locked == 1(установка значения)

  2. Забудет значение, если ключ == -1(удаление значения)

  3. Вернёт сигнал 1 если запрошенное и сохраненное совпадают, а ключ не установлен (0)

Как это применить? Вот так:

START_TEST(suite_figure_create_test1) {
  figure_t *figure = NULL, *check = NULL;
  memory_locked(sizeof(figure_t), 1);
  figure = figure_create(10);
  memory_locked(0, -1);
  check = figure;
  figure_destroy(figure);
  ck_assert_ptr_null(check);
}
END_TEST

START_TEST(suite_figure_create_test2) {
  figure_t *figure = NULL, *check = NULL;
  memory_locked(sizeof(int*), 1);
  figure = figure_create(10);
  memory_locked(0, -1);
  check = figure;
  figure_destroy(figure);
  ck_assert_ptr_null(check);
}
END_TEST

START_TEST(suite_figure_create_test3) {
  figure_t *figure = NULL, *check = NULL;
  memory_locked(sizeof(int), 1);
  figure = figure_create(10);
  memory_locked(0, -1);
  check = figure;
  figure_destroy(figure);
  ck_assert_ptr_null(check);
}
END_TEST

Рассмотрим: В первом тесте мы запрещаем выделение памяти размером с sizeof(figure_t), в втором тесте мы запрещаем выделение памяти размера sizeof(int*) и в третьем — размера самого int - sizeof(int). И наконец, давайте проверим, к чему это нас приведёт.

Результат

100%. И это только и исключительно благодаря работе с памятью. Все же, хочу обратить ваше внимание на несколько деталей:

  1. При работе в таком манере с размерностью int'а будьте готовы, что у вас появится целая гора Sega Mega Drive неожиданных ошибок при работе с памятью, благо вы точно знаете где вы это включили. Вам понадобятся функции, очень качественно обращающиеся с памятью (как например мой деструктор figure_destroy())

  2. Под valgrind'ом эта техника работать не будет, так как он сам транслирует вашу программу тестов, а у него системные функции не переопределены и работают оригинальные. То есть, утечки на тестах отладить вы сможете, но результаты выполнения самих тестов под valgrind'ом будут отличаться.

В завершении статьи — бонус. Вот еще пара функций, которая мне также неоднократно пригождалась, для неё я рекомендую завести дистрибьютор с другим именем, чтобы не пересекались с выделением памяти (в моем случае — memory_locked_mem()).

void *memset(void *__s, int __c, size_t __n) {
  void *result = NULL;
  void *(*libc_memset)(void *, int, size_t) = NULL;
  *(void **)(&libc_memset) = dlsym(RTLD_NEXT, "memset");
  if (!memory_locked_mem(__n, 0)) result = libc_memset(__s, __c, __n);
  return result;
}

void *memcpy(void *__dest, const void *__src, size_t __n) {
  void *result = NULL;
  void *(*libc_memcpy)(void*, const void*, size_t) = NULL;
  *(void **)(&libc_memcpy) = dlsym(RTLD_NEXT, "memcpy");
  if (!memory_locked_mem(__n, 0)) result = libc_memcpy(__dest, __src, __n);
  return result;
}

Ссылка на репозиторий проекта

Комментарии (13)


  1. rsashka
    16.05.2024 08:40
    +2

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

    Но потом стало очевидно, что голый "процент покрытия", это вспомогательный параметр оценки качества и подобные финты с эмуляцией ошибок выделения памяти помогают закрывать договора (в которых "процент покрытия" указан ключевым показателем), но слабо влияют на реальное качество кода.

    Гораздо лучшая стратегия, реализовать архитектуру приложения без необходимости ручного управления памятью (shared_ptr, unique_ptr), а освободившиеся ресурсы перенаправить на тестирование логики, которая в свою очередь должна покрываться тестами на 100% без исключения.


    1. baharev_il Автор
      16.05.2024 08:40

      Спасибо вам за проявленный интерес.

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

      Более того, использование умных указателей (которых нет в Си) - также совсем не панацея от всего и подлежит тестированию.


      1. rsashka
        16.05.2024 08:40

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

        Кажется, вы не поняли мою мысль. Я пытался вам сказать, что лучше сосредоточиться на проверке качества выполнения целевой функции приложения, т.е. на работе бизнес логики, а генерация и обработка надуманных ошибок при ручном управлении памятью, это из области индусского кода. т.е. текста много, но пользы от этого мало, т.к. вы перераспределяется усилия команды (ограниченные ресурсы) на имитацию надуманных ошибок, обработка которых может быть сделана за счет правильной архитектуры приложения.

        Более того, использование умных указателей (которых нет в Си) - также совсем не панацея от всего и подлежит тестированию.

        С этим соглашусь, но тут есть другой архитектурный нюанс. Если С++ использовать нельзя или нежелательно (например в прошивках для микроконтроллеров), то нужно смотреть на конкретную задачу. Можно либо отказать от динамического выделения памяти (только статические буферы), либо делать С-ишный враппер над C++ библиотекой.

        И да, все нужно тестировать:-)


        1. baharev_il Автор
          16.05.2024 08:40
          +1

          Понял вас отлично.) Но не согласен с вашей точкой зрения. "Генерация и обработка надуманных ошибок" - de facto стандарт продуктовой разработки и чем они разнообразнее и нетривиальнее - тем лучше. Думаю, здесь меня поддержат многочисленные профессиональные тестировщики. А вот когда все усилия сконцентрированы на одной бизнес-логике и программа ломается на любое отклонение от идеальных условий - как раз то, что вы называете "индусский код".

          Повторю еще раз: Умные указатели не панацея и в рамках С++. Особенно, когда речь идет о многопоточности (еще один продуктовый стандарт). На эту тему уже писали здесь, на Хабре: https://habr.com/ru/articles/311560/


          1. rsashka
            16.05.2024 08:40

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

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

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

            Повторю еще раз: Умные указатели не панацея и в рамках С++. ...

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


          1. rsashka
            16.05.2024 08:40

            Кажется видел тут на Хабре статью, в которой переопределяли менеджер памяти, чтобы достичь близкого к 100% процента покрытия тестами, но там реализация идеи была проще и красивей.

            Перед тестом (или во время запуска) формировался счетчик вызовов malloc, по достижении которого функция возвращала ошибку. И при каждом вызове счетчик последовательно увеличивался от 1 до максимального. И все это работает без правки кода и непонятных наведенных ошибок.

            Но там для подобных извращений была необходимость, т.к. процент покрытия тестами был определен контрактом и являлся одним из критериев приемки проекта, т.е. процент покрытия тестами был оформлен как одно из бизнес-требований.


            1. baharev_il Автор
              16.05.2024 08:40
              +2

              Не будем продолжать, дабы не превратить в спор. Спасибо за идею со счётчиком вызовов.


              1. rsashka
                16.05.2024 08:40
                +1

                Без проблем!

                Спасибо за идею со счётчиков вызовов. Хабр, он такой


  1. domix32
    16.05.2024 08:40
    +1

    Эти приёмы больше смахивают на недофаззинг. Есть инструменты, которые занимаются подобным - скармливают странные/ошибочные данные, немного меняют поведение стандартных функций и прочие прелести LD_PRELOAD. Правда всё это занимает заметно больше времени. Про какой-то похожий test suite расказывал Антон Полухин (~15:30)- наборы для проверки отказоусточивости микросервисов.

    Как альтернатива запускать тесты с ulimit и гнать нагрузку на систему, тогда часть выделений памяти провалится. Правда всё это работает в POSIX-совместимых осях и для Win надо смотреть аналоги отдельно.


    1. baharev_il Автор
      16.05.2024 08:40

      Вы совершенно правы. Точечный, контролируемый фаззинг. Не как с ulimit, а когда точно знаешь где, когда и в какой потоковой функции выстрелит.


  1. firegurafiku
    16.05.2024 08:40
    +1

    Вот еще пара функций, которая мне также неоднократно пригождалась

    А для чего именно вам были полезны такие memset и memcpy? Я вижу, что при некоторых значениях аргумента n они просто возвращают NULL вместо того, чтобы делать свою работу, но ума не приложу, зачем такое поведение может понадобиться в тестах.

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

    Кроме того, memset и memcpy — в некотором роде волшебные функции: компилятор, если увидит такую возможность, реализует их эффект прямо на месте, без вызова библиотечной функции.


    1. baharev_il Автор
      16.05.2024 08:40

      Здесь я просто опирюсь на стандарт POSIX:

      malloc: https://pubs.opengroup.org/onlinepubs/9699919799/functions/malloc.html

      memcpy: https://pubs.opengroup.org/onlinepubs/9699919799/functions/memcpy.html

      Где ответ NULL зарезервирован и является индикатором ошибки.


      1. firegurafiku
        16.05.2024 08:40

        Там по вашей второй ссылке написано:

        The memcpy() function shall return s1; no return value is reserved to indicate an error.

        И то же самое сказано на странице про memset. Для меня это выглядит так, как будто проверять, не вернули ли эти функции NULL, нет никакого смысла.

        С функцией malloc() действительно другая история (и к ней у меня вопросов не было): проверять возврат на не-NULL надо, хотя на практике это не даёт гарантии того, что память реально была выделена и ею можно пользоваться без опаски.