Юнит-тестирование с общих позиций

/ Просмотров: 1176
"The safe assumption is: if it is not tested, it is broken." (Мантра разработчиков компании Kitware)
"So why should we spend time writing tests? Very easy: because it helps make research reproducible." (блог Kitware)
"Writing test scripts helps developers gain insight about their code, and to find more adequate and efficient ways to expose its interface." (там же)
"Moment of Zen. What scientists call 'Experiments', open source developers call 'Tests'." (Ibanez L., Schroeder W.J., Hanwell M.D. Practicing Open Science // Implementing Reproducible Research. 2014. C. 241.)
"Unit testing is not just a software practice — it is a state of mind." (там же)

Введение

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

  1. Быть достаточно исчерпывающими (не параноидальными).
  2. Запускаться автоматически.

Поверхностные юнит-тесты не приносят особой пользы, в то время как их слишком щепетильная реализация может стать неоправданно ресурсоемкой с точки зрения потраченного времени (которое — деньги). Что же касается автоматического запуска, то это требование немедленно вытекает из практики: юнит-тест, прогоняемый вручную, — это «dead man walking». Рано или поздно про него все забудут.

Часто произносятся такие слова, как Test-Driven Development (TDD), адресующие к подходу написания тестов до реализации собственно полезного кода. На практике бывает полезно писать тесты не до, а одновременно с целевой функциональностью, так как предугадать все особенности поведения алгоритма бывает сложно. Наличие же теста на этапе разработки алгоритма позволяет выполнять промежуточные запуски, частичную верификацию и отладку без промедлений.

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

Цель заметки

Мы опишем методику построения юнит-тестов, которая не зависит от среды разработки и достаточно универсальна для применения в любых проектах на C++. Это обмен опытом, который может пригодиться вам в том случае, если ваша цель — быстро (читаем недорого) запрототипировать рабочее решение для встроенного механизма юнит-тестирования. Часто масштаб проекта не настолько велик, чтобы оправдать разработку полноценной системы тестирования (включая, например, технологии continuous integration). Однако гарантировать нерегрессионную разработку так или иначе нужно, если мы говорим о приложениях индустриального качества. Выход состоит в разработке легковесной системы юнит-тестирования, оформленной в виде подключаемой библиотеки.

Принцип

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

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

В нашей заметке мы будем работать в следующих ограничениях:

  1. Язык программирования С++.
  2. Каждый тестируемый алгоритм представлен отдельным классом.
  3. Тесты не являются интерактивными. Нет графического интерфейса пользователя, позволяющего как-то настроить параметры запуска тестов.

Для каждого алгоритма мы создадим соответствующий тестовый класс (Test Case) с набором тестовых функций (Test Function). Тестовый класс выполняет группирующую роль для тестов. Тестовая функция — это полезная начинка, т.е. собственно верификатор. Основные объекты проиллюстрированы на следующем рисунке.

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

Нумерация тестов

Обычно назначение каждой тестовой функции сфокусировано на чем-то конкретном, например, на некоторой специфической вариации входных данных. Скажем, юнит-тесты для NURBS-интерполяции могут использовать различные подходы к выбору параметризации кривой. На практике часто возникает потребность уникальным образом идентифицировать тесты. Привязав к юнит-тесту целочисленный идентификатор, мы можем позднее сослаться на него в проектной документации или баг-трекере. Более того, наличие у каждого теста уникального ID поможет нам осуществить изолированный запуск конкретного сценария или группы сценариев.

Документирование тестов

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

  1. Документация должна быть отделена от кода C++ (принцип отделения содержания от оформления, т.е. логики теста от его описания).
  2. Документация должна позволять использование динамически подставляемых переменных. Например, бывает полезно указать в описании теста те входные параметры, которые используются для запуска целевого алгоритма.

Технологии

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

Базовая библиотека CommonTestLib будет содержать весь полезный инструментарий для реализации юнит-тестов в нашем приложении. На этапе проектирования этой библиотеки желательно минимизировать ее зависимости от всех прочих компонент проекта — так будет легче пересадить это «сердце» юнит-тестирования в другие приложения при необходимости. Содержимое CommonTestLib мы не будем особенно детализировать, так как в огромной степени это зависит от ваших нужд. Скажем лишь, что в типовой реализации автора эта библиотека содержит следующий инструментарий:

  1. Базовый класс для всех тестовых классов.
  2. Утилита для запуска тестов (Launcher).
  3. Утилиты для связывания тестового класса с его документацией.
  4. Утилиты для фильтрации запускаемых тестов по ID.
  5. Утилиты для создания отчетов по итогам тестирования.

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

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

  1. Реализация посредством QTestLib.
  2. «Чистая» С++ реализация.

Qt-based тестирование

Использование библиотеки QTestLib (поставка Qt) является серьезным подспорьем для создания полнофункционального «движка» юнит-тестирования. Фактически, эта библиотека содержит уже готовую реализацию диспетчера в форме единственного метода:

QTest::qExec(...)

В качестве параметра в диспетчер передается тестовый класс, необходимо наследующий QObject. Тестовыми функциями являются все слоты тестового объекта, причем некоторые из них играют чисто утилитарную роль: запускаются строго до и после каждой тестовой функции или всего класса. Мы не будем подробно останавливаться на деталях использования QTestLib, так как вся информация по этой библиотеке есть в открытом доступе.

Qt, как известно, использует дополнительные мета-объекты, с помощью которых реализован базовый механизм сигналов-слотов. Технически это означает, что для каждого тестового класса, наследующего QObject будет необходимо создан соответствующий мета-класс, через который диспетчер осуществляет запуск тестовых функций. Реализуется это довольно простым способом, так как мета-объекты позволяют итерироваться по слотам оригинального объекта и запускать их, передавая актуальный указатель на объект this. Ниже представлена наглядная выдержка из кода библиотеки QTestLib для версии Qt 4.7.4:

static void qInvokeTestMethods(QObject *testObject)
{
  // Берем Meta-объект
  const QMetaObject *metaObject = testObject->metaObject();
  ...
  for ( ... ) { // Итерации по слотам
    QMetaMethod slotMethod = metaObject->method(i);
    ...
    if ( !qInvokeTestMethod( slotMethod.signature() ) )
      break;
  }
  ...
}

Вызов метода в конечном итоге осуществляется следующим образом:

TestCaseObj->qt_metacall(...)

«Магия» заключается в том, что метод qt_metacall(), реализующий вызов целевого метода по индексу, был добавлен в оригинальный класс TestCaseObj автоматически при помощи мета-объектного компилятора (MOC). Необходимость использовать MOC является платой за гибкость и удобство работы с объектами Qt.

«Чистая» реализация

Случается так, что использовать QTestLib по каким-то причинам невозможно или нежелательно. В этом случае мы вольны реализовать диспетчер самостоятельно.

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

  1. Все тестовые функции являются статическими методами.
  2. Каждый тестовый класс вынужден «регистрировать» свои тестовые функции явно.
Следует обратить внимание на то, что первое ограничение присуще только выбранной нами реализации. Для огромного множества задач достаточно всего-лишь иметь возможность выполнить некоторый тестовый код, тогда как экземпляр тестового класса в принципе не нужен. В случае, если состояние тестового класса имеет критическое значение, можно обратить внимание на библиотеку CppUnit, построенную на тех же принципах, что излагаются в этой статье.

Нетрудно видеть, что оба эти недостатка не присущи юнит-тестированию с использованием Qt. Первое ограничение является наиболее жестким, так как статичность методов лишает нас какой-либо возможности сохранять внутреннее состояние тестового класса в его полях. Второе ограничение некритично и на практике не более назойливо, чем необходимость декларировать тестовые функции как слоты в случае QTestLib. Приведем пример тестового класса для «чистой» реализации:

class QrTest_BasisEffectiveNDers
{
public:
  
  static void Functions(QrTestFunctions& functions)
  {
    functions << &test1
              << &test2
              << &test3;
  }
  
private:
  
  static QrBool test1(const QrInt funcID);
  static QrBool test2(const QrInt funcID);
  static QrBool test3(const QrInt funcID);
  
};

Тестовый класс QrTest_BasisEffectiveNDers декларирует несколько тестовых функций: test1(), test2() и test3(). Все эти функции являются статическими и объявляются в приватной секции класса. В свою очередь, метод Functions() занимается тем, что перекладывает указатели на тестовые функции в коллекцию специального вида: QrTestFunctions. Последняя коллекция — это не более чем удобная обертка для стандартного вектора указателей:

//! Collection of pointers to Test Functions.
class QrTestFunctions
{
public:
  
  //! Default constructor.
  QrTestFunctions() {}
  
public:
  
  //! Adds the passed function pointer to the collection of pointers to
  //! Test Functions.
  //! param funcPtr [in] function pointer to add.
  //! eturn this reference for subsequent streaming.
  QrTestFunctions& operator<<(const QrTestFunction& funcPtr)
  {
    m_testFunctions.push_back(funcPtr);
    return*this;
  }
  
  //! Returns size of the collection of function pointers.
  //! eturn requested size.
  inline size_t Size() const
  {
    return m_testFunctions.size();
  }
  
  //! Returns function pointer for the given index.
  //! param idx [in] index of the function pointer to access.
  //! eturn requested function pointer.
  const QrTestFunction& Func(const QrInt idx) const
  {
    return m_testFunctions.at(idx);
  }
  
private:
  
  //! Internal collection of pointers to Test Functions.
  std::vector<QrTestFunction> m_testFunctions;
  
};

Указатель на тестовую функцию объявлен следующим образом:

//! Pointer to Test Function.
//! Please note that {funcID} should be normally passed by Test Case. The
//! convention is to have {funcID} as 1-based integer number.
typedef QrBool (*QrTestFunction)(const QrInt funcID);

Обратите внимание на сигнатуру тестовой функции — принимаемое целочисленное значение соответствует индексу функции в коллекции типа QrTestFunctions. Этот индекс будет необходим для реализации документирования тестов. Одной из задач диспетчера, таким образом, является передача корректного индекса при вызове каждой тестовой функции.

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

Реализация диспетчера с учетом принятой архитектуры получается довольно простой. Независимо от того, какой тип имеет рабочий тестовый класс, мы можем без труда получить список указателей на его тестовые функции и выполнить их одну за другой. В примере ниже этим занимается метод Launch() диспетчера, реализованного на шаблонах C++:

//! Template-based implementation of Launcher mechanism dedicated to certain
//! Test Case.
template <typename CaseType>
class QrTestLib_CaseLauncher : public QrTestLib_CaseLauncherAPI
{
public:
  
  //! Default constructor.
  QrTestLib_CaseLauncher() : QrTestLib_CaseLauncherAPI()
  {}
  
public:
  
  //! Launches the Test Case of the given type. Returns true in case of
  //! success, false -- otherwise.
  //! eturn true/false.
  virtual QrBool Launch()
  {
    // Collect Test Functions to run
    QrTestFunctions functions;
    CaseType::Functions(functions);
  
    // Run functions one by one
    QrBool areAllOk = QrTrue;
    for ( QrInt f = 0; f < (QrInt) functions.Size(); ++f )
    {
      const QrTestFunction& func = functions.Func(f);
      const QrBool isOk = (*func)(f + 1);
  
      m_funcResults.push_back(isOk);
      if ( !isOk && areAllOk )
        areAllOk = QrFalse;
    }
    return areAllOk;
  }
};

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

Нумерация тестов

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

  • Идентификация тестов в результирующих отчетах.
  • Включение и выключение конкретных тестов из общего запуска.

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

//! IDs for Test Cases.
enum QrTest_CaseID
{
  CaseID_Core_Quaternion = 1,
  
  CaseID_BSpl_Basis_BasisFindSpan,
  CaseID_BSpl_Basis_BasisN,
  CaseID_BSpl_Basis_BasisEffectiveN,
  CaseID_BSpl_Basis_BasisEffectiveNDers,
  CaseID_BSpl_Reconstruct_ParamsUniform,
  CaseID_BSpl_Reconstruct_ParamsChordLength,
  CaseID_BSpl_Reconstruct_ParamsCentripetal,
  CaseID_BSpl_Reconstruct_KnotsAverage
};

Заметим, что это перечисление должно находиться в библиотеке ApplicationTestLib, так как оно имеет отношение к конкретному приложению, но не к общему механизму тестирования. Теперь каждый тестовый класс имеет возможность сообщить свой идентификатор, выбирая нужный из объявленного перечисления.

В случае «чистой» реализации ядра, каждый тестовый класс заявляет собственный ID при помощи специального статического метода:

class QrTest_BasisEffectiveNDers
{
public:
  
  //! Returns Test Case ID.
  //! eturn ID of the Test Case.
  static QrInt ID()
  {
    return CaseID_BSpl_Basis_BasisEffectiveNDers;
  }
  
...
};

В случае тестирования, основанного на библиотеке QTestLib, мы можем поместить декларацию метода CaseID() в базовый класс для тестов:

class CommonTestLib_SuiteObject : public QObject
{
Q_OBJECT
  
public:
  
  CommonTestLib_SuiteObject(QObject* theParent = 0);
  
  virtual ~CommonTestLib_SuiteObject();
  
public:
  
  virtual int
    CaseID() const = 0;
  
...
};

Документирование тестов

Идея документирования заключается в том, что для каждого тестового класса дополнительно создается файл с описанием в произвольной форме. Рассмотрим такое описание на примере:

[TITLE]
  B-Spline basis functions and derivatives
[1-2:OVERVIEW]
  Evaluates non-vanishing B-Spline basis functions along with their derivatives.
[3:OVERVIEW]
  Evaluates 1-st derivative of 3-degree B-Spline basis functions. The results
  secured by this test have been verified visually (manually) according to the
  results given in "The NURBS Book".
[1-3:DETAILS]
  Inputs:
  <ul>
    <li>Knot vector: %%U%%
    <li>Degree: %%p%%
    <li>Parameter to evaluate: %%u%%
    <li>Max derivative order: %%n%%
  </ul>
  Outputs:
  <ul>
    <li>Found span index: %%i%%
    <li>Basis functions & derivatives (dN): %%dN%%
  </ul>

Формат файла выбран в соответствии с нуждами автора. Здесь ключ [TITLE] маркирует общее описание тестового класса, [OVERVIEW] — краткое описание каждой тестовой функции [DETAILS] — дополнительные сведения. Приведенные секции в конечном итоге поступают в соответствующие блоки результирующего HTML-отчета.

Отметим следующие важные моменты относительно приведенного примера:

  1. Синтаксис выбран произвольным образом с единственным требованием: быть интуитивно понятным. Члены вашей команды не должны долго разбираться с очередным вновь изобретенным языком разметки — все должно быть просто.
  2. Индексы тестовых функций, предварающие блоки [OVERVIEW] и [DETAILS] соответствуют локальному номеру функции в тестовом классе. В случае использования QTestLib этот номер определяется порядком объявления соответствующего слота. В случае «чистой» реализации используется порядок инициализации в методе Functions() для каждого тестового класса.
  3. Формат предполагает использование переменных, заявленных при помощи синтаксиса %%VarName%%. Фактические значения переменных выставляются в ходе выполнения каждой конкретной тестовой функции. За счет этого мы получаем возможность снабжать статический текст описания динамическими значениями переменных, используемых в коде.

Поиск файла с описаниями можно автоматизировать в случае использования QTestLib, если договориться, что имена файлов совпадают с именами тестовых классов. Последние имена легко получить через мета-объект. В случае же «чистой» реализации можно декларировать имена файлов описаний в каждом конкретном тестовом классе с помощью специальных методов, например:

class QrTest_BasisEffectiveNDers
{
...
  
public:
  
  //! Returns filename for the description.
  //! eturn filename for the description of the Test Case.
  static QrString DescriptionFn()
  {
    return "QrTest_BasisEffectiveNDers";
  }
  
  //! Returns Test Case description directory.
  //! eturn description directory for the Test Case.
  static QrString DescriptionDir()
  {
    return "BSpl";
  }
  
...
};

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

Автозапуск

На данный момент мы отделили «ядро» тестирования как библиотеку, содержащую «джентельменский набор» инструментов, от собственно тестовых классов.

Если ApplicationTestLib содержит достаточно легковесные тесты, то их запуск можно сделать частью процесса компиляции. Например, в MS Visual Studio нетрудно настроить «Post-Build Event» для проекта ApplicationTestLib так, чтобы соответствующий исполнимый файл (содержащий функцию main()) запускался сразу после сборки. Ниже мы приводим пример функции main(), запускающей тесты:

int main(int, char*)
{
  std::vector< QrPtr<QrTestLib_CaseLauncherAPI> > CaseLaunchers;
  CaseLaunchers.push_back( new QrTestLib_CaseLauncher<QrTest_BasisEffectiveN> );
  CaseLaunchers.push_back( new QrTestLib_CaseLauncher<QrTest_BasisEffectiveNDers> );
  CaseLaunchers.push_back( new QrTestLib_CaseLauncher<QrTest_Quaternion> );
  ... /* Many other Test Cases are registered here */
  
  // Launcher of entire test suite
  QrTestLib_Launcher Launcher;
  for ( int c = 0; c < (int) CaseLaunchers.size(); ++c )
    Launcher << CaseLaunchers[c];
  
  if ( !Launcher.Launch(&std::cout) ) // Launch Test Cases
  {
    std::cout << "	***	Tests FAILED" << std::endl;
    return 1;
  }
  
  std::cout << "	***	Tests SUCCEEDED" << std::endl;
  return 0;
}

Альтернативы

Рукописная подсистема юнит-тестирования дает нам максимально гибкий инструментарий уже просто потому, что мы разрабатываем ее под свои конкретные нужды. По мнению автора, позволить себе такую «роскошь» как собственная тестовая библиотека теоретически могут даже маленькие проекты. Действительно, основополагающий принцип запуска тестов прозрачен и прост в реализации — это вызов метода через указатель. Существующие библиотеки обыгрывают этот принцип по-разному, упрощая создание и регистрацию тестовых классов, предоставляя различные удобные макросы, а также наборы для генерации отчетов. Если готовое решение для вас более предпочтительно, чем свое, имеет смысл оглядеться вокруг, так как множество доступных библиотек для юнит-тестирования отнюдь не пустое. На момент написания этой заметки, популярными были следующие продукты (C++):

  • Boost Test
  • Google Test
  • Cpp Unit

В контексте геометрического моделирования и инженерной графики плодотворным может оказаться использование командного интерпретатора Draw Test Harness библиотеки Open CASCADE Technology (OCCT). Этот интерпретатор (по-простому именуемый Draw) работает со сценариями, написанными на языке Tcl, а точнее на его пользовательских расширениях. Преимущество Draw состоит в возможности визуализировать геометрические данные, что особенно актуально в области 3D моделирования.

Итог

Право завершающего слова о богоугодности юнит-тестирования предоставим основателям Kitware. Напомним, что компания Kitware — это небольшая группа квалифицированных разработчиков наукоемкого ПО, работающих в модели Open Source. В таких компаниях бюрократии не место. Они либо ярко живут, либо бесследно исчезают.

"We've found that, even for small projects, testing quickly pays for itself. For us, automated testing identifies roughly four issues for every user-reported issue. Our automated testing has shown that after an issue is fixed, it's often reintroduced in later code changes, and automated testing catches this. In the area of testing, our mantra is, "If it isn't tested, it doesn't work." Even for experienced developers, this is often true. Daily testing also reduces the cost of fixing issues because identifying a problem's source is much easier when you're considering only a single day's changes. On the few occasions when we deferred testing until release, developers had to inspect many months' worth of changes to find an issue's cause." (Martin K., Hoffman B. An Open Source Approach to Developing Software in a Small Organization // IEEE Softw. 2007)

Оставьте комментарий!

Имя и сайт используются только при регистрации

Выберите человечка с поднятой рукой!

При нажатии на картинку, Ваш комментарий будет добавлен.