Not a kernel guy

… in the Windows kernel team

Tuesday, December 25, 2007

И кнопочку «Повтор» не забудьте!

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

Тут есть минимум две проблемы. Во-первых, как только в тест подмешиваются любые внешние, т.е. неконтролируемые параметры, так сразу начинаются сложности. Например, если тест использует сетевое соединение, то его успешность зависит от того, что творилось в сети на момент запуска теста. Или, скажем, если тест может зависеть от регистрации того или иного COM компонента. Строго говоря, в таких случаях тест перестаёт быть юнит тестом. Его нужно называть интеграционным тестом или как-нибудь ещё. На практике же терминологические тонкости мало кого интересуют, а вот снижение повторяемость тестов – очень даже.

Во-вторых, даже если на тест не влияют никакие внешние «раздражители», остаётся другая проблема – неизбежный рост сложности кода. Каждая новая ветка кода требует отдельного теста. В теории. На практике об этом забывают сразу же после завершения работы над юнит тестом для “Hello world”. Писать отдельный тест каждой комбинации входных параметров выходит крайне неэффективно. Тесты, покрывающие все или большую часть комбинаций входных параметров, очень скоро стремятся стать сложнее тестируемого кода, да и времени полный перебор отнимает изрядно. По этому, в ход идут тесты, генерирующие входные параметры случайным или псевдослучайным образом.

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

Хотя использование фаззеров позволяет эффективно отлавливать ошибки, в точки зрения повторяемости результатов фаззеры выглядят не очень хорошо. Случайность генерируемых ошибок фундаментальна. Отказ от неё ведет либо к полному перебору, либо к индивидуальным тестам, воспроизводящим лишь наиболее часто возникающие условия. Однако и со случайностью можно бороться, не отказываясь от неё. Любой уважающий себя тест обязан вести журнал выполняемых действий и полученных результатов. В случае сбоя по этому журналу можно будет восстановить последовательность событий. Если пойти немного дальше можно встроить возможность повторения всех проделанных действий непосредственно в тест. Тем самым разработчик сможет воспроизвести проблему тут же на месте, непосредственно в отладчике. Код может выглядеть примерно вот так:

void
replayTest(
    const Log& log
    )
{
    ...
    while (!log.empty())
    {
        ...
        int r = log.top();
        log.pop_front();
        ...
    }
    ...
}

void
doTest()
{
    Log log;

    try
    {
        ...
        while (1)
        {
            ...
            int r = rand();
            log.push_back(r);
            ...
        }
        ...
    }
    catch (...)
    {
        DebugBreak();

        replayTest(log);

        throw;
    }
}

Любое исключение, достигшее «catch(…)», считается ошибкой. При этом будет вызвана функция «replayTest», которая воспроизведет в точности действия, приведшие к исключению. Этот пример можно сделать ещё лучше, вынеся последнюю итерацию цикла в отдельный блок кода и добавив возможность перезапустить «replayTest» несколько раз.

Posted at 3:03 pm •

RSS feed | Trackback URI

18 Comments »

[...] from blog.not-a-kernel-guy.com. Filed under: Программирование, [...]

 
Comment by Sergey Kostrukov — December 26, 2007 @ 1:13 am

Как раз вспоминал на днях как называется “фаззинг тестирование” =)

Только вот фаззинг-тестирование с точки зрения построения процесса не сопоставляется с юнит-тестами. Задача юнит-тестов доказать (или опровергнуть), что отдельно взятый модуль на “ожидаемый” поток входной информации поведет себя адекватно. А задача фаззинг-тестирования - доказать (или опровергнуть), что отдельно взятый модуль на “неожидаемый” поток входной информации, как минимум, не ответит неадекватно =)

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

Comment by Not a kernel guy — December 26, 2007 @ 10:05 am

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

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

 
 
Comment by BlaseMan — December 26, 2007 @ 3:20 am

А имеются ли какие-нибудь фреймворки для разработки фаззинг тестов? Или надо для каждого конкретного случая изобретать велосипед?

 
Comment by mihailik — December 26, 2007 @ 8:51 am
 
Comment by mihailik — December 26, 2007 @ 8:55 am

По-моему, правильный подход очевиден.

Случайным образом генерируются только данные. А уж сами юнит-тесты не должны иметь никаких случайных вариаций после того как запущены. Загрузить тестовый материал из файла testdata-314.dat и прогнать набор тестов.

Самый стабильный, документируемый и железобетонный способ, плюс regression tests всегда можно организовать.

Comment by Not a kernel guy — December 26, 2007 @ 10:08 am

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

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

Comment by mihailik — December 26, 2007 @ 10:39 am

А пользы от случайности данных ни в каком случае никакой.

На самом деле можно было бы просто инициализировать Random фиксированым seed, но я бы не пытался сэкономить мегабайты ценой усложнения логики тестов. Чем проще тест, тем надёжнее.

Comment by Not a kernel guy — December 26, 2007 @ 11:01 am

А пользы от случайности данных ни в каком случае никакой.

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

Comment by mihailik — December 26, 2007 @ 4:41 pm

Ну, не мне тебя математике учить. Покрытие в моём методе будет в точности тем же, что и в твоём.

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

Comment by Not a kernel guy — December 26, 2007 @ 4:59 pm

Дык в том то и вопрос, что я генерирую X*число запусков теста комбинаций, а ты - просто X комбинаций.

Comment by mihailik — December 30, 2007 @ 12:57 pm

А ты не догадался автоматизировать “число запусков теста”?

Вместо того чтобы запускать тест вручную 10 раз, стоило бы сделать X в десять раз больше.

Это получается знахарство и халтура.

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

Comment by Not a kernel guy — December 30, 2007 @ 3:01 pm

Вместо того чтобы запускать тест вручную 10 раз, стоило бы сделать X в десять раз больше.

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

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

Если ты не можешь оценить, сколько тебе нужно запусков теста, то этот тест не даёт тебе никакого позитивного результата.

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

Comment by mihailik — January 1, 2008 @ 12:56 pm

При отсутсвии “внешних раздражителей”, тест не должен зависеть от запуска.

Значение X должено быть ДОСТАТОЧНО большое, чтобы надёжно видеть PASSED/FAILED.

Если при разных запусках ты получаешь разные результаты, это не тест а азартная игра. Увеличь X и перестань надеяться на удачу.

Comment by Not a kernel guy — January 2, 2008 @ 9:45 am

Я, кажеться, нигде не говорил что X не предполагается быть достаточно большим.

(Comments wont nest below this level)
 
 
 
 
 
 
 
 
 
 
Comment by Not a kernel guy — December 26, 2007 @ 9:50 am

Нашел ошибку в посте:

Хотя использование фаззеров позволяет эффективно отлавливать ошибки, в точки зрения результатов фаззеры выглядят не очень хорошо.

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

 
Comment by ivaliy — December 26, 2007 @ 12:46 pm

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

Ну это как раз то что надо. То есть по-умолчанию проверяем на “все ОК”. Дальше пишем тесты на ожидаемые обломы и что должно происходить. Т.е. нет связи, код должен кинуть какой-нибудь ConnectionException, связь есть, но медленная должно прокинуться TimeoutException, есть связь и с таймаутами все ок, но нет прав на получение данных - кидаем AuthorizationException и т.д. и т.п. Ну а вообще конечно понятно, что для раскрытия темы одного поста мало :). И этих всяких видов и способов тестирования можно накатать вагон и еще целый состав вагонов.

Comment by Not a kernel guy — December 26, 2007 @ 1:24 pm

Ну это как раз то что надо.

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

 
 

Your Comment (smaller | larger)

You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

Powered by WordPress