company_banner

Случайность в автотестах

Введение


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

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

Пример


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

def smart_sqr(x)
  x > 0 ? x*x : -x*x;
end

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

assert_equal(smart_sqr(4), 16);

Вопрос — по какому принципу мне выбирать значения.

«Преимущества» случайных значений


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

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

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

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

Итак, мне не нужны случайные значения, чтобы проверить работоспособность моей функции. Я просто использую все граничные значения (верней те, которые мне таковыми кажутся) и по одному значения для каждого класса значений, которые, по моему мнению, ведут себя одинаково. В реальности для нашей функции я бы использовал значения –7, 0 и 13. Ваше мнение о граничных условиях может отличаться от моего, и это нормально. Например, единица ведет себя несколько отличным образом: ее квадрат равен исходному значению.

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

Недостатки случайных значений


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

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

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

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

Но в моем случае...


Да, в некоторых случаях использование случайных значений может оказаться полезным. Но вы должны относиться к этому крайне настороженно. В некоторых языках возможен вызов приватных методов класса. И это тоже иногда может оказаться полезным. Но это не повод не подумать семь раз, а потом еще два, перед тем как использовать эту возможность.

Я приведу пару случаев, в которых, на мой взгляд, можно закрыть глаза на использование случайных значений. Это не полный перечень. Если здравый смысл подсказывает вам, что вы можете или даже должны нарушить правило, которое я озвучил выше, нарушайте его.

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

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

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

В заключение


Хотелось бы добавить, что автоматическое тестирование, на мой взгляд, — одна из самых плохо исследованных и формализованных областей в программировании. На любой вопрос можно получить диаметрально противоположные ответы, а по любому поводу услышать взаимоисключающие мнения. Даже точки зрения уважаемых и признанных специалистов могут существенно разниться. Если вы прямо сейчас попробуете поиском найти ответ на вопрос, обсуждаемый в моей статье, вы услышите тысячи точек зрения, начиная от «случайность необходима» и заканчивая «случайность недопустима». Я попытался как можно более понятно разъяснить свои идеи, т. к. простое формулирование принципов в области автотестирования давно уже не работает.
Mail.ru Group
994.17
Строим Интернет
Share post

Comments 31

    +9
    Если взять все же чуть более сложный пример, чем просто возведение в степень — то замены особо то и нету.

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

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

    Альтернативы вот этой случаности я не вижу. Генерить собственные сейвы очень сложная задача. Можно оставлять сейвы с предыдушего прогона бота — но основные баги как раз находятся из-за доигрывания сейвов игроков.
      +1
      Да, на мой взгляд, вы все правильно делаете. Определить на глаз граничные условия в такой сложной штуке как игра — практически невозможно. Поэтому просто возьмем какую-то кучу сейвов и сделаем из них кучу тестов. Ну а дальше вступает в ход соображение и производительности: не можем мы каждый раз запускать всю кучу.
        0
        Мне кажется правильнее так: вместо одного теста со случайными величинами лучше сделать пару десятков тестов с фиксированными значениями и правильно их назвать. Например: БотСрезваетУгол, БотИдетВБрод, БотНаступаетНаБанан и тд. и тогда по картине падения тестов, даже не заглядывая в них вы можете догадаться где ошибка. А тест со случайными значениями еще надо разобрать, понять почему он упал, а это время и деньги.
          +1
          последовательным надо быть — сперва фиксированные тесты, а потом случайные
          0
          Ибо игрок может загнать игру в такую комбинацию, никакой бот не загонет.


          Не в этом ли проблема?

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

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

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

          Коротко о парном тестировании — сперва вы определяете набор действий и значений которые может выполнять игрок(бот), далее вы определяете стандартные значения для каждого из действий/значений.
          После этого — вы начинаете попарно изменять значения в каждом тесте.
          В конкретно вашем случае — тестом будет скорее не прохождение игры от начала до конца, а совершение одного хода игры (если игра пошаговая), с контролем ожидаемого результата на каждом шагу.
            +1
            Мухи отдельно, котлеты отдельно. Для автотестов выбрать определённые сейвы, можно случайным образом. И зафиксировать навечно. Выбрать достаточно много, чтобы хотя бы code coverage было хорошим.

            А случайностью тестировать — это не автотесты — это стресстестирование. Можно гонять сервер с тестированием хоть сутки на пролёт, если найдётся вариант, выдающий баг, который не ловится обычными тестами, исправить баг, добавить руками этот вариант в тест.

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

            Не вижу чем ваш вариант отличается от истории «Мы написали конкурент mysql. Пользователь может может выполнять кучу разных SQL запросов в произвольном порядке! Сам сервер работает в N потоков и в любом месте может быть дедлок или какое-либо системное race condition! Нам просто это не протестировать, мы берём реальные SQL запросы пользователей и прогоняем их на разном железе»
            +2
            Самый важный момент, который почти все забывают. Тест должен не только показывать, что ошибка есть, но и помогать её обнаружить, то есть быть составной частью цикла дебага.
              0
              Почемуже это самый важный момент?
              Сам факт наличия бага нисколько не мешает и не пугает. Зная о баге — его можно найти, разными способами и затратами по времени, но все же. В худшем случае предупредить клиентов/временно отключить фичу/сделать костыль.
              А вот не знание о существовании бага — это прямые потери и беды.
              Так что самый важный момент — это по прежнему определение факта наличия бага, а не поиск причины.
                0
                Репорт «приложение иногда падает» — бесполезен. Необходим максимум информации о том при каких условиях и с каким состоянием.
                  0
                  Нет не бесполезен. Он позволяет узнать о проблеме. Если мое приложение падает — я буду искать почему падает.
                  Если я не знаю о том, что падает — искать не буду.
                  Все же очень просто.
                    0
                    И чтобы найти где и что падает таки придётся написать тест, который не маскирует проблемное место, который не отправляет входные параметры в /dev/null, который не портит стектрейс и который не мешает дебаггеру останавливаться в месте возникновения исключительной ситуации, который не рандомизирует входные параметры. Почему вы считаете, что тест, который ничего из перечисленного не делает, является значительно более сложным?
                      0
                      Потому, что если бы баги ловились автотестами — мы бы жили совершенно в другом мире. :)
                      А вообще, то что вы описали — это не «помогает искать баги», а не «не мешает искать».
                      Конечно, тест не должен портить стэк и не должен мешать дебагеру. Но именно что не должен мешать, а не должен помогать.
                        0
                        Зачастую лучшая помощь — это не мешать.
                    +1
                    Дополню:
                    Нет никакого смысла писать изначально тест заточенный на поиск проблемы, т.к. проблема может никогда не появится, а время на усложнение теста будет потрачено.
                    Писать надо простой тест, который обнаружит факт наличия проблемы. И уже в случае возникновения проблемы надо усложнять тест, чтобы он более детально обрабатывал ситуацию. Или же вообще к другим инструментам прибегать.
                +8
                Статья — капитантство. «Вы не должны использовать, но вот иногда можно».

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

                Такое можно сказать практически о любой сфере деятельности. ООП — нужно/не нужно, TDD — нужно/умерло. Кроме вас, вашу задачу никто не решит. Поэтому решает каждый для себя сам.

                Компилятор, PDF Viewer, браузер, — куда вы уедете при создании таких инструментов без автоматического тестирования? Google на своих кластерах гоняет свои продукты на fuzzing-тестах непрерывно, выкашивая баги сотнями.
                В то же время, да, если вы делаете тесты с элементом случайности, при этом не проверяя граничные значения или не сохраняя seed упавшего теста — грош цена такому тестированию.

                Рекомендую курс Udacity по тестированию, по случайности там отдельная глава есть. Читает John Regehr, отличный специалист в своей области. К слову, в другом курсе рассказывается про язык хардварной верификации, в котором все переменные по умолчанию имеют случайные значения. Вот жуть-то, не правда ли?
                  +4
                  А можете написать топик-ответ? Я думаю Вам есть что высказать
                  0
                  То о чем вы говорите является базовыми знаниями в теории функционального тестирования.
                  По теме — Анализ граничных значений (Boundary Value Analysis) и Эквивалентное разбиение (Equivalence Partitioning).

                  Использование же случайных значений — так же рассматривается как проблема функционального тестирования, а именно как путь борьбы с «эффектом пестицида». При этом случайные значения беруться только в пределах какого-то из полученных разбиений, а не на всём множестве значений.

                  В итоге чтобы сделать хорошее автоматизированное тестирование — человеку неплохо бы подтянуть базу по функциональному тестированию.
                    +1
                    Проблема с граничными значениями часто в том, что программист не представляет себе реально граничных значений. Если бы представлял, то сразу бы написал без багов, а так и баг допустит, и тест не напишет. Несколько реальных примеров:
                    1. Функция бинарного поиска в сортированном массиве в куче языков программирования была сломана десятки лет для массивов длиной более 2^30 записей. Никому не приходило в голову, что (a+b)/2 может выдать отрицательное число для 32-битных знаковых целых.
                    2. Функция преобразования строки в число с плавающей точкой более десяти лет зацикливалась на некоторых входных строках. Миллионы людей пользовались, но никто не написал автотест на этот граничный случай.
                    3. Некто написал (на Java) код вроде table[Math.abs(str.hashCode())%size]. Многим ли придёт в голову написать тест на этот код, подав на вход строку «polygenelubricants» и одновременно size, не являющийся степенью двойки? Тот, кому бы пришло в голову написать такой тест, никогда бы не написал такой код.

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

                      Ничего вы не добьётесь тем, что будете добавлять случайность в тесты. Когда тот самый «2.2250738585072012e-308» вам попадётся (а именно, раз в 10 000 лет), в скажите «а, опять хрупкий тест», потому что, на самом деле он у вас падает пару раз в неделю по другим причинам, которые сложно найти из-за того, что их сложно воспроизвести из-за случайных данных.
                        0
                        То есть вы в своих тестах не выводите необходимую для воспроизведения информацию в отчёт? Это же несложно, а для случайных тестов вдвойне обязательно. Если же тест зациклился, тут ещё проще: вы приходите утром, убеждаетесь, что ночная сборка тестов почему-то всё ещё выполняется, снимаете дамп памяти и смотрите, что там происходит. Либо какой-нибудь хадсон по таймауту сам уже снял вам дамп и прибил сборку.
                          0
                          В вашем идеальном мире, отладночной информации всегда достаточно чтобы исправить баг. Т.е. есть баг, есль вывод какой-то информации. Программист его чинит, ни разу не запуская тест. Вообще ни разу.
                          Даже когда он написал код, он его не запускает и не проверяет, а сразу коммитит и заканчивает работу, даже не пытаясь посмотреть на результат.

                          Я же больше говорю про «воспроизведение бага» т.е. реальные запуски теста с тем чтобы баг повторился (и желательно чтобы это было не трудоёмко).
                        0
                        Именно потому и нужно разделять программиста и тестировщика и нельзя возлагать на разработчика ответственность за тестирование своих же поделок. Никто не пишет кода с багами, каждый разработчик думает что то что он написал работает правильно. И исходит из этого. Плюс к тому — умения для тестирования нужны не те же что для разработки. Это просто разные поля деятельности для которых нужны отличные образы мышления и подготовка.

                        Вот тестировщик сможет определить правильные граничные значения. Работа у него такая, он этим с утра до вечера занимается.
                          0
                          Следует различать тесты, направленные на обнаружение ошибок в новой функциональности, и тесты, направленные на закрепление существующей. Первые должен писать тестировщик, вторые — программист.
                            0
                            Возникает невольный вопрос: а почему?
                              0
                              Потому что программист знает (ну, в идеале :), что он написал, а тестировщик знает (ну, в идеале :), что программист должен был написать. По сути тест, написанный программистом, является отчётом о работе по заданию, а при методологиях типа TDD/BDD одновременно (и прежде всего) и заданием. Не задача программиста искать ошибки в понимании им задания — это не эффективно. Но и не задача тестировщика искать ошибки в реализации правильно понятого задания — это тоже не эффективно.
                                0
                                Вроде бы всё написано правильно, но непонятно как это подкрепляет точку зрения о том, что разработчик должен писать тесты. На мой взгляд единственные тесты, которые может и, возможно, должен писать разработчик — это юнит тесты, вещи для контроля что его код работает именно так как он задумал. Но это абсолютно никак не коррелирует с заданием, кроме как через голову этого самого разработчика. Задача таких тестов только помочь разработчику удостовериться что то что он сделал работает так как он хочет. Именно как он сам задумал, а не как написано в задании.

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

                                Разработчик этого делать не будет потому что ему это скучно и не сможет сделать, потому что его мозг не заточен на поиск каких-нибудь ошибок. Он может искать причину существующей ошибки, если на неё пожаловались, но не искать целенаправленно какую-то абстрактную ошибку, которой может и не быть. Разработчик это создатель, ему сама мысль о том что функционал, описанный в ТЗ может работать как-то не так противна и чужда как минимум до уровня миддла :)
                        +1
                        Мне казалось, что именно для того чтобы совместить предсказуемость со случайностью и придуманы разные тестовые фреймворки с генераторами (aka property based testing), такие как QuickCheck и его многочисленные потомки.
                          0
                          Все это можно отнести к категории «я ищу ошибку». Вероятно, мне стоило подчеркнуть, что речь идет в первую очередь о регресионных тестах, которые программист использует при рарзработке.
                            0
                            Отнюдь. Тест на сгенерированных значениях точно также поможет найти регрессию при очередном рефакторинге или оптимизации как и любой другой тест. Проблема с этими фреймворками скорее в том, что генераторы частенько оказываются достаточно нетривиальными.
                          0
                          Также есть вариант с автоматической неслучайной генерацией.

                          Вот именно. никогда не использую случайные данные в тестах. если нужно что-то симулировать, или нужен большой объём данных, использую генераторы псевдослучайных чисел с заранее заданным seed. Т.е. последовательность чисел всегда одинаковая (может поменяться если поменять код теста).
                            0
                            Согласен. Если seed задан заранее, то мы можем смело случайными данные не считать: просто некий нетривиальный генератор. Правда, если это возможно, я бы рекомендовал использовать тривиальный генератор.

                          Only users with full accounts can post comments. Log in, please.