Написать пост

Меняем фреймворк юнит-тестирования одной строчкой кода

Как сменить фреймворк для юнит-тестирования на С и С++, если количество самих тестов слишком велико, а писать их заново не хочется.

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

Введение

В современной разработке сложно представить себе серьезный проект, в котором не использовались бы практики CI/CD, а код не был бы покрыт юнит-тестами, однако при создании прошивок для микроконтроллеров далеко не каждая команда уделяет время настройке и поддержке этих процессов. При работе над одним из медицинских приборов мы обнаружили, что имеющиеся юнит-тесты покрывают только те части кода, которые отвечают за логику и программные вычисления, но совсем не затрагивают тестирование аппаратных функций. Эта статья про то, почему нужно обязательно тестировать вызовы к HW, почему пришлось менять фреймворк юнит-тестирования и как удалось упростить процесс перехода.

Зачем тестировать аппаратные функции?

Ответ на этот вопрос очень простой – затем же, зачем и неаппаратные, то есть, чтобы быть уверенным, что код работает именно так, как ожидается. Некоторые разработчики попадают в ловушку, предполагая, что если используются документированные SDK API, то можно целиком на них положиться и не перепроверять их работу. Однако представим себе разработку устройства на стадии подготовки прототипа. В любой момент может быть принято решение об изменении версии SDK или микроконтроллера, и в этом случае точно придется перепроверять работу аппаратных функций. Такие миграции возможны и при разработке очередной версии уже готового устройства, когда кодовая база успела значительно разрастись. А если продукт уже вышел в серийное производство, то понадобится проверка каждого устройства в заводских условиях. Для этого пишутся специальные тестовые прошивки, базой для которых легко может стать набор юнит-тестов для аппаратных функций.

Еще одна потенциальная проблема – запуск юнит-тестов на ПК недостаточен для проверки правильности вычислений. Из-за различий в аппаратной архитектуре самые обычные операции, например, арифметика чисел с плавающей точкой, могут давать разные результаты.

Ориентируясь на эти тезисы, мы занялись исправлением ситуации на проекте.

Выбираем фреймворк

На проекте на тот момент уже использовался популярный фреймворк GoogleTest для юнит-тестирования кода на С++. О его преимуществах говорить не будем, их много, и статья не о них. В нашем случае были важнее его недостатки, а именно – размер собранной библиотеки, который «съедал» солидный кусок из наших скромных 256KB.

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

На этой стадии получалось, что мы используем Unity для SDK API, и GoogleTest для всего остального. Вариант, в целом, рабочий, и в какой-то момент мы даже думали остановиться на нем несмотря на то, что иметь в проекте разные фреймворки для, по сути, одной задачи, это явно перебор. Но переводить уже имеющуюся базу тестов на новый фреймворк никто не горел желанием. Однако вскоре этот вопрос поднялся снова – часть тестов была направлена на проверку корректности вычислений, и требовала дублирования тестов на обоих фреймворках. А это уже было неприемлемо. Пришлось лезть внутрь исходников GoogleTest и смотреть на реализацию используемых тестовых макросов. Довольно быстро стало ясно, что можно написать собственный преобразователь GoogleTest => Unity, который позволит оставить код имеющихся тестов неизменным.

Пишем gtest2unity.h

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

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

			#define ASSERT_EQ(a, b) TEST_ASSERT_EQUAL(a, b)
#define ASSERT_NE(a, b) TEST_ASSERT_NOT_EQUAL(a, b)
#define ASSERT_TRUE(a) TEST_ASSERT_TRUE(a)
#define ASSERT_NEAR(expected, actual, delta) TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual)
		

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

Теперь переходим к чуть более сложной части. Что еще используется в имеющихся тестах, связанное с GoogleTest?

			class PumpDriverTests : public testing::Test
{
protected:
  PumpDriver pumpDriver;
 
  void SetUp() override
  {
	pumpDriver.init();
  }
 
  void TearDown() override
  {
	pumpDriver.deinit();
  }
};
		

Что ж, тут тоже очевидно, что нужен собственный класс testing::Test, от которого мы сможем наследоваться подобным образом.

			namespace testing
{
class Test
{
protected:
  virtual void SetUp()
  {}
  virtual void TearDown()
  {}
};
} // namespace testing
		

Теперь переходим к последней части, зависимой от GoogleTest:

			TEST_F(PumpDriverTests, testInit)
{
  //some test code here
}
		

Здесь уже приходится обращаться к исходному коду GoogleTest, потому что догадаться, что скрыто за макросом TEST_F, довольно сложно (заинтересованным рекомендую обратиться к гитхабу проекта и изучить макрос GTEST_TEST_ в файле gtest-internal.h). Оказалось, что за TEST_F спрятано довольно много кода, большая часть из которого нам не нужна. Главные пункты, которые нам пришлось адаптировать:

– каждый тест-кейс – отдельный класс, унаследованный от нашего PumpDriverTests;

– тело тест-кейса – реализация приватной функции этого класса;

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

Сначала определим пару макросов для удобства, чтобы легко получать уникальные имена для наших классов и функций:

			#define EXTERN_TEST_F_NAME(test_group, test_name) \
test_group##_##test_name##_caller
#define TEST_F_CLASS_NAME(test_group, test_name) \
test_group##_##test_name##_Test
		

А затем реализуем описанные пункты:

			#define TEST_F(test_group, test_name) \
class TEST_F_CLASS_NAME(test_group, test_name) : public test_group { \
public: \
  void implement(); \
  friend void EXTERN_TEST_F_NAME(test_group, test_name)(); \
}; \
void EXTERN_TEST_F_NAME(test_group, test_name)() \
{ \
  TEST_F_CLASS_NAME(test_group, test_name) t; \
  t.SetUp(); \
  t.implement(); \
  t.TearDown(); \
} \
void TEST_F_CLASS_NAME(test_group, test_name)::implement()
		

Вот мы и получили рабочий вариант gtest2unity.h. Теперь для перевода файла с GoogleTest на Unity достаточно простого:

Ну и, конечно, добавить файлы Unity в систему сборки (например, в CMakeLists.txt). Ошибок компиляции больше нет, и наша работа закончена… или нет? Скомпилировать код без ошибок — это, конечно, хорошо, но сами тесты еще и запустить надо. GoogleTest определяет необходимые методы по умолчанию, но в Unity нужно все делать руками. Программисты, как известно, не очень любят ручную работу, поэтому мы решили написать простой генератор кода запуска тестов.

Генерируем test_runner.cpp

Запустить тесты с помощью Unity не сложно:

			int main()
{
  UNITY_BEGIN();
  RUN_TEST(test_function);
  return UNITY_END();
}
		

Над генератором долго не думаем – выбираем Python и вперед! Хардкодим подключение заголовочных файлов и части с UNITY_BEGIN и UNITY_END в локальные строки. Дальше парсим исходники, но и тут все довольно тривиально – находим макрос TEST_F в коде, вытаскиваем его параметры и записываем их в макрос EXTERN_TEST_F_NAME дважды – первый раз перед main() вместе с extern объявлением (даем понять, что сама функция находится в другом файле), второй уже внутри RUN_TEST, чтобы, соответственно, запустить сам тест.

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

Заключение

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

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

Следите за новыми постами
Следите за новыми постами по любимым темам
985 открытий1К показов