Статья
Версия для печати
Обсудить на форуме
Модульное тестирование ПО встроенных систем в среде Unity
Часть 3

(C) Dale, 18.07.2011 — 20.07.2011.

Начало: Часть 1, ЧАсть 2.

Рефакторим тесты

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

Код: (C)
static void callTriangleAreaAndCheckException(double a, double b, double c)
{
    CEXCEPTION_T e;
    Try
    {
        triangleArea(a, b, c);
        TEST_FAIL_MESSAGE("No exception was thrown");
    }
    Catch(e)
    {
        TEST_ASSERT_EQUAL_UINT((unsigned int)NEGATIVE_SIDE, (unsigned int)e);
    }
}

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

Код: (C)
void test_Side_1_isLessThan_0_Exception(void)
{
    callTriangleAreaAndCheckException(-3.0, 4.0, 5.0);
}

void test_Side_2_isLessThan_0_Exception(void)
{
    callTriangleAreaAndCheckException(3.0, -4.0, 5.0);
}

Убедимся, что они по-прежнему работоспособны, несмотря на эту простоту:

../../../TriangleArea/test/TestTriangleArea.c:16:test_TriangleArea_3_4_5_Equals_6:PASS
../../../TriangleArea/test/TestTriangleArea.c:42:test_Side_1_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:47:test_Side_2_isLessThan_0_Exception:PASS
-----------------------
3 Tests 0 Failures 0 Ignored
OK

Продолжаем в том же духе. Добавляем тест для третьей стороны…:

Код: (C)
void test_Side_3_isLessThan_0_Exception(void)
{
    callTriangleAreaAndCheckException(3.0, 4.0, -5.0);
}

../../../TriangleArea/test/TestTriangleArea.c:16:test_TriangleArea_3_4_5_Equals_6:PASS
../../../TriangleArea/test/TestTriangleArea.c:42:test_Side_1_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:47:test_Side_2_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:52:test_Side_3_isLessThan_0_Exception:PASS
-----------------------
4 Tests 0 Failures 0 Ignored
OK

…и три аналогичных теста для сторон с нулевой длиной:

Код: (C)
void test_Side_1_isEqual_0_Exception(void)
{
    callTriangleAreaAndCheckException(0.0, 4.0, 5.0);
}

void test_Side_2_isEqual_0_Exception(void)
{
    callTriangleAreaAndCheckException(3.0, 0.0, 5.0);
}

void test_Side_3_isEqual_0_Exception(void)
{
    callTriangleAreaAndCheckException(3.0, 4.0, 0.0);
}

../../../TriangleArea/test/TestTriangleArea.c:16:test_TriangleArea_3_4_5_Equals_6:PASS
../../../TriangleArea/test/TestTriangleArea.c:42:test_Side_1_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:47:test_Side_2_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:52:test_Side_3_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:57:test_Side_1_isEqual_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:62:test_Side_2_isEqual_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:67:test_Side_3_isEqual_0_Exception:PASS
-----------------------
7 Tests 0 Failures 0 Ignored
OK

Недопустимое сочетание сторон треугольника

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

Доработка вспомогательной функции

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

Код: (C)
static void callTriangleAreaAndCheckException(double a, double b, double c, CEXCEPTION_T ce)
{
    CEXCEPTION_T e;
    Try
    {
        triangleArea(a, b, c);
        TEST_FAIL_MESSAGE("No exception was thrown");
    }
    Catch(e)
    {
        TEST_ASSERT_EQUAL_UINT((unsigned int)ce, (unsigned int)e);
    }
}

Соответственно корректируем вызовы callTriangleAreaAndCheckException() в тестах, затем запускаем тестовый набор.

Первый тест недопустимого сочетания сторон

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

Код: (C)
void test_Side_1_isTooLong_Exception(void)
{
    callTriangleAreaAndCheckException(10.0, 1.0, 1.0, BAD_TRIANGLE);
}

../../../TriangleArea/test/TestTriangleArea.c:16:test_TriangleArea_3_4_5_Equals_6:PASS
../../../TriangleArea/test/TestTriangleArea.c:42:test_Side_1_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:47:test_Side_2_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:52:test_Side_3_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:57:test_Side_1_isEqual_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:62:test_Side_2_isEqual_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:67:test_Side_3_isEqual_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:72:test_Side_1_isTooLong_Exception:PASS
-----------------------
8 Tests 0 Failures 0 Ignored
OK

Проверяем остальные сочетания

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

Код: (C)
void test_Side_2_isTooLong_Exception(void)
{
    callTriangleAreaAndCheckException(1.0, 10.0, 1.0, BAD_TRIANGLE);
}

void test_Side_3_isTooLong_Exception(void)
{
    callTriangleAreaAndCheckException(1.0, 1.0, 10.0, BAD_TRIANGLE);
}

../../../TriangleArea/test/TestTriangleArea.c:16:test_TriangleArea_3_4_5_Equals_6:PASS
../../../TriangleArea/test/TestTriangleArea.c:42:test_Side_1_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:47:test_Side_2_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:52:test_Side_3_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:57:test_Side_1_isEqual_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:62:test_Side_2_isEqual_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:67:test_Side_3_isEqual_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:72:test_Side_1_isTooLong_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:77:test_Side_2_isTooLong_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:82:test_Side_3_isTooLong_Exception:PASS
-----------------------
10 Tests 0 Failures 0 Ignored
OK

Последний штрих

Мы уже проверили, что площадь корректно построенного треугольника считается правильно, а в случае некорректных исходных данных выбрасываются соответствующие исключения. В заключение проверим поведение программы на черте между корректными и некорректными данными, а именно — когда треугольник вырождается в линию. Если и в данном случае не будет сюрпризов, нашему модулю более-менее можно доверять.

Код: (C)
void test_TriangleArea_Height_is_0(void)
{
    // фаза 1 - подготовка
    double const expected = 0.0;
    // фаза 2 - выполнение
    double actual = triangleArea(1.0, 1.0, 2.0);
    // фаза 3 - оценка
    TEST_ASSERT_EQUAL_FLOAT(expected, actual);
    // фаза 4 - вырожденная
}

Полностью наш набор тестов теперь выглядит так:

Код: (C)
// TestTriangleArea.c

#include "unity.h"
#include "TriangleArea.h"
#include "CException.h"
#include "ErrorCode.h"

void setUp(void)
{
}

void tearDown(void)
{
}

void test_TriangleArea_3_4_5_Equals_6(void)
{
    // фаза 1 - подготовка
    double const expected = 6.0;
    // фаза 2 - выполнение
    double actual = triangleArea(3.0, 4.0, 5.0);
    // фаза 3 - оценка
    TEST_ASSERT_EQUAL_FLOAT(expected, actual);
    // фаза 4 - вырожденная
}

static void callTriangleAreaAndCheckException(double a, double b, double c, CEXCEPTION_T ce)
{
    CEXCEPTION_T e;
    Try
    {
        triangleArea(a, b, c);
        TEST_FAIL_MESSAGE("No exception was thrown");
    }
    Catch(e)
    {
        TEST_ASSERT_EQUAL_UINT((unsigned int)ce, (unsigned int)e);
    }
}

void test_Side_1_isLessThan_0_Exception(void)
{
    callTriangleAreaAndCheckException(-3.0, 4.0, 5.0, INVALID_SIDE);
}

void test_Side_2_isLessThan_0_Exception(void)
{
    callTriangleAreaAndCheckException(3.0, -4.0, 5.0, INVALID_SIDE);
}

void test_Side_3_isLessThan_0_Exception(void)
{
    callTriangleAreaAndCheckException(3.0, 4.0, -5.0, INVALID_SIDE);
}

void test_Side_1_isEqual_0_Exception(void)
{
    callTriangleAreaAndCheckException(0.0, 4.0, 5.0, INVALID_SIDE);
}

void test_Side_2_isEqual_0_Exception(void)
{
    callTriangleAreaAndCheckException(3.0, 0.0, 5.0, INVALID_SIDE);
}

void test_Side_3_isEqual_0_Exception(void)
{
    callTriangleAreaAndCheckException(3.0, 4.0, 0.0, INVALID_SIDE);
}

void test_Side_1_isTooLong_Exception(void)
{
    callTriangleAreaAndCheckException(10.0, 1.0, 1.0, BAD_TRIANGLE);
}

void test_Side_2_isTooLong_Exception(void)
{
    callTriangleAreaAndCheckException(1.0, 10.0, 1.0, BAD_TRIANGLE);
}

void test_Side_3_isTooLong_Exception(void)
{
    callTriangleAreaAndCheckException(1.0, 1.0, 10.0, BAD_TRIANGLE);
}

void test_TriangleArea_Height_is_0(void)
{
    // фаза 1 - подготовка
    double const expected = 0.0;
    // фаза 2 - выполнение
    double actual = triangleArea(1.0, 1.0, 2.0);
    // фаза 3 - оценка
    TEST_ASSERT_EQUAL_FLOAT(expected, actual);
    // фаза 4 - вырожденная
}

А вот результат его выполнения:

../../../TriangleArea/test/TestTriangleArea.c:16:test_TriangleArea_3_4_5_Equals_6:PASS
../../../TriangleArea/test/TestTriangleArea.c:42:test_Side_1_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:47:test_Side_2_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:52:test_Side_3_isLessThan_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:57:test_Side_1_isEqual_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:62:test_Side_2_isEqual_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:67:test_Side_3_isEqual_0_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:72:test_Side_1_isTooLong_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:77:test_Side_2_isTooLong_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:82:test_Side_3_isTooLong_Exception:PASS
../../../TriangleArea/test/TestTriangleArea.c:87:test_TriangleArea_Height_is_0:PASS
-----------------------
11 Tests 0 Failures 0 Ignored
OK

Выводы

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

Мифы и предрассудки, связанные с тестированием

Как уже упоминалось, среди различных процессов разработки ПО тестирование ПО часто находится на голодном пайке.
Прежде всего, литературы по тестированию существенно меньше, чем, скажем, по языкам программирования. Не то чтобы ее не было вовсе, нет; но все же зайдите в любой книжный магазин и попытайтесь найти что-нибудь по тестированию. Улов вряд ли окажется богатым.
В образовательных программах тема тестирования, как правило, также обойдена вниманием. Возможно, в единичных продвинутых учебных заведениях и учат этой премудрости, но это скорее исключение из общей малоутешительной картины.
Естественное следствие: специалистов по тестированию гораздо меньше, чем в других областях программной инженерии. Отнюдь не редкость целые организации, в штате которых нет ни одного человека, знакомого с предметом. Однако публично сознаваться в отсутствии навыков тестирования не принято, поскольку демонстративно игнорировать тему качества в целом и тестирования как его важнейшей составляющей в частности считается неприличным; напротив, на словах обычно все поголовно оказываются горячими борцами за качество.
Как известно, природа не терпит пустоты, и там, где нет реальных опыта и знаний, вакуум заполняют домыслы и предрассудки. Многие из них встречались в моей личной практике, с другими довелось познакомиться из литературы. Некоторые из таких предрассудков считаю целесообразным упомянуть здесь.
  • Мои программы в тестировании не нуждаются, поскольку моя квалификация настолько высока, что позволяет мне обходиться вообще без ошибок.
    Эта болезнь характерна для студентов младших курсов, которым наконец-то удалось освоить какой-либо из компьютерных языков до степени, которая позволяет достаточно быстро исправить ошибки компиляции. Логично, ведь если бы в программе были ошибки, уж компилятор-то нашел бы их непременно!
    Как и большинство детских болезней, эта обычно проходит сама, с приходом опыта. В особо запущенных случаях остается с пациентом на всю жизнь; в этом случае единственно действенное средство – карантин: по возможности изолируйте больного от своих проектов, они (проекты) от этого только выиграют.
  • Тестирование – это чересчур трудоемко, я не могу позволить себе такие расходы.
    На самом деле, как мы только что видели в статье, тесты – это весьма короткие и простые функции с простой линейной логикой, их написание потребует не так уж много времени; грамотное использование специальных инструментов дополнительно снижает трудоемкость. Если же вы затрудняетесь написать для какой-то функции тест, это может быть явным признаком того, что вы вообще с этой функцией еще толком не разобрались и кодировать ее рановато.
  • Тестирование – это очень просто. Я играючи справляюсь с ним без всяких специальных инструментов и уж тем более без теорий, которыми вы морочите нам головы.
    На деле очень часто оказывается, что «тестировщики от сохи» попросту путают тестирование с пошаговой отладкой, в которой они действительно поднаторели (не от хорошей жизни, разумеется).
  • Тестовая среда должна автоматически разбираться в спецификациях и исходниках, при необходимости производить реинжиниринг тестируемой системы, определять логику ее работы и проверять ее правильность. Вы подаете на вход системы тестирования глючные программы, а она указывает вам, где именно закрались ошибки.
    Звучит, конечно же, анекдотично, но мне реально доводилось встречать такую точку зрения. Конечно же, такие наивные представления бесконечно далеки от реальности. В действительности у тестовой системы куда более простая задача: сравнивать действительное поведение программы с желаемым, зафиксированным в спецификациях. Соответственно, если нет спецификаций (фактически это означает, что вы сами толком не знаете, что за программу написали и какой она должна была быть на самом деле), не может быть и полноценного тестирования.

Ответы на часто возникающие вопросы по поводу тестирования

  • Это вообще кому-нибудь реально нужно?
    Надеюсь, после прочтения данной статьи этот вопрос перестанет мучить читателя.
  • Как тестировать?
    Примерно в том же духе, как было продемонстрировано в статье: писать тесты в выбранной вами тестовой инфраструктуре, выполнять их и оценивать результат.
  • Что именно следует тестировать?
    Все, что поддается тестированию: устройство (систему в целом), ее ПО, связь между подсистемами, каждую подсистему и модуль по отдельности. Для каждого из этих видов тестирования существуют методики и инструментальные средства.
  • Когда начинать тестирование?
    Как можно раньше. Чем раньше начнете, тем добротнее будет результат при прочих равных условиях. Есть технологии управляемой тестированием разработки (Test Driven Design, TDD), в которых модульные тесты пишутся даже раньше, чем код. Непривычному глазу такая последовательность может показаться странной, но в ней кроется глубокий смысл.
  • Как часто тестировать?
    Ответ в духе предыдущего: как можно чаще. Задача набора тестов – обнаружить имеющиеся в программе ошибки; и чем раньше ошибка будет обнаружена, тем дешевле и практичнее обойдется ее исправление.
    Конечно, частое тестирование целесообразно проводить лишь в том случае, если каждая тестовая сессия происходит достаточно быстро, не задерживая весь процесс. А это в свою очередь возможно лишь в том случае, когда каждый тест короткий и быстрый. Кроме того,  может оказаться, что какая-то функциональность завязана на внешние подсистемы, которые работают довольно медленно и тормозят процесс  тестирования (например, WWW или SQL-серверы). Для тестирования подобных систем  могут потребоваться так называемые «тестовые дублеры», которые заменяют реальные модули и при этом обладают гораздо более высоким быстродействием.
    Если выполнение набора тестов занимает несколько секунд, этот набор можно позволить себе выполнять так часто, как это требуется, например, при каждом внесении изменений в программу. Такое тестирование называется регрессионным: программа максимально часто тестируется в полном объеме, при этом мы проверяем работу не только измененных модулей, но и остальных, на которых изменение может повлиять косвенно. О пользе как можно более раннего обнаружения ошибок речь уже шла выше.
  • Что проверять при модульном тестировании?
    Модуль как «черный ящик». Для проверки модуля используется его интерфейс: через него осуществляется подача тестовых воздействий, и с него же снимается результат. Если модуль трудно тестировать, это часто является явным признаком слабой проработки его интерфейса.
  • Чем руководствоваться при написании модульных тестов?
    Прежде всего – спецификациями тестируемого модуля. Именно несоответствия реальной функциональности модуля его спецификациям являются основным проявлением ошибки. На каждый пункт спецификаций должен быть соответствующий набор тестов для его проверки. В процессе написания этих тестов часто обнаруживаются ошибки и недочеты в спецификациях, что дает еще один плюс в пользу как можно более раннего начала тестирования.
  • Сколько тестов писать? Ведь число возможных сочетаний параметров для нетривиального модуля астрономически велико, и проверить их попросту нереально.
    Не нужно проверять все сочетания. Достаточно проанализировать природу данных и выделить основные диапазоны, внутри которых модуль ведет себя единообразно.
    Простой пример: модуль отвечает за отображение точки в экранной области. На оси абсцисс мы можем выделить следующие диапазоны:
    • Область левее левой границы рамки окна.
    • Левая граница рамки окна.
    • Экранная область.
    • Правая граница рамки окна.
    • Область правее правой границы рамки окна.
    Для проверки корректной обработки абсциссы точки нам достаточно написать 5 тестов, по одному на каждую из областей.
    Кроме этого, есть еще одно очень важное правило. Если, несмотря на все ваши титанические усилия по тестированию, незамеченная ошибка все же прокралась в конечный продукт, обязательно добавьте тест для ее диагностики в тестовый набор модуля еще до того, как ошибка будет исправлена. Это страховка от повторения этой же ошибки в будущем.
  • Почему по ходу тестирования нам пришлось выполнять ручные операции (в частности, после каждого добавления нового теста мы были вынуждены запускать скрипт generate_test_runner.rb? Ведь нам обещали автоматизированное тестирование.
    Исключительно в познавательных целях, чтобы, так сказать, лучше прочувствовать происходящее «под капотом».
    На самом деле для запуска generate_test_runner.rb создается соответствующая цель в make-файле проекта, и скрипт запускается автоматически каждый раз, как изменится исходный файл набора тестов.
  • Что можно почитать для более глубокого ознакомления с предметом?
    • Прежде всего – классический труд Джерарда Мессароша «Шаблоны тестирования xUnit».
    • Тесты не менее, чем код (а может, даже более) нуждаются в постоянном рефакторинге. Следовательно – Мартин Фаулер, «Рефакторинг. Улучшение существующего кода».
    • Превосходная книга James W. Grenning «Test Driven Development for Embedded C» заставит вас по-новому посмотреть на программирование микропроцессоров.
    • Еще одна замечательная (хоть и небольшая по объему) Mark VanderVoord «Embedded Testing with Unity and CMock».
    • И не забывайте регулярно наведываться в мой тематический обзор книг. Он регулярно пополняется новыми книгами и статьями, и надеюсь, будет пополняться и далее.


Тестовый проект находится здесь: TestedTriangle.zip.

Версия для печати
Обсудить на форуме