Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Kernigan_B__Payk_R_Praktika_programmirovania.pdf
Скачиваний:
78
Добавлен:
18.03.2016
Размер:
2.53 Mб
Скачать

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

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

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

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

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

Упражнение 6-3

Опишите, как вы будете тестировать f req.

Упражнение 6-4

Спроектируйте и реализуйте версию f req, которая подсчитывала бы частоты для других типов данных — таких, как 32-битовые целые или числа с плавающей точкой. Сможете ли вы добиться того, чтобы программа элегантно обрабатывала данные различных типов?

Автоматизация тестирования

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

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

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

В некоторых системах имеется большой арсенал средств, облегчающих подобную автоматизацию; языки скриптов позволяют писать короткие сценарии для запуска последовательностей тестов. В Unix утилиты сравнения файлов вроде cliff и стр дают возможность отслеживать изменения вывода, sort группирует схожие элементы, дгер фильтрует вывод тестов, we, sum и f req подсчитывают статистику вывода. С помощью этих утилит можно без труда создать подходящие к случаю тестовые оснастки — слабоватые, возможно, для больших проектов, но вполне пригодные для программ, создающихся одним или несколькими программистами.

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

for i in ka_data.*

# цикл по всем тестовым файлам данных do

old_ka $i >out1

#выполнить старую версию new_ka $i >out2

#выполнить новую версию if ! cmp -s out"! out2

#compare output files then

echo $i: BAD

# различаются: сообщение об ошибке fi done

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

Аргумент -s заставляет стр возвращать результат прохождения теста и ничего не выводить. Если сравниваемые файлы оказываются эквивалентными, стр возвращает результат "истина", тогда ! стр оказывается "ложью", и, стало быть, ничего не печатается. Однако, если файлы вывода старой и новой версий

различаются, стр возвращает значение "ложь", и выводится имя файла и предупреждение.

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

Создавайте замкнутые тесты. В дополнение к возвратным тестам полезно использовать и замкнутые тесты, которые содержат в себе и вводимые данные, и ожидаемые результаты. Здесь может оказаться поучительным наш опыт в тестировании программ на Awk. Многие конструкции языка тестируются посредством запуска крошечных программ на различных входных данных и проверкой выводимых результатов. Приведенный кусок взят из большого набора разнообразных тестов, он проверяет некоторое хитроумное инкрементное выражение. Тест запускает очередную версию программы (newawk), записывает ее выходные результаты в файл, правильные ответы записывает в другой файл с помощью команды echo, файлы сравнивает и в случае их различия сообщает об ошибке.

it тест инкремента полей:

$i++ означает($1)++, а не $(!++) echo 3 5 | newawk '{i = 1; print $i++; print $1, i}' >out1 echo '3

4 1' >out2 # правильный ответif ! cmp -s outl out2

# результаты различаются then

echo 'BAD: тест инкремента полей не прошел' fi

Первый комментарий — важная часть входных данных теста, ибо в нем описывается, что же именно проверяет данный тест.

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

try {if ($1 == 1) print "yes"; else print "no"} 1 yes

1.0 yes 1EO yes 0.1E1 yes 10E-1 yes 01 yes +1 yes 10E-2 no 10 no

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

ожидается вывод слова yes. Первые семь тестов должны все напечатать yes, а два последних — nо.

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

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

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

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

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

Упражнение 6-5

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

Тестовые оснастки

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

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

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

Чтобы проиллюстрировать повествования, мы создадим тест для memset, одной из функций семейства mem... стандартной библиотеки C/C++. Эти функции часто пишутся на языке ассемблера для,конкретных машин, поскольку их быстродействие очень важно. Однако чем более тонко они настраиваются на конкретные условия, тем больше вероятность возникновения в них ошибок и тем более тщательно должны они тестироваться.

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

Функция memset (s, с, п) записывает байт с в п байтов памяти, начиная с адреса s, и возвращает s. Если нет ограничений на скорость работы, написать такую функцию,— не проблема:

/* memset: устанавливает первые n байтов s равными с */ void *memset(void *s, int с, size_t n)

{

size_t i; char *p;

p = (char *) s;

for (i = 0; i < n; i++) p[i] = c; return s; }

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

Тестирование базируется на комбинации всесторонних проверок (в частности, естественно, проверок граничных условий в потенциально опасных точках). Для memset граничными, очевидно, являются такие значения п, как ноль, один и два, числа, являющиеся степенями двойки, а также соседние с ними значения — от самых маленьких до громадных, вроде 216, что соответствует естественной границе во многих машинах — 16-битовому слову. Степени двойки привлекают внимание изза того, что один из способов ускорить работу memset — устанавливать одновременно несколько байтов; это может быть выполнено с помощью специальных инструкций или посредством установки сразу не байта, а слова. Также надо проверять начальные значения массивов при различных выравниваниях — на

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

Кроме перечисленного нам надо проверить еще множество значений для с — включая ноль, Ox7F (самое большое значение для числа со знаком при 8-битовых байтах)-, 0x80 и OxFF (проверяя на потенциальные ошибки, связанные со знаковыми и беззнаковыми символами) и значения, превышающие один байт (чтобы удостовериться, что используется только один байт). Нам надо также записать в память некий шаблон, отличающийся от любого из этих значений, — с тем чтобы иметь возможность проверить, не производила ли memset запись вне границ предназначенной области.

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

big = максимальная левая граница + maximum n + максимальная правая граница

sO = malloc(big) s1 = malloc(big)

для каждого значения параметров n, с и отступа offset: установить sO и s1

в шаблонное значение выполнить медленный memset(sO + offset, с, п) выполнить быстрый memset(s1 + offset, с, п) проверить возвращаемые значения сравнить содержимое sO и s1 побайтово

Ошибка, вынуждающая memset писать вне границ своего массива, скорее всего, проявится в байтах рядом с началом и концом массива, так что, оставив буферную зону, проще увидеть поврежденные байты. Уменьшается и вероятность того, что будут перезаписаны какие-то части программы в памяти. Для проверки записи вне границ мы должны проверить все значения sO и s1, а не только те п байтов, которые должны были быть записаны.

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

offset = 10, 11, .., 20

с = 0, 1, Ox7F, 0x80, OxFF, 0x11223344 n = 0, 1, 2, 3, 4, 5, 7, 8, 9, 15, 16, 17, 31, 32, 33, .... 65535, 65536, 65537

Для n должны быть подставлены, по крайней мере, значения 2' - 1, 2' и 2' + 1 для всех г от 0 до 16.

Все перечисленные значения, естественно, не надо встраивать в основную часть оснастки — надо предусмотреть возможность записывать их в отдельные массивы

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

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

С тестированием memset связана одна история, которая может послужить вам хорошим уроком. Однажды мы дали копию тестов для memset одному программисту, разрабатывавшему операционную систему и библиотеки для нового процессора. Через несколько месяцев мы (авторы тестов) начали работать с этой новой машиной. В какой-то момент большое приложение не прошло своего набора тестов. Мы стали искать причины и после кропотливого труда докопались до истоков — проблема состояла в трудноуловимой неточности, связанной со знаковым расширением" в реализации memset на ассемблере. По непонятным причинам создатель библиотеки изменил тесты для memset, исключив из них проверку значений, больших Ox7F. Естественно, ошибка была найдена при запуске изначальной версии теста сразу после того, как подозрение пало на memset.

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

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

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

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

Далее был написан код, использующий эту отлаженную однопиксельную обработку,

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