Юнит-тестирование. Пример. Boost Unit Test

Разработка и поддержка программ невозможна без внесения изменений в существующий код. Однако, всякое изменение сопряжено с возможным внесением ошибок. Чем больше и сложнее проект — тем более нетривиальным образом изменения могут сказываться на работе подсистем. В связи с этим, любое изменение кода требует проведения тестирования. В статье описываются:

  • теория unit-тестирования;
  • Unit Test Framework библиотеки Boost;
  • пример разработки программы с использованием unit-тестирования.

1 Цели тестирования

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

  • как документация. Тесты отражают ожидаемую реакцию системы на какое-либо воздействие. Информация, записанная в виде тестов, может быть полезна при ознакомлении с устройством программы (например, при попадании в проект нового разработчика);
  • для ознакомления с проектом программист может покрыть код тестами (так называемыми «учебными тестами«). При этом разработчик сформулирует свои ожидания к коду (в виде наборов тестовых данных) и проверит их корректность;
  • в случае использования сторонних библиотек можно натолкнуться на неприятности при обновлении. Разработчики библиотек могут исправить какие-либо ошибки в своем коде, но это может привести к тому, что наш собственный проект сломается. Одним из возможных решений проблемы является локализация обращений к чужому коду при помощи шаблона проектирования «адаптер» [1]. Адаптер покрывается тестами и, в случае изменения стороннего кода, мы быстро узнаем что именно сломалось;
  • для формализации требований заказчика (тесты заказчика, приемочные тесты). Одним из принципов, модного в настоящее время, экстремального программирования является тесное взаимодействие с заказчиком. Заказчик, так или иначе, формулирует свои требования, которые можно выразить в виде наборов тестов.

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

2 Организация тестирования

Тесты (test cases) могут группироваться в наборы (test suites). В случае провала тестирования, программист увидит имена виновников — наборов и тестов. В связи с этим, имя должно сообщать программисту о причинах неприятности и вовлеченных модулях.

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

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

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

Мало того, что основной проект не должен зависеть от тестов — тесты не должны зависеть друг от друга. В противном случае на результаты тестирования может оказывать влияние порядок выполнения тестов (т.е. фаза луны). Из этого простого правила можно сделать следующие важные выводы:

  • недопустимо изменять глобальные объекты в тестах и тестируемых модулях. Если же тестируемый код взаимодействует с глобальным объектом (например базой данных), то проверка корректности такого взаимодействия — удел системного тестирования;
  • каждый тестовый случай должен быть настроен на выявление лишь одной возможной ошибки — иначе по имени ошибки нельзя установить причину неполадок;
  • модули проекта должны соблюдать принцип единой ответственности (Single responsibility principle, SRP) — в противном случае наши тесты будут выявлять более чем по одной ошибке;
  • тесты не должны дублировать друг друга. Методология разработки через тестирование (test driven developing, TDD) предполагает короткие итерации, каждая из которых содержит этап рефакторинга (переработки кода). Переработке должен подвергаться не только код основного проекта, но и тесты.
Test_Driven_Developing_model

Рис. 1 Модель Test Driven Developing

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

3 Использование boost test framework

Каждый файл, использующий boost test framework, должен подключать соответствующий заголовочный файл:

#include <boost/test/unit_test.hpp>

Как известно, любая программа должна иметь точку входа (функцию, с которой начинается выполнение программы). В зависимости от того, как организован проект, вы можете либо написать эту функцию сами, либо доверить всю работу Boost. Функция main будет добавлена автоматически, если перед подключением unit_test.hpp объявлены константы BOOST_TEST_MAIN и BOOST_TEST_DYN_LINK. Вызовы всех тестов проекта будут автоматически помещены в сгенерированную функцию.

Тестовые случаи группируются в наборы посредством макросов BOOST_AUTO_TEST_SUITE и BOOST_AUTO_TEST_SUITE_END, обозначающих начало и конец набора соответственно. Если тест описан вне набора, то он автоматически попадает в главный test suite. По умолчанию главному набору присвоено имя «Master Test Suite», но можно задать другое — в константе BOOST_TEST_MODULE.

При запуске тестирования программа будет последовательно входить в наборы и выполнять вложенные тесты. Чтобы проследить этот процесс по шагам — можно передать исполняемому файлу аргумент —log_level=test_suite (он используется в первом примере статьи).

Для описания тестовых случаев применяется макрос BOOST_AUTO_TEST_CASE, содержащий имя и код теста. Код теста содержит специальные макросы, проверяющие соответствие фактических результатов работы функции ожидаемым. Макросы отличаются уровнем предупреждения (CHECK — ошибка, REQUIRE — критическая ошибка, WARN — предупреждение). Среди макросов есть следующие:

  • BOOST_CHECK(условие) — сообщает об ошибке, если условие ложно;
  • BOOST_REQUIRE_EQUAL(аргумент_1, аргумент_2) — сообщает о критической ошибке, если аргумент_1 не равен аргумент_2;
  • BOOST_WARN_MESSAGE(условие, сообщение) — выводит предупреждение с текстом сообщения, если условие ложно;
  • BOOST_CHECK_NO_THROW(выражение) — сообщает об ошибке, если при вычислении выражения вырабатывается исключение;
  • BOOST_CHECK_THROW(выражение, исключение) — сообщает об ошибке, если при вычислении выражения не вырабатывается исключение требуемого типа;
  • BOOST_CHECK_CLOSE_FRACTION(аргумент_1, аргумент_2, погрешность) — проваливает тест если аргумент_1 не равен аргумент_2 с заданной погрешностью.

Более полный список доступных макросов можно найти в официальной документации [2].

4 Простой пример использования boost test framework

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

#define BOOST_TEST_MAIN
#define BOOST_TEST_DYN_LINK
#include <boost/test/unit_test.hpp>

namespace compare_nsp {
  inline bool is_close(const float a, const float b, const float epsilon = 0.00001) {
    throw "notImplemented";
  }
}

#include "../../utils/fuzzycompare.h"
#include <boost/test/unit_test.hpp>

BOOST_AUTO_TEST_SUITE(TestFuzzyCompare)

BOOST_AUTO_TEST_CASE(Equal) {
  const float
      d = 0.0001, dd = 0.00001,
      a = 3, b = a + d - dd;
  BOOST_REQUIRE(compare_nsp::is_close(a, b, d));
}

BOOST_AUTO_TEST_CASE(NotEqual) {
  const float
      d = 0.0001, dd = 0.00001,
      a = 3, b = a + d + dd;
  BOOST_REQUIRE_EQUAL(compare_nsp::is_close(a, b, d), false);
}

BOOST_AUTO_TEST_SUITE_END()

Внутри набора с именем TestFuzzyCompare описаны два тестовых случая. В первом — аргументы функции эквивалентны с заданной погрешностью, поэтому в качестве результата ожидается true и используется макрос BOOST_REQUIRE. Во втором случае — числа не равны, используется макрос BOOST_REQUIRE_EQUAL(выражение, false).

Запуск проекта с опцией —log_level=test_suite выведет информацию о порядке и результатах прохождения всех описанных нами тестов. Обычно порядок прохождения тестов не интересен, поэтому дальше мы будем обходиться без этой опции.

Running 2 test cases…
Entering test suite «Master Test Suite»
Entering test suite «TestFuzzyCompare»
Entering test case «Equal»
unknown location(0): fatal error in «Equal»: C string: notImplemented
../../../boost-test/tests/utils/test_fuzzycompare.cpp(10): last checkpoint
Leaving test case «Equal»
Entering test case «NotEqual»
unknown location(0): fatal error in «NotEqual»: C string: notImplemented
../../../boost-test/tests/utils/test_fuzzycompare.cpp(17): last checkpoint
Leaving test case «NotEqual»
Leaving test suite «TestFuzzyCompare»
Leaving test suite «Master Test Suite»

*** 2 failures detected in test suite «Master Test Suite»

Мы убедились, что тесты запускаются и можем приступить к реализации нашей функции:

#include <cmath>

namespace compare_nsp {
  inline bool is_close(const float a, const float b, const float epsilon = 0.00001) {
    return std::fabs(a - b) < epsilon;
  }
}

Теперь программа пройдет все тесты без ошибок.

Running 2 test cases…

*** No errors detected

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

5 Пример unit-тестирования

Предположим, что перед нами поставлена задача — разработать программу, находящую действительные корни квадратных уравнений. Заказчик сообщил нам, что уравнения должны задаваться тремя дробными числами \(a, b, c : a*x^2 + b*x + c = 0\).

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

\(
D = b^2 — 4 \cdot a \cdot c,\\
\begin{equation*}
x_{1,2} =
\begin{cases}
\frac {-b \pm \sqrt{D}}{2 \cdot a} \quad &\text{$D > 0$} \\
\frac {-b + \sqrt{D}}{2 \cdot a} \quad &\text{$D = 0$} \\
\emptyset \quad &\text{$D < 0$}
\end{cases}
\end{equation*}\)

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

BOOST_AUTO_TEST_CASE(solve_quadratics_Two_Root) {
  std::vector<double> solution;
  const unsigned int nSolution = 2;
  const double good_solution[nSolution] = { -0.5, -3 };

  BOOST_CHECK_NO_THROW(solution = solve_quadratics(2, 7, 3));

  BOOST_REQUIRE(solution.size() == nSolution);

  BOOST_REQUIRE_CLOSE(solution[0], good_solution[0], Accuracy);
  BOOST_REQUIRE_CLOSE(solution[1], good_solution[1], Accuracy);
}

BOOST_AUTO_TEST_CASE(solve_quadratics_Single_root) {
  std::vector<double> solution;
  const unsigned int nSolution = 1;
  const double good_solution = -1.;

  BOOST_CHECK_NO_THROW(solution = solve_quadratics(4, 8, 4));

  BOOST_REQUIRE(solution.size() == nSolution);

  BOOST_REQUIRE_CLOSE(solution[0], good_solution, Accuracy);
}

BOOST_AUTO_TEST_CASE(solve_quadratics_No_root) {
  const unsigned int nSolution = 0;
  std::vector<double> solution;

  BOOST_CHECK_NO_THROW(solution = solve_quadratics(1, 2, 3));
  BOOST_REQUIRE(solution.size() == nSolution);
}

  double get_discriminant(const double a, const double b, const double c) {
    return b*b - 4*a*c;
  }

  std::vector<double> solve_quadratics(const double a, const double b, const double c) {
    double discriminant = get_discriminant(a, b, c);
    if (discriminant < 0)
      return std::vector<double>();

    double root_1 = (-b + std::sqrt(discriminant)) / (2*a),
           root_2 = (-b - std::sqrt(discriminant)) / (2*a);

    if (compare_nsp::is_close(discriminant, 0))
      return std::vector<double>({root_1});
    return std::vector<double>({root_1, root_2});
  }

Мы отмечали правило, заключающееся в том, что на один тест должна приходиться одна проверка. Под проверкой имеется ввиду случай, описанный заказчиком, а не проверка условия. Значит наши тесты не нарушают правило, хотя и содержат по несколько проверяющих макросов.

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

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

\(
\begin{equation*}
x_{1,2} =
\begin{cases}
\frac {-c}{b} \quad &\text{$a = 0, b \ne 0$} \\
NoEquationException \quad &\text{$a = 0, b = 0$}
\end{cases}
\end{equation*}\)

При параметре a равном нулю уравнение становится линейным и имеет лишь один корень. Если же и a и b равны нулю — программа может как не иметь корней, так и иметь их бесконечно много. Пусть наш код вырабатывает исключение в этом случае.

BOOST_AUTO_TEST_CASE(solve_quadratics_Linear_equation) {
  std::vector<double> solution;
  const unsigned int nSolution = 1;
  const double good_solution = -2./3.;

  BOOST_CHECK_NO_THROW(solution = solve_quadratics(0, 3, 2));

  BOOST_REQUIRE_EQUAL(solution.size(), nSolution);
  BOOST_REQUIRE_CLOSE(solution[0], good_solution, Accuracy);
}

BOOST_AUTO_TEST_CASE(solve_quadratics_linear_NoEquation) {
  BOOST_REQUIRE_THROW(solve_quadratics(0, 0, 5), NoEquationException);
}

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

class NoEquationException : public std::exception {
  public:
    const char* what() const throw ();
  };

  std::vector<double> solve_quadratics(const double a, const double b, const double c);
  //!< возвращает список корней уравнения a*(x^2) + b*x + c = 0

  double solve_linear(const double b, const double c);
  //!< возвращает корень уравнения b*x + c = 0;

  double get_discriminant(const double a, const double b, const double c);
  //!< вычисление дискриминанта уравнения a*(x^2) + b*x + c = 0
}

  const char* NoEquationException::what() const throw () {
    return "argument is not an equation";
  }

  double get_discriminant(const double a, const double b, const double c) {
    return b*b - 4*a*c;
  }

  double solve_linear(const double b, const double c) {
    if (compare_nsp::is_close(b, 0))
      throw NoEquationException();
    return -c / b;
  }

  std::vector<double> solve_quadratics(const double a, const double b, const double c) {
    if (compare_nsp::is_close(a, 0))
      return std::vector<double>({solve_linear(b, c)});

    double discriminant = get_discriminant(a, b, c);
    if (discriminant < 0)
      return std::vector<double>();

    double root_1 = (-b + std::sqrt(discriminant)) / (2*a),
           root_2 = (-b - std::sqrt(discriminant)) / (2*a);

    if (compare_nsp::is_close(discriminant, 0))
      return std::vector<double>({root_1});
    return std::vector<double>({root_1, root_2});
  }

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

Исходый код проекта

Литература по теме статьи:

  1. Васильев В.С. Паттерн «Адаптер» [Электронный ресурс] – режим доступа: https://pro-prof.com/archives/1372. Дата обращения: 24.11.2014.
  2. Документация Boost Test Framework [Электронный ресурс] – режим доступа: http://www.boost.org/doc/libs/1_36_0/libs/test/doc/html/utf/testing-tools/reference.html. Дата обращения: 24.11.2014.

5 thoughts on “Юнит-тестирование. Пример. Boost Unit Test

  1. reef213

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

    Желательно побольше теории на эту тему.

    1. admin Post author

      Про документирование в статье сказано.
      Сложные системы создаются не сразу, т.е. если вы не будете писать тесты пока ваша система «не достаточно сложная», то потом проект может загнуться.

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

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

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

      Он описывает, что успешно проект был завершен лишь потому, что они постоянно общались с заказчиком — в простой задаче есть куча подводных камней. Например, как изменяется стоимость, если человек оплатил стоянку за 2 часа, а пришел через 3? По-хорошему все такие случаи должны быть описаны в техническом задании, но владельцы стоянки не составят такой документ сами. Постепенно в результате встреч и диалога формируется ТЗ, но вместо того, чтобы описывать это на бумаге — можно сразу описать тестовые случаи. Это ведь так просто и полезно.

  2. reef213

    Перейдем к разбору примера, вопрос что это за структура?Что должно за чем следовать?

    -make_all.pro
    -solve_quadratics
    —main.cpp
    —solve_equation.cpp
    —solve_equation.h
    —solve_quadratics.pro
    -tests
    —solve_quadratics
    —main.cpp
    —test_solve_equation.cpp
    —test_solve_quadratics.pro
    —utils
    —test_fuzzycompare.cpp
    -utils
    -fuzzycompare.h

    Если есть make_all.pro, то должно компилироваться все и сразу, если — то где README хотя бы.

    1. admin Post author

      В примере тесты вынесены в отдельный проект (и это самое правильное и распространенное решение) — про это написано во второй части статьи.

      В самом начале третьего раздела написано, что тестовый проект, как и любая программа должна иметь точку входа. В основном проекте тоже есть точка входа, куда без нее? — соответственно нельзя просто так взять и «скомпилировать все сразу». Никто не заставляет использовать make_all, даже использовать qmake совсем не обязательно.

      Странный вопрос вообще задаете. Очевидно, вы привели структуру проекта. Что в ней не так?
      Очевидно есть 2 проекта: первый размещается в подкаталоге tests, второй — в solve_quadratics. В каталогах лежат файлы проекта — можно запустить их по отдельности.

      Подкаталог utils содержит функцию нечеткого сравнения чисел. Можно было бы поместить эту функцию внутрь solve_quadratics — это бы работало. Это было бы не совсем правильно, ведь нечеткое сравнение может быть нужно и другим модулям, но оно никогда не будет зависеть от других модулей. Такие общие и независимые функции всегда выносятся в отдельный проект (и могут даже тестироваться отдельно).

      Вместо README тут целая статья. Задавайте конкретный вопрос — я дам конкретный ответ. Пока что я не не понятно что вас смущает в структуре проекта.

      1. reef213 Post author

        Скомпилировал пример, не получалось потому, что:

        • я передавал компоновщику не те библиотеки;
        • Qt creator неправильно обрабатывал параметры.

        Проблемы решил:

        • В .pro файл добавил библиотеки через мастер — пункт «add library»;
        • удалил $$PWD/;
        • удалил строки с макросом PRE_TARGETDEPS, т.к. они предназначены для MSVS, а я использую MinGW.

Добавить комментарий