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

December 25th, 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» несколько раз.

,

  1. Sergey Kostrukov
    December 26th, 2007 at 01:13 | #1

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

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

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

    • December 26th, 2007 at 10:05 | #2

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

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

  2. December 26th, 2007 at 03:20 | #3

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

  3. mihailik
    December 26th, 2007 at 08:51 | #4

    Grammar-based Whitebox Fuzzing
    http://research.microsoft.com/research/pubs/view.aspx?0rc=p&type=technical+report&id=1397

    Microsoft Research, November 2007

  4. mihailik
    December 26th, 2007 at 08:55 | #5

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

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

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

    • December 26th, 2007 at 10:08 | #6

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

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

      • mihailik
        December 26th, 2007 at 10:39 | #7

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

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

        • December 26th, 2007 at 11:01 | #8

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

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

          • mihailik
            December 26th, 2007 at 16:41 | #9

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

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

            • December 26th, 2007 at 16:59 | #10

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

              • mihailik
                December 30th, 2007 at 12:57 | #11

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

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

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

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

                • December 30th, 2007 at 15:01 | #12

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

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

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

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

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

                  • mihailik
                    January 1st, 2008 at 12:56 | #13

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

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

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

                    • January 2nd, 2008 at 09:45 | #14

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

  5. December 26th, 2007 at 09:50 | #15

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

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

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

  6. December 26th, 2007 at 12:46 | #16

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

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

    • December 26th, 2007 at 13:24 | #17

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

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

  1. December 25th, 2007 at 15:06 | #1
Comments are closed.