>
>
>
Как статический анализ дополняет TDD

Андрей Карпов
Статей: 674

Как статический анализ дополняет TDD

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

TDD это замечательно

Разработка через тестирование (test-driven development, TDD) — техника разработки программного обеспечения, которая основывается на повторении очень коротких циклов разработки. Сначала пишется тест, покрывающий желаемое изменение, затем пишется код, который позволит пройти тест, и под конец проводится рефакторинг нового кода к соответствующим стандартам. Подробнее останавливать на том, что такое TDD я не буду. На эту тематику имеется большое количество статей, которые легко найти в интернете.

При использовании TDD мне кажется, очень важным не увлечься бесчисленным количеством тестов. Очень легко создавать видимость бурной деятельности и писать огромное количество строк кода в день. Но при этом функциональность продукта будет наращивать очень медленно. Можно почти все усилия и время тратить на код тестов. Вдобавок тесты иногда трудоёмко поддерживать при изменении функциональности.

Именно по этому, при разработке PVS-Studio мы не используем TDD в чистом виде. Если писать тесты для отдельных функций, то время разработки увеличится в десятки раз. Дело в следующем. Чтобы вызвать функцию раскрывающую тип в typedef или выполнить какой-то анализ кода, необходимо подготовить очень много входных данных. Нужно построить в памяти корректный фрагмент дерева разбора и заполнить множество структур. Это отнимет слишком много времени.

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

int A() {
  int x;
  return x; //Err
}

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

int B() {
  static int x;
  return x; //Ok
}

Здесь всё хорошо, так как переменная является статической.

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

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

TDD это замечательно, но не надо терять голову

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

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

Итак, вот две типовые проблемы при создании тестов:

1) Сами тесты никто не тестирует.

2) Тесты не тестируют редкие критические ситуации.

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

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

Например, запуская PVS-Studio для проверки очередного проекта, я очень часто встречаю ошибки в коде тестов. Приведу пример, взятый из проекта Chromium.

TEST(SharedMemoryTest, MultipleThreads) {
  ....
  int threadcounts[] = { 1, kNumThreads };
  for (size_t i = 0;
       i < sizeof(threadcounts) / sizeof(threadcounts); i++) {
  ....
}

Какой-то тест должен запускаться в одном потоке, а потом в нескольких. Из-за опечатки, не тестируется параллельная работа алгоритма. Ошибка вот здесь: sizeof(threadcounts) / sizeof(threadcounts).

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

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

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

bool Test()
{
  char *buf = new char[10];
  FooFoo(buf);
  bool ok = memcmp(buf, "1234567890", sizeof(buf)) == 0;
  delete [] buf;
  return ok;
}

Такой тест работает "наполовину". Он сравнивает только первые 4 или 8 байт. Количество сравниваемых байт зависит от размера указателя. Такой тест может казаться очень хорошим и работающим, но на самом деле доверять ему нельзя.

Другое слабое место TDD это отсутствие тестов для критических ситуаций. Конечно, такие тесты можно сделать. Но это неоправданно трудоемко. Например, сделать так, чтобы malloc() в нужный момент вернул NULL, требует много сил. А пользы от этого весьма мало. В программе вероятность такого события может быть меньше 0.0001%. Приходится находить компромисс между полнотой тестов и трудоёмкостью их создания.

Поиграем с числами. Пусть в программе функция malloc() используется 1000 раз. Пусть вероятность нехватки памяти при вызове каждой из них равна 0.0001%. Посчитаем, чему равна вероятность, что при работе программы возникнет ошибка выделения памяти:

(1 - 0.999999^1000) * 100% = 0.09995%

Приблизительно вероятность нехватки памяти составляет 0.1%. Не экономно писать 1000 тестов для тестирования таких ситуаций. Однако, 0.1% это не так уж и мало. У кого-то из пользователей точно будут возникать такие ситуации. Как быть уверенным, что они будут корректно обработаны?

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

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

Другим помощником может стать статический анализатор кода. Этому инструменту всё равно, как часто выполняется та или иная ветвь программы. Он проверяет почти весь код. Слово "почти" написано из-за того, что в программах на Си/Си++ есть "#ifdef" и явно отключенные ветки с помощью "if(0)" про содержимое которых лучше молчать.

Вот пример недочёта, который может обнаружить статический анализ в обработчиках ошибок:

VTK_THREAD_RETURN_TYPE vtkTestCondVarThread( void* arg )
{
  ....
  if ( td )                  // <=
  {
    ....
  }
  else
  {
    cout << "No thread data!\n";
    cout << "  Thread " << ( threadId + 1 ) 
         << " of " << threadCount << " exiting.\n";

    -- td->NumberOfWorkers;  // <=

    cout.flush();
  }
  ...
}

В случае возникновения ошибки выводится сообщение и модифицируется переменная "td->NumberOfWorkers". Этого делать нельзя, так как указатель 'td' равен нулю.

Выводы

Резюмирую содержание статьи:

1. TDD – замечательная технология. Стоит уделить время знакомству с ней и начать использовать в своей работе. Если классический вариант TDD неудобен, не стоит сразу отказываться от этой методологии. Возможно, её удастся использовать, взглянув на неё под другим углом или с другого уровня абстракции.

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