Программирование — страница 46 из 57

Тестирование

“Я только проверил корректность кода, но не

тестировал его”.

Дональд Кнут (Donald Knuth)


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

26.1. Чего мы хотим

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

• Если средний элемент равен искомому, мы заканчиваем поиск.

• Если средний элемент меньше искомого, проводим бинарный поиск в правой части.

• Если средний элемент больше искомого, проводим бинарный поиск в левой части.

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


Используйте в качестве критерия сравнения (сортировки) оператор “меньше” (

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

Итак, вы написали функцию для бинарного поиска. Если нет, то вернитесь к предыдущему абзацу. Почему вы уверены, что ваша функция поиска корректна? Изложите свои аргументы, обосновывающие корректность программы.

 Вы уверены в своих аргументах? Нет ли слабых мест в вашей аргументации? Это была тривиальная программа, реализующая очень простой и хорошо известный алгоритм. Исходный текст вашего компилятора занимает около 200 Кбайт памяти, исходный текст вашей операционной системы — от 10 до 50 Мбайт, а код, обеспечивающий безопасность полета самолета, на котором вы отправитесь отдыхать во время ваших следующих каникул или на конференцию, составляет от 500 Кбайт до 2 Мбайт. Это вас утешает? Как применить методы, которые вы использовали для проверки функции бинарного поиска, к реальному программному обеспечению, имеющему гораздо большие размеры.

Любопытно, что, несмотря на всю сложность, большую часть времени большая часть программного обеспечения работает правильно. К этому числу критически важных требований программы мы не относим игровые программы на персональных компьютерах. Следует подчеркнуть, что программное обеспечение с особыми требованиями к безопасности практически всегда работает корректно. Мы не будем упоминать в этой связи программное обеспечение бортовых компьютеров авиалайнеров или автомобилей из-за того, что за последнее десятилетие были зарегистрированы сбои в их работе. Рассказы о банковском программном обеспечении, вышедшем из строя из-за чека на 0,00 доллара, в настоящее время устарели; такие вещи больше не происходят. И все же программное обеспечение пишут такие же люди, как вы. Вы знаете, что делаете ошибки; но если мы можем делать ошибки, то почему следует думать, что “они” их не делают?

 Чаще всего мы считаем, что знаем, как создать надежную систему из ненадежных частей. Мы тяжело работаем над каждой программой, каждым классом и каждой функцией, но, как правило, терпим неудачу при первом же испытании. Затем мы отлаживаем, тестируем и заново проектируем программу, устраняя в ней как можно больше ошибок. Однако в любой нетривиальной системе остается несколько скрытых ошибок. Мы знаем о них, но не можем найти или (реже) не можем найти их вовремя. После этого мы заново проектируем систему, чтобы выявить неожиданные и “невозможные” события. В результате может получиться система, которая выглядит надежно. Отметим, что такая система может по-прежнему скрывать ошибки (как правило, так и бывает) и работать меньше, чем ожидалось. Тем не менее она не выходит из строя окончательно и выполняет минимально возможные функции. Например, при исключительно большом количестве звонков телефонная система может не справляться с правильной обработкой каждого звонка, но никогда не отказывает окончательно.

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

26.1.1. Предостережение

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

26.2. Доказательства

 Постойте! Почему бы просто не доказать, что наши программы корректны, и не возиться с тестами? Как лаконично указал Эдсгер Дейкстра (Edsger Dijkstra): “Тестирование может выявить наличие ошибок, а не их отсутствие”. Это приводит к очевидному желанию доказать корректность программ так, как математики доказывают теоремы.

 К сожалению, доказательство корректности нетривиальных программ выходит за пределы современных возможностей (за исключением некоторых очень ограниченных прикладных областей), само доказательство может содержать ошибки (как и математические теоремы), и вся теория и практика доказательства корректности программ являются весьма сложными. Итак, поскольку мы можем структурировать свои программы, то можем раздумывать о них и убеждаться, что они работают правильно. Однако мы также тестируем программы (раздел 26.3) и пытаемся организовать код так, чтобы он был устойчив к оставшимся ошибкам (раздел 26.4).

26.3. Тестирование

В разделе 5.11 мы назвали тестирование систематическим поиском ошибок. Рассмотрим методы такого поиска.

 Различают тестирование модулей (unit testing) и тестирование систем (system testing). Модулем называется функция или класс, являющиеся частью полной программы. Если мы тестируем такие модули по отдельности, то знаем, где искать проблемы в случае обнаружения ошибок; все ошибки, которые мы можем обнаружить, находятся в проверяемом модуле (или в коде, который мы используем для проведения тестирования). Это контрастирует с тестированием систем, в ходе которого тестируется полная система, и мы знаем, что ошибка находится “где-то в системе”. Как правило, ошибки, найденные при тестировании систем, — при условии, что мы хорошо протестировали отдельные модули, — связаны с нежелательными взаимодействиями модулей. Ошибки в системе часто найти труднее, чем в модуле, причем на это затрачивается больше сил и времени.

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

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

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

 Говоря, что кто-то (может быть, вы сами) может изменить код после того, как вы его протестируете, приводит нас к идее регрессивного тестирования. По существу, как только вы внесли изменение, сразу же повторите тестирование, чтобы убедиться, что вы ничего не разрушили. Итак, если вы улучшили модуль, то должны повторить его тестирование и, перед тем как передать законченную систему кому-то еще (или перед тем, как использовать ее самому), должны выполнить тестирование полной системы. Выполнение такого полного тестирования системы часто называют регрессивным тестированием (regression testing), поскольку оно подразумевает выполнение тестов, которые ранее уже выявили ошибки, чтобы убедиться, что они не возникли вновь. Если они возникли вновь, то программа регрессировала и ошибки следует устранить снова.

26.3.1. Регрессивные тесты

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

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

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

26.3.2. Модульные тесты

Однако достаточно слов! Рассмотрим конкретный пример: протестируем программу для бинарного поиска. Ее спецификация из стандарта ISO приведена ниже (раздел 25.3.3.4).


template

  bool binary_search(ForwardIterator first,

    ForwardIterator last,const T& value);


 template

   bool binary_search(ForwardIterator first,

     ForwardIterator last,const T& value,Compare comp);


Требует. Элементы

e
из диапазона
[first, last]
разделены в соответствии с отношением
e
и
!(value
или
comp(e,value)
и
!comp(value,e)
. Кроме того, для всех элементов
e
диапазона
[first,last]
из условия
e
следует
!(value
, а из условия
comp(e,value)
следует
!comp(value,e)
.

Возвращает. Значение

true
, если в диапазоне
[first,last]
существует итератор
i
, удовлетворяющий условиям:
!(*I
или
comp(*i,value)==false&&comp(value,*i)==false
.

Сложность. Не более

log(last–first)+2
сравнения.

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

true
, если оно лежит в диапазоне, определенном указанными итераторами. Эти итераторы должны задавать упорядоченную последовательность. Критерием сравнения (упорядочения) является оператор
<
. Вторую версию функции
binary_search
, в которой критерий сравнения задается как дополнительный аргумент, мы оставляем читателям в качестве упражнения.

Здесь мы столкнемся только с ошибками, которые не перехватывает компилятор, поэтому примеры, подобные этому, для кого-то станут проблемой.


binary_search(1,4,5);  // ошибка: int — это не однонаправленный

                       // итератор

vector v(10);

binary_search(v.begin(),v.end(),"7"); // ошибка: невозможно найти

                                      // строку

                                      // в векторе целых чисел

binary_search(v.begin(),v.end());     // ошибка: забыли значение


 Как систематически протестировать функцию

binary_search()
? Очевидно, мы не можем просто перебрать все аргументы, так как этими аргументами являются любые мыслимые последовательности значений любого возможного типа — количество таких тестов станет бесконечным! Итак, мы должны выбрать тесты и определить некие принципы этого выбора.

• Тест на возможные ошибки (находит большинство ошибок).

• Тест на опасные ошибки (находит ошибки, имеющие наихудшие возможные последствия).


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

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

Лучшие тестировщики не только методичные, но и изворотливые люди (в хорошем смысле, конечно).

26.3.2.1. Стратегия тестирования

 С чего мы начинаем испытание функции

binary_search
? Мы смотрим на ее требования, т.е. на предположения о ее входных данных. К сожалению для тестировщиков, в требованиях явно указано, что диапазон
[first,last]
должен быть упорядоченной последовательностью. Другими словами, именно вызывающий модуль должен это гарантировать, поэтому мы не имеем права испытывать функцию
binary_search
, подавая на ее вход неупорядоченную последовательность или диапазон
[first,last]
, в котором выполняется условие
last
. Обратите внимание на то, что в требованиях функции
binary_search
не указано, что она должна делать, если мы нарушим эти условия. В любом другом фрагменте стандарта говорится, что в этих случаях функция может генерировать исключение, но она не обязана это делать. И все же во время тестирования функции
binary_search
такие вещи следует твердо помнить, потому что, если вызывающий модуль нарушает требования функции, такой как
binary_search
, скорее всего, возникнут ошибки.

Для функции

binary_search
можно себе представить следующие виды ошибок.

• Функция ничего не возвращает (например, из-за бесконечного цикла).

• Сбой (например, неправильное разыменование, бесконечная рекурсия).

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

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


Кроме того, необходимо помнить о следующих возможностях для пользовательских ошибок.

• Последовательность не упорядочена (например,

{2,1,5,–7,2,10}
).

• Последовательность не корректна (например,

binary_search(&a[100],&a[50],77)
).


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

binary_search(p1,p2,v)
? Ошибки часто возникают в особых ситуациях. В частности, при анализе последовательностей (любого вида) мы всегда ищем их начало и конец. Кроме того, всегда следует проверять, не пуста ли последовательность. Рассмотрим несколько массивов целых чисел, которые упорядочены так, как требуется.


{ 1,2,3,5,8,13,21 }      // "обычная последовательность"

{ }

{ 1 }                    // только один элемент

{ 1,2,3,4 }              // четное количество элементов

{ 1,2,3,4,5 }            // нечетное количество элементов

{ 1, 1, 1, 1, 1, 1, 1 }  // все элементы равны друг другу

{ 0,1,1,1,1,1,1,1,1,1,1,1,1 }  // другой элемент в начале

{ 0,0,0,0,0,0,0,0,0,0,0,0,0,1 } // другой элемент в конце


Некоторые тестовые последовательности лучше генерировать программой.


vector v1; 
// очень длинная последовательность

 for (int i=0; i<100000000; ++i) v.push_back(i);


• Последовательности со случайным количеством элементов.

• Последовательности со случайными элементами (по-прежнему упорядоченные).


И все же этот тест не настолько систематический, насколько нам бы хотелось. Как-никак, мы просто выискали несколько последовательностей. Однако мы следовали некоторым правилам, которые часто полезны при работе с множествами значений; перечислим их.

• Пустое множество.

• Небольшие множества.

• Большие множества.

• Множества с экстремальным распределением.

• Множества, в конце которых происходит нечто интересное.

• Множества с дубликатами.

• Множества с четным и нечетным количеством элементов.

• Множества, сгенерированные с помощью случайных чисел.


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

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

В целом мы ищем следующие условия.

• Экстремальные ситуации (большие или маленькие последовательности, странные распределения входных данных и т.п.).

• Граничные условия (все, что происходит в окрестности границы).


Реальный смысл этих понятий зависит от конкретной тестируемой программы.

26.3.2.2. Схема простого теста

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

binary_search
.


int a[] = { 1,2,3,5,8,13,21 };

if (binary_search(a,a+sizeof(a)/sizeof(*a),1) == false) cout << " отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),5) == false) cout << " отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),8) == false) cout << " отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),21) == false) cout << " отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),–7) == true) cout << " отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),4) == true) cout << " отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),22) == true) cout << " отказ";


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


int a[] = { 1,2,3,5,8,13,21 };

if (binary_search(a,a+sizeof(a)/sizeof(*a),1) == false) cout << "1 отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),5) == false) cout << "2 отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),8) == false) cout << "3 отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),21) == false) cout << "4 отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),–7) == true) cout << "5 отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),4) == true) cout << "6 отказ";

if (binary_search(a,a+sizeof(a)/sizeof(*a),22) == true) cout << "7 отказ";


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

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

Какими недостатками обладают указанные тесты?

• Один и тот же код приходится писать несколько раз.

• Тесты пронумерованы вручную.

• Вывод минимальный (мало информативный).


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


{ 27 7 { 1 2 3 5 8 13 21} 0 }


Это тест под номером

27
. Он ищет число
7
в последовательности
{ 1,2,3,5,8,13,21 }
, ожидая, что результатом является
0
(т.е.
false
). Почему мы записали этот тест в файл, а не в текст программы? В данном случае мы вполне могли написать этот тест прямо в исходном коде, но большое количество данных в тексте программы может ее запутать. Кроме того, тесты часто генерируются другими программами. Как правило, тесты, сгенерированные программами, записываются в файлы. Кроме того, теперь мы можем написать тестовую программу, которую можно запускать с разными тестовыми файлами.


struct Test {

  string label;

  int val;

  vector seq;

  bool res;

};


istream& operator>>(istream& is, Test& t); // используется описанный

                                           // формат


int test_all(istream& is)

{

  int error_count = 0;

  Test t;

  while (is>>t) {

    bool r = binary_search( t.seq.begin(), t.seq.end(), t.val);

    if (r !=t.res) {

      cout << "отказ: тест " << t.label

<< "binary_search: "

<< t.seq.size() << "элементов, val==" << t.val

<< " –> " << t.res << '\n';

      ++error_count;

    }

  }

  return error_count;

}


int main()

{

  int errors = test_all(ifstream ("my_test.txt");

  cout << "Количество ошибок: " << errors << "\n";

}


Вот как выглядят некоторые тестовые данные.


{ 1.1 1 { 1 2 3 5 8 13 21 } 1 }

{ 1.2 5 { 1 2 3 5 8 13 21 } 1 }

{ 1.3 8 { 1 2 3 5 8 13 21 } 1 }

{ 1.4 21 { 1 2 3 5 8 13 21 } 1 }

{ 1.5 –7 { 1 2 3 5 8 13 21 } 0 }

{ 1.6 4 { 1 2 3 5 8 13 21 } 0 }

{ 1.7 22 { 1 2 3 5 8 13 21 } 0 }

{ 2 1 { } 0 }

{ 3.1 1 { 1 } 1 }

{ 3.2 0 { 1 } 0 }

{ 3.3 2 { 1 } 0 }


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

26.3.2.3. Случайные последовательности

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

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

cout
с помощью функции
randint()
из раздела 24.7 и заголовочного файла
std_lib.facilities.h
.


void make_test(const string& lab,int n,int base,int spread)

 // записывает описание теста с меткой lab в поток cout

 // генерирует последовательность из n элементов, начиная

 // с позиции base

 // среднее расстояние между элементами равномерно распределено

 // на отрезке [0, spread]

{

  cout << "{ " << lab << " " << n << " { ";

  vector v;

  int elem = base;

  for (int i = 0; i

    elem+= randint(spread);

    v.push_back(elem);

  }

  int val = base + randint(elem–base); // создаем искомое значение

  bool found = false;

  for (int i = 0; i

                              // найден ли элемент val

    if (v[i]==val) found = true;

    cout << v[i] << " ";

  }

  cout << "} " << found << " }\n";

}


Отметим, что для проверки, найден ли элемент

val
в случайной последовательности, мы не использовали функцию
binary_search
. Для того чтобы обеспечить корректность теста, мы не должны использовать функцию, которую проверяем.

На самом деле функция

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


int no_of_tests = randint(100); // создаем около 50 тестов

for (int i = 0; i

  string lab = "rand_test_";

  make_test(lab+to_string(i), // to_string из раздела 23.2

  randint(500),               // количество элементов

  0,                          // base

  randint(50));               // spread

}


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

Причина, по которой случайные числа не являются панацеей для тестирования функции

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

26.3.3. Алгоритмы и не алгоритмы

 В качестве примера мы рассмотрели функцию

binary_search()
. Свойства этого алгоритма приведены ниже

Имеет точно определенные требования к входным данным.

• У него есть точно определенные указания, что он может и чего не может делать с входными данными (в данном случае он не изменяет эти данные).

• Не связан с объектами, которые не относятся явно к его входным данным.

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


У алгоритма бинарного поиска есть очевидные и открыто сформулированные пред- и постусловия (см. раздел 5.10). Иначе говоря, этот алгоритм — просто мечта тестировщика. Часто нам не так сильно везет и приходится тестировать плохой код (как минимум), сопровождаемый небрежными комментариями на английском языке и парой диаграмм.

Погодите! А не впадаем ли мы в заблуждение? Как можно говорить о корректности и тестировании, если у нас нет точного описания, что именно должен делать сам код? Проблема заключается в том, что многое из того, что должно делать программное обеспечение, нелегко выразить с помощью точных математических терминов. Кроме того, во многих случаях, когда это теоретически возможно, программист не обладает достаточным объемом математических знаний, чтобы написать и протестировать такую программу. Поэтому мы должны расстаться с идеальными представлениями о совершенно точных спецификациях и смириться с реальностью, в которой существуют не зависящие от нас условия и спешка.

А теперь представим себе плохую функцию, которую нам требуется протестировать. Под плохой функцией мы понимаем следующее.

Входные данные. Требования к входным данным (явные или неявные) сформулированы не так четко, как нам хотелось бы.

Выходные данные. Результаты (явные или неявные) сформулированы не так четко, как нам хотелось бы.

Ресурсы. Условия использования ресурсов (время, память, файлы и пр.) сформулированы не так четко, как нам хотелось бы.


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

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

 Итак, что мы ищем? Наша задача как тестировщиков — искать ошибки. Где они обычно скрываются? Чем отличаются программы, которые чаще всего содержат ошибки?

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

• Управление ресурсами. Обратите внимание на управление памятью (операторы

new
и
delete
), использование файлов, блокировки и т.п.

• Поищите циклы. Проверьте условия выхода из них (как в функции

binary_search()
).

• Инструкции

if
и
switch
(которые часто называют инструкциями ветвления). Ищите ошибки в их логике.


Рассмотрим примеры, иллюстрирующие каждый из перечисленных пунктов.

26.3.3.1. Зависимости

Рассмотрим следующую бессмысленную функцию.


int do_dependent(int a,int& b) // плохая функция

                               // неорганизованные зависимости

{

  int val;

  cin>>val;

  vec[val] += 10;

  cout << a;

  b++;

  return b;

}


Для тестирования функции

do_dependent()
мы должны не просто синтезировать набор аргументов и посмотреть, что она с ними будет делать. Мы должны учесть, что эта функция использует глобальные переменные
cin
,
cout
и
vec
. Это обстоятельство вполне очевидно в данной небольшой и бессмысленной программе, но в более крупном коде оно может быть скрыто. К счастью, существует программное обеспечение, позволяющее находить такие зависимости. К несчастью, оно не всегда доступно и довольно редко используется. Допустим, у нас нет программного обеспечения для анализа кода и мы вынуждены строка за строкой просматривать функцию в поисках ее зависимостей.

Для того чтобы протестировать функцию

do_dependent()
, мы должны проанализировать ряд ее свойств.

• Входные данные функции

• Значение переменной

a
.

• Значения переменной

b
и переменной типа
int
, на которую ссылается переменная
b
.

• Ввод из потока

cin
(в переменную
val
) и состояние потока
cin
.

• Состояние потока

cout
.

• Значение переменной

vec
, в частности значение
vec[val]
.

• Выходные данные функции

• Возвращаемое значение.

• Значение переменной типа

int
, на которую ссылается переменная
b
(мы ее инкрементировали).

• Состояние объекта

cin
(проверьте состояния потока и формата).

• Состояние объекта

cout
(проверьте состояния потока и формата).

• Состояние массива

vec
(мы присвоили значение элементу
vec[val]
).

• Любые исключения, которые мог сгенерировать массив

vec
(ячейка
vec[val]
может находиться за пределами допустимого диапазона).


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

Как только мы идентифицировали входные и выходные данные, мы тут же оказываемся в ситуации, в которой уже побывали, тестируя

binary_search()
. Мы просто генерируем тесты с входными значениями (для явного и неявного ввода), чтобы увидеть, приводят ли они к желаемым результатам (явным и неявным). Тестируя функцию
do_dependent()
, мы могли бы начать с очень большого значения переменной
val
и отрицательного значения переменной
val
, чтобы увидеть, что произойдет. Было бы лучше, если бы массив
vec
оказался вектором, предусматривающим проверку диапазона (иначе мы можем очень просто сгенерировать действительно опасные ошибки). Конечно, мы могли бы поинтересоваться, что сказано об этом в документации, но плохие функции, подобные этой, редко сопровождаются полной и точной спецификацией, поэтому мы просто “сломаем” эту функцию (т.е. найдем ошибки) и начнем задавать вопросы о ее корректности. Часто такое сочетание тестирования и вопросов приводит к переделке функции.

26.3.3.2. Управление ресурсами

Рассмотрим бессмысленную функцию.


void do_resources1(int a, int b, const char* s) // плохая функция

                           // неаккуратное использование ресурсов

{

  FILE* f = fopen(s,"r");    // открываем файл (стиль C)

  int* p = new int[a];       // выделяем память

  if (b<=0) throw Bad_arg(); // может генерировать исключение

  int* q = new int[b];       // выделяем еще немного памяти

  delete[] p;                // освобождаем память,

                             // на которую ссылается указатель p

}


Для того чтобы протестировать функцию

do_resources1()
, мы должны проверить, правильно ли распределены ресурсы, т.е. освобожден ли выделенный ресурс или передан другой функции.

Перечислим очевидные недостатки.

• Файл

s
не закрыт.

• Память, выделенная для указателя

p
, не освобождается, если
b<=0
или если второй оператор new генерирует исключение.

• Память, выделенная для указателя

q
, не освобождается, если
0
.


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

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


void do_resources2(int a, int b, const char* s) // менее плохой код

{

  ifstream is(s);            // открываем файл

  vectorv1(a);          // создаем вектор (выделяем память)

  if (b<=0) throw Bad_arg(); // может генерировать исключение

  vector v2(b);         // создаем другой вектор (выделяем память)

}


 Теперь каждый ресурс принадлежит объекту и освобождается его деструктором. Иногда, чтобы выработать идеи для тестирования, полезно попытаться сделать функцию более простой и ясной. Общую стратегию решения задач управления ресурсами обеспечивает метод RAII (Resource Acquisition Is Initialization — получение ресурса есть инициализация), описанный в разделе 19.5.2.

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


FILE* do_resources3(int a, int* p, const char* s) // плохая функция

                                   // неправильная передача ресурса

{

  FILE* f = fopen(s,"r");

  delete p;

  delete var;

  var = new int[27];

  return f;

}


Правильно ли, что функция

do_resources3()
передает (предположительно) открытый файл обратно как возвращаемое значение? Правильно ли, что функция
do_resources3()
освобождает память, передаваемую ей как аргумент
p
? Мы также добавили действительно коварный вариант использования глобальной переменной var (очевидно, указатель). В принципе передача ресурсов в функцию и из нее является довольно распространенной и полезной практикой, но для того чтобы понять, корректно ли выполняется эта операция, необходимо знать стратегию управления ресурсами. Кто владеет ресурсом? Кто должен его удалять/освобождать? Документация должна ясно и четко отвечать на эти вопросы. (Помечтайте.) В любом случае передача ресурсов изобилует возможностями для ошибок и представляет сложность для тестирования.

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

26.3.3.3. Циклы

 Мы уже рассматривали циклы, когда обсуждали функцию

binary_search()
.

Большинство ошибок возникает в конце циклов.

• Правильно ли проинициализированы переменные в начале цикла?

• Правильно ли заканчивается цикл (часто на последнем элементе)?


Приведем пример, который содержит ошибку.


int do_loop(const vector& v) // плохая функция

                                  // неправильный цикл

{

  int i;

  int sum;

  while(i<=vec.size()) sum+=v[i];

  return sum;

}


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

sum
.

 Многие циклы связаны с данными и могут вызвать переполнение при вводе больших чисел.

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


char buf[MAX];    // буфер фиксированного объема

char* read_line() // опасная функция

{

  int i = 0;

  char ch;

  while(cin.get(ch) && ch!='\n') buf[i++] = ch;

  buf[i+1] = 0;

  return buf;

}

Разумеется, вы не написали бы ничего подобного! (А почему нет? Что плохого в функции

read_line()
?) Однако эта ошибка, к сожалению, является довольно распространенной и имеет разные варианты.


// опасный фрагмент

gets(buf);       // считываем строку в переменную buf

scanf("%s",buf); // считываем строку в переменную buf


 Поищите описание функций

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

26.3.3.4. Ветвление

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

if
и
switch
являются одними из основных целей для тестировщиков. Существуют две проблемы, которые необходимо исследовать.

• Все ли возможные варианты предусмотрены?

• Правильные ли действия связаны с правильными вариантами выбора?


Рассмотрим следующую бессмысленную функцию:


void do_branch1(int x, int y) // плохая функция

    // неправильное использование инструкции if

{

  if (x<0) {

    if (y<0)

      cout << "Большое отрицательное число \n";

    else

      cout << "Отрицательное число \n";

  }

  else if (x>0) {

    if (y<0)

      cout << "Большое положительное число \n";

    else

      cout << "Положительное число \n";

  }

}


Наиболее очевидная ошибка в этом фрагменте заключается в том, что мы забыли о варианте, в котором переменная

x
равна нулю. Сравнивая числа (положительные или отрицательные) с нулем, программисты часто забывают о нем или приписывают неправильной ветви (например, относят его к отрицательным числам). Кроме того, существует более тонкая (хотя и распространенная) ошибка, скрытая в этом фрагменте: действия при условиях (
x>0 && y<0
) и (
x>0 && y>=0
) каким-то образом поменялись местами. Это часто случается, когда программисты пользуются командами “копировать и вставить”.

Чем более сложными являются варианты использования инструкций

if
, тем вероятнее становятся ошибки. Тестировщики анализируют такие коды и стараются не пропустить ни одной ветви. Для функции
do_branch1()
набор тестов очевиден.


do_branch1(–1,–1);

do_branch1(–1, 1);

do_branch1(1,–1);

do_branch1(1,1);

do_branch1(–1,0);

do_branch1(0,–1);

do_branch1(1,0);

do_branch1(0,1);

do_branch1(0,0);


По существу, это наивный подход “перебора всех альтернатив”, которой мы применили, заметив, что функция

do_branch1()
сравнивает значения с нулем с помощью операторов
<
и
>
. Для того чтобы выявить неправильные действия при положительных значениях переменной
x
, мы должны объединить вызовы функции с желаемыми результатами.

Обработка инструкций

switch
аналогична обработке инструкций
if
.


void do_branch1(int x, int y)  // плохая функция

 // неправильное использование инструкции switch

{

  if (y<0 && y<=3)

  switch (x) {

  case 1:

    cout << "Один\n";

    break;

  case 2:

    cout << "Два\n";

    case 3:

  cout << "Три\n";

  }

}


Здесь сделаны четыре классические ошибки.

• Мы проверяем значения неправильной переменной (

y
, а не
x
).

• Мы забыли об инструкции

break
, что приводит к неправильному действию при
x==2
.

• Мы забыли о разделе

default
(считая, что он предусмотрен инструкцией
if
).

• Мы написали

y<0
, хотя имели в виду
0
.


 Как тестировщики мы всегда ищем непредвиденные варианты. Пожалуйста, помните, что просто устранить проблему недостаточно. Она может возникнуть снова, когда мы ее не ожидаем. Мы хотим писать тесты, которые систематически выявляют ошибки. Если мы просто исправим этот простой код, то можем либо неправильно решить задачу, либо внести новую ошибку. Цель анализа кода заключается не только в выявлении ошибок (хотя это всегда полезно), а в разработке удобного набора тестов, позволяющих выявить все ошибки (или, говоря более реалистично, большинство ошибок).

 Подчеркнем, что циклы всегда содержат неявные инструкции if: они выполняют проверку условия выхода из цикла. Следовательно, циклы также являются инструкциями ветвления. Когда мы анализируем программы, содержащие инструкции ветвления, первым возникает следующий вопрос: все ли ветви мы проверили? Удивительно, но в реальной программе это не всегда возможно (потому что в реальном коде функции вызываются так, как удобно другим функциям, и не всегда любыми способами). Затем возникает следующий вопрос: какую часть кода мы проверили? И в лучшем случае мы можем ответить: “Мы проверили большинство ветвей”, объясняя, почему мы не смогли проверить остальные ветви. В идеале при тестировании мы должны проверить 100% кода.

26.3.4. Системные тесты

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

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

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

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

26.3.4.1. Зависимости

 Представьте себе, что вы сидите перед экраном, стараясь систематически тестировать программу со сложным графическим пользовательским интерфейсом. Где щелкнуть мышью? В каком порядке? Какие значения я должен ввести? В каком порядке? Для любой сложной программы ответить на все эти вопросы практически невозможно. Существует так много возможностей, что стоило бы рассмотреть предложение использовать стаю голубей, которые клевали бы по экрану в случайном порядке (они работали бы всего лишь за птичий корм!). Нанять большое количество новичков и глядеть, как они “клюют”, — довольно распространенная практика, но ее нельзя назвать систематической стратегией. Любое реальное приложение сопровождается неким повторяющимся набором тестов. Как правило, они связаны с проектированием интерфейса, который заменяет графический пользовательский интерфейс.

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

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



Это позволяет писать или генерировать сценарии для главной программы так, как мы это делали при тестировании отдельных модулей (см. раздел 26.3.2). Затем мы можем протестировать главную программу отдельно от графического пользовательского интерфейса.



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

Приведенный ниже рисунок иллюстрирует два важных аспекта хорошего тестирования.



• Части системы следует (по возможности) тестировать по отдельности. Только модули с четко определенным интерфейсом допускают тестирование по отдельности.

• Тесты (по возможности) должны быть воспроизводимыми. По существу, ни один тест, в котором задействованы люди, невозможно воспроизвести в точности.


Рассмотрим также пример проектирования с учетом тестирования, которое мы уже упоминали: некоторые программы намного легче тестировать, чем другие, и если бы мы с самого начала проекта думали о его тестировании, то могли бы создать более хорошо организованную и легче поддающуюся тестированию систему (см. раздел 26.2). Более хорошо организованную? Рассмотрим пример.



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

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

Мы уже видели такой пример: классы графического интерфейса из глав 13–16. Они изолируют главную программу (т.е. код, который вы написали) от готовой системы графического пользовательского интерфейса: FLTK, Windows, Linux и т.д. При такой схеме мы можем использовать любую систему ввода-вывода.



 Важно ли это? Мы считаем, что это чрезвычайно важно. Во-первых, это облегчает тестирование, а без систематического тестирования трудно серьезно рассуждать о корректности. Во-вторых, это обеспечивает переносимость программы. Рассмотрим следующий сценарий. Вы организовали небольшую компанию и написали ваше первое приложение для системы Apple, поскольку (так уж случилось) вам нравится именно эта операционная система. В настоящее время дела вашей компании идут успешно, и вы заметили, что большинство ваших потенциальных клиентов выполняют свои программы под управлением операционной систем Windows или Linux. Что делать? При простой организации кода с командами графического интерфейса (Apple Mac), разбросанными по всей программе, вы будете вынуждены переписать всю программу. Эта даже хорошо, потому что она, вероятно, содержит много ошибок, не выявленных в ходе несистематического тестирования. Однако представьте себе альтернативу, при которой главная программа отделена от графического пользовательского интерфейса (для облегчения систематического тестирования). В этом случае вы просто свяжете другой графический пользовательский интерфейс со своими интерфейсными классами (транслятор на диаграмме), а большинство остального кода системы останется нетронутым.



 На самом деле эта схема представляет собой пример использования “тонких” явных интерфейсов, которые явным образом отделяют части программы друг от друга. Это похоже на использование уровней, которые мы видели в разделе 12.4. Тестирование усиливает желание разделить программу на четкие отдельные модули (с интерфейсами, которые можно использовать для тестирования).

26.3.5. Тестирование классов

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

Shape
из раздела 14.2.


class Shape { // задает цвет и стиль, хранит последовательность линий

public:

  void draw() const;                 // задает цвет и рисует линии

  virtual void move(int dx, int dy); // перемещает фигуру

                                     // на +=dx и +=dy

  void set_color(Color col);

  Color color() const;


  void set_style(Line_style sty);

  Line_style style() const;


  void set_fill_color(Color col);

  Color fill_color() const;


  Point point(int i) const; // доступ к точкам без права

                            // модификации

  int number_of_points() const;


  virtual ~Shape() { }

protected:

  Shape();

  virtual void draw_lines() const; // рисует соответствующие точки

  void add(Point p);               // добавляет точку p

  void set_point(int i,Point p);   // points[i]=p;

private:

  vector points; // не используется всеми

  // фигурами

  Color lcolor;         // цвет для линий и символов

  Line_style ls;

  Color fcolor;         // цвет заполнения


  Shape(const Shape&);  // предотвращает копирование

  Shape& operator=(const Shape&);

};


Как приступить к тестированию этого класса? Сначала рассмотрим, чем класс

Shape
отличается от функции
binary_search
с точки зрения тестирования.

• Класс

Shape
имеет несколько функций.

• Состояние объекта класса

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

• Класс

Shape
имеет виртуальные функции. Другими словами, поведение объекта класса
Shape
зависит от того, какой производный класс был создан на его основе (если такой класс существует).

• Класс

Shape
не является алгоритмом.

• Изменение объекта класса

Shape
может влиять на содержимое экрана.


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

Shape
. Это не соответствует принципам систематичного, воспроизводимого и доступного тестирования. Как указывалось в разделе 26.3.4.1, мы часто прибегаем к разным уловкам, чтобы избежать этого. Однако пока будем предполагать, что существует наблюдатель, который замечает отклонения изображения от требуемого образца.

 Отметим важную деталь: пользователь может добавлять точки, но не может их удалять. Пользователь или функции класса

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

Что мы можем тестировать, а что не можем? Для того чтобы тестировать класс

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

Ранее мы уже отметили, что объект класса

Shape
имеет состояние (значение), определенное четырьмя данными-членами.


vector points;

Color lcolor; // цвет линий и символов

Line_style ls;

Color fcolor; // цвет заполнения


Все, что мы можем сделать с объектом класса

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

Простейшим объектом класса

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


Line ln(Point(10,10), Point(100, 100));

ln.draw();  // смотрим, что произошло


// проверка точек:

if (ln.number_of_points() != 2)

  cerr << "Неправильное количество точек ";

if (ln.point(0)!=Point(10,10)) cerr << "Неправильная точка 1";

if (ln.point(1)!=Point(100,100)) cerr << "Неправильная точка 2";


for (int i=0; i<10; ++i) { // смотрим на перемещения объекта

  ln.move(i+5,i+5);

  ln.draw();

}


for (int i=0; i<10; ++i) { // проверяем, возвращается ли объект

                           // в исходное положение

  ln.move(i–5,i–5);

  ln.draw();

}


if (point(0)!=Point(10,10))

  cerr << "Неправильная точка 1 после перемещения";

if (point(1)!=Point(100,100))

  cerr << "Неправильная точка 2 после перемещения";


for (int i = 0; i<100; ++i) { // смотрим, правильно ли изменяются

                              // цвета

  ln.set_color(Color(i*100));

  if (ln.color() != Color(i*100))

    cerr << "Неправильное значение set_color";

  ln.draw();

}


for (int i = 0; i<100; ++i) { // смотрим, правильно ли изменяется

                              // стиль

  ln.set_style(Line_style(i*5));

  if (ln.style() != Line_style(i*5))

  cerr << "Неправильное значение set_style";

  ln.draw();

}


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

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

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

Shape
. Итак, у нас появляются две альтернативы:

• замедлить работу программы, чтобы за ней мог следить наблюдатель;

• найти такое представление класса

Shape
, чтобы мы могли читать и анализировать его с помощью программы.


Отметим, что мы еще не тестировали функцию

add(Point)
. Для того чтобы проверить ее, мы, вероятно, должны были бы использовать класс
Open_polyline
.

26.3.6. Поиск предположений, которые не выполняются

Спецификация класса

binary_search
ясно указывает на то, что последовательность, в которой выполняется поиск, должна быть упорядоченной. Это не позволяет нам создавать многие изощренные модульные тесты. Однако очевидно, что существует возможность написать неправильный код, для которого мы не сможем изобрести тест, идентифицирующий ошибки (за исключением системных тестов). Можем ли мы использовать свое знание системных модулей (функций, классов и т.п.) для того, чтобы изобрести более хорошие тесты?

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

Рассмотрим функцию

binary_search
еще раз: мы не можем проверить, что входная последовательность
[first:last]
действительно является последовательностью и что она была упорядочена (см. раздел 26.3.2.2). Однако можем написать функцию, которая выполняет эту проверку.


template

bool b2(Iter first, Iter last, const T& value)

{

  // проверяем, является ли диапазон [first:last)

  // последовательностью:

  if (last


  // проверяем, является ли последовательность упорядоченной :

  for (Iter p = first+1; p

    if (*p<*(p–1)) throw Not_ordered();


  // все хорошо, вызываем функцию binary_search:

  return binary_search(first,last,value);

}


Перечислим причины, по которым функция

binary_search
не содержала таких проверок.

• Условие

last
нельзя проверить для однонаправленного итератора; например, итератор контейнера
std::list
не имеет оператора
<
(раздел Б.3.2). В общем, на самом деле хорошего способа проверки того, что пара итераторов определяет последовательность, не существует (начинать перемещение с итератора
first
, надеясь достигнуть итератора
last
, — не самая хорошая идея).

• Просмотр последовательности для проверки того, что ее значения упорядочены, является более затратным, чем выполнение самой функции

binary_search
(действительная цель выполнения функции
binary_search
заключается не в слепом блуждании по последовательности в поисках значения, как это делает функция
std::find
).


Что же мы могли бы сделать? Мы могли бы при тестировании заменить функцию

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


template // предупреждение:

                              // содержит псевдокод

bool binary_search (Iter first, Iter last, const T& value)

{

  if ( тест включен ) {

    if (Iter является итератором произвольного доступа) {

      // проверяем, является ли [first:last)

      // последовательностью :

      if (last

    }


    // проверяем является ли последовательность

    // упорядоченной:

    if (first!=last) {

      Iter prev = first;

      for (Iter p = ++first; p!=last; ++p, ++ prev)

        if (*p<*prev) throw Not_ordered();

    }

  }

  // теперь выполняем функцию binary_search

}


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

test_enabled
. Мы также оставили условие Iter является итератором произвольного доступа в виде псевдокода, поскольку не хотели объяснять свойства итератора. Если вам действительно необходим такой тест, посмотрите тему свойства итераторов (iterator traits) в более подробном учебнике по языку С++.

26.4. Проектирование с учетом тестирования

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

Примеры из разделов 26.3.2.1 и 26.3.3 иллюстрируют эти важные положения.

• Пишите точно определенные интерфейсы так, чтобы вы могли написать для них тесты.

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

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

• Минимизируйте зависимости и делайте их явными.

• Придерживайтесь ясной стратегии управления ресурсами.


С философской точки зрения это можно рассматривать как применение методов модульного тестирования для проверки подсистем и полных систем.

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

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

26.5. Отладка

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

26.6. Производительность

 Для того чтобы программа оказалась полезной, мало, чтобы она была правильной. Даже если предположить, что она имеет все возможности, чтобы быть полезной, она к тому же должна обеспечивать приемлемый уровень производительности. Хорошая программа является достаточно эффективной; иначе говоря, она выполняется за приемлемое время и при доступных ресурсах. Абсолютная эффективность никого не интересует, и стремление сделать программу как можно более быстродействующей за счет усложнения ее кода может серьезно повредить всей системе (из-за большего количества ошибок и большего объема отладки), повысив сложность и дороговизну ее эксплуатации (включая перенос на другие компьютеры и настройку производительности ее работы).

Как же узнать, что программа (или ее модуль) является достаточно эффективной? Абстрактно на этот вопрос ответить невозможно. Современное аппаратное обеспечение работает настолько быстро, что для многих программ этот вопрос вообще не возникает. Нам встречались программы, намеренно скомпилированные в режиме отладки (т.е. работающие в 25 раз медленнее, чем требуется), чтобы повысить возможности диагностики ошибок, которые могут возникнуть после их развертывания (это может произойти даже с самым лучшим кодом, который вынужден сосуществовать с другими программами, разработанными “где-то еще”).

Следовательно, ответ на вопрос “Достаточно ли эффективной является программа?” звучит так: “Измерьте время, за которое выполняется интересный тест”. Очевидно, что для этого необходимо очень хорошо знать своих конечных пользователей и иметь представление о том, что именно они считают интересным и какую продолжительность работы считают приемлемой для такого интересного теста. Логически рассуждая, мы просто отмечаем время на секундомере при выполнении наших тестов и проверяем, не работали ли они дольше разумного предела. С помощью функции

clock()
(раздел 26.6.1) можно автоматически сравнивать продолжительность выполнения тестов с разумными оценками. В качестве альтернативы (или в дополнение) можно записывать продолжительность выполнения тестов и сравнивать их с ранее полученными результатами. Этот способ оценки напоминает регрессивное тестирование производительности программы.

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

Matrix
из главы 26).

Некто предложил использовать подходящую функцию.


double row_sum(Matrix m, int n); // суммирует элементы в m[n]


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

v[n]
— сумма элементов в первых
n
строках.


double row_accum(Matrix m, int n) // сумма элементов

                                            // в m[0:n)

{

  double s = 0;

  for (int i=0; i

  return s;

}


 // вычисляет накопленные суммы по строкам матрицы m:

 vector v;

 for (int i = 0; i

 v.push_back(row_accum(m,i+1));


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

m
. Почему? Дело в том, что мы просуммировали все элементы в первой строке, затем добавили элементы из второй строки (снова перебрав все элементы из первой строки), потом все элементы из третьей строки (перебрав все элементы из первой и второй строк) и т.д. Если вы считаете этот пример неудачным, посмотрите, что произойдет, если функция
row_sum()
обратится к базе данных за данными. Чтение данных с диска во много тысяч раз медленнее, чем чтение из оперативной памяти.

Вы можете возразить: “Никто никогда не сможет сделать нечто настолько глупое!” Извините, но мы видели вещи и похуже, и, как правило, плохой (с точки зрения производительности) алгоритм очень нелегко выявить, если он глубоко скрыт в коде приложения. Заметили ли вы проблемы с производительностью, когда в первый раз увидели этот код? Проблему бывает трудно выявить, если не искать ее целенаправленно. Рассмотрим простой реальный пример, найденный на одном сервере.


for (int i=0; i


Часто переменная

s
представляет собой строку размером примерно 20 K.

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

Такие “неалгоритмические” проблемы обычно связаны с неправильным проектированием. Перечислим некоторые из них.

• Повторяющееся перевычисление информации (как, например, в приведенном выше примере).

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

• Повторяющиеся обращения к диску (или к сети).


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

26.6.1. Измерение времени

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

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

time
, чтобы система вывела продолжительность ее выполнения. Можете также использовать команду
time
, чтобы выяснить, сколько времени заняла компиляция исходного файла
x.cpp
. Обычно компиляция выполняется по команде


g++ x.cpp


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

time
.


time g++ x.cpp


Система откомпилирует файл

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

 А что, если вы хотите измерить интервал времени, длящийся всего несколько миллисекунд? Что, если вы хотите выполнить свои собственные, более подробные измерения, связанные с работой части вашей программы? Продемонстрируем использование функции

clock()
из стандартной библиотеки, позволяющей измерить продолжительность выполнения функции
do_something()
.


#include 

#include 

using namespace std;


int main()

{

  int n = 10000000;        // повторяем do_something() n раз

  clock_t t1 = clock();    // начало отсчета

  if (t1 == clock_t(–1)) { // clock_t(–1) значит "clock()

                           // не работает"

    cerr << "Извините, таймер не работает \n";

    exit(1);

  }

  for (int i = 0; i


  clock_t t2 = clock(); // конец отсчета

  if (t2 == clock_t(–1)) {

    cerr << "Извините, таймер переполнен \n";

    exit(2);

  }


  cout << "do_something() " << n << " раз занимает "

<< double(t2–t1)/CLOCKS_PER_SEC << " сек "

<< " (точность измерений: "

<< CLOCKS_PER_SEC << " сек)\n";

}


Функция

clock()
возвращает результат типа
clock_t
. Явное преобразование
double(t2–t1)
перед делением необходимо, поскольку тип
clock_t
может быть целым число. Точный момент запуска функции
clock()
зависит от реализации; функция
clock()
предназначена для измерения интервалов времени в пределах одного сеанса выполнения программы. При значениях
t1
и
t2
, возвращаемых функцией
clock()
, число
double(t2–t1)/CLOCKS_PER_SEC
является наилучшим приближением времени, прошедшего между двумя вызовами функции
clock()
и измеренного в секундах. Макрос
CLOCKS_PER_SEC
(тактов в секунду) описан в заголовке
.

Если функция

clock()
для процессора не предусмотрена или временной интервал слишком длинный, функция
clock()
возвращает значение
clock_t(–1)
. Функция
clock()
предназначена для измерения временных интервалов, длящихся от доли секунды до нескольких секунд. Например, если (что бывает довольно часто) тип
clock_t
представляет собой 32-битовый тип
int
со знаком и параметр
CLOCKS_PER_SEC
равен
1000000
, мы можем использовать функцию
clock()
для измерения интервалов времени продолжительностью от 0 до 2000 секунд (около половины часа), выраженных в микросекундах.

 Напоминаем: нельзя доверять любым измерениям времени, которые нельзя повторить, получив примерно одинаковые результаты. Что значит “примерно одинаковые результаты”? Примерно 10%. Как мы уже говорили, современные компьютеры являются быстрыми: они выполняют миллиард инструкций в секунду. Это значит, что вы не можете измерить продолжительность ни одной операции, если она не повторяется десятки тысяч раз или если программа не работает действительно очень медленно, например, записывая данные на диск или обращаясь в веб. В последнем случае вы должны повторить действие несколько сотен раз, но медленная работа программы должна вас насторожить.

26.7. Ссылки

Stone, Debbie, Caroline Jarrett, MarkWoodroffe, and Shailey Minocha. User Interface Design and Evaluation. Morgan Kaufmann, 2005. ISBN 0120884364.

Whittaker, James A. How to Break Software: A Practical Guide to Testing. Addison-Wesley, 2003. ISBN 0321194330.


Задание

Протестируйте функцию

binary_search
.

1. Реализуйте оператор ввода для класса

Test
из раздела 26.3.2.2.

2. Заполните файл тестов для последовательностей из раздела 26.3.

2.1.

{ 1 2 3 5 8 13 21 }        // "обычная последовательность"

2.2.

{ }

2.3.

{ 1 }

2.4.

{ 1 2 3 4 }                // нечетное количество элементов

2.5.

{ 1 2 3 4 5 }              // четное количество элементов

2.6.

{ 1 1 1 1 1 1 1 }               // все элементы равны

2.7.

{ 0 1 1 1 1 1 1 1 1 1 1 1 1 }   // другой элемент в начале

2.8.

{ 0 0 0 0 0 0 0 0 0 0 0 0 0 1 } // другой элемент в конце

3. Основываясь на разделе 26.3.1.3, выполните программу, генерирующую следующие варианты.

3.1. Очень большая последовательность (что считать большой последовательностью и почему?).

3.2. Десять последовательностей со случайным количеством элементов.

3.3. Десять последовательностей с 0, 1, 2 ... 9 со случайными элементами (но упорядоченные).

4. Повторите эти тесты для последовательностей строк, таких как

{ Bohr Darwin Einstein Lavoisier Newton Turing }
.


Контрольные вопросы

1. Создайте список приложений, сопровождая их кратким описанием наихудшего события, которое может произойти из-за ошибки; например, управление самолетом — авиакатастрофа: гибель 231 человека; потеря оборудования на 500 млн. долл.

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

3. В чем заключается разница между модульным и системным тестированием?

4. Что такое регрессивное тестирование и почему оно является важным?

5. Какова цель тестирования?

6. Почему функция

binary_search
просто не проверяет свои требования?

7. Если мы не можем проверить все возможные ошибки, то какие ошибки следует искать в первую очередь?

8. В каких местах кода, манипулирующего последовательностью элементов, вероятнее обнаружить ошибки?

9. Почему целесообразно тестировать программу при больших значениях?

10. Почему часто тесты представляются в виде данных, а не в виде кода?

11. Почему и когда мы используем многочисленные тесты, основанные на случайных величинах?

12. Почему трудно тестировать программы, использующие графический пользовательский интерфейс?

13. Что необходимо тестировать при проверке отдельного модуля?

14. Как связаны между собой тестируемость и переносимость?

15. Почему классы тестировать труднее, чем функции?

16. Почему важно, чтобы тесты были воспроизводимыми?

17. Что может сделать тестировщик, обнаружив, что модуль основан на непроверяемых предположениях (предусловиях)?

18. Как проектировщик/конструктор может улучшить тестирование?

19. Чем тестирование отличается от отладки?

20. В чем заключается важность производительности?

21. Приведите два (и больше) примера того, как легко возникают проблемы с производительностью.


Ключевые слова


Упражнения

1. Выполните ваш алгоритм

binary search
из раздела 26.1 с тестами, представленными в разделе 26.3.1.

2. Настройте тестирование функции

binary_search
на обработку элементов произвольного типа. Затем протестируйте ее на последовательности элементов типа
string
и чисел с плавающей точкой.

3. Повторите упражнение 1 с вариантом функции

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

4. Изобретите формат для тестовых данных, чтобы можно было один раз задать последовательность и выполнить для нее несколько тестов.

5. Добавьте новый тест в набор тестов для функции

binary_search
и попытайтесь перехватить (маловероятную) ошибку при модификации последовательности.

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

7. Протестируйте простой текстовый редактор из раздела 20.6.

8. Добавьте текстовый интерфейс к библиотеке графического пользовательского интерфейса из глав 12–15. Например, строка

Circle(Point(0,1),15)
должна генерировать вызов
Circle(Point(0,1),15)
. Используйте этот текстовый интерфейс для создания “детского рисунка”: плоский домик с крышей, два окна и дверь.

9. Добавьте формат текстового вывода к библиотеке графического интерфейса. Например, при выполнении вызова

Circle(Point(0,1),15)
в поток вывода должна выводиться строка
Circle(Point(0,1),15)
.

10. Используя текстовый интерфейс из упр. 9, напишите более качественный тест для библиотеки графического пользовательского интерфейса.

11. Оцените время выполнения суммирования в примере из раздела 26.6, где

m
— квадратная матрица с размерами 100, 10 000, 1 000 000 и 10 000 000. Используйте случайные значения из диапазона
[–10:10]
. Перепишите процедуру вычисления величины
v
, используя более эффективный (не
O(n2)
) алгоритм, и сравните продолжительность его выполнения.

12. Напишите программу, генерирующую случайные числа с плавающей точкой, и отсортируйте их с помощью функции

std::sort()
. Измерьте время, затраченное на сортировку 500 тысяч чисел типа double и 5 миллионов чисел типа
double
.

13. Повторите эксперимент из предыдущего упражнения, но со случайными строками, длина которых лежит в интервале

[0:100]
.

14. Повторите предыдущее упражнение, но на этот раз используйте контейнер

map
, а не
vector
, чтобы сортировать его не требовалось.


Послесловие

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

Глава 27