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

Юнит тесты, в отличие от многих других видов тестирования, обладают одной замечательной особенностью. Они обеспечивают практически 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» несколько раз.

comments powered by Disqus