Статьи

Искоренение недетерминизма в тестах

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

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

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

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

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

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


Почему недетерминированные тесты являются проблемой

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

Начну с расширения их бесполезности. Основное преимущество наличия автоматических тестов состоит в том, что они обеспечивают механизм обнаружения ошибок, выступая в качестве регрессионных тестов [1] . Когда регрессионный тест становится красным, вы знаете, что у вас возникла немедленная проблема, часто из-за того, что ошибка попала в систему без вашего ведома.

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

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

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

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


Карантин

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

Поместите любой недетерминированный тест в помещенную на карантин область. (Но исправьте карантинные тесты быстро.)

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

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

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


Недостаток изоляции

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

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

Держите свои тесты изолированными друг от друга, чтобы выполнение одного теста не влияло на другие.

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

Начать с пустого состояния обычно легко с модульными тестами, но может быть намного сложнее с функциональными тестами [4] — особенно если у вас есть много данных в базе данных, которые должны быть там. Перестройка базы данных каждый раз может затратить много времени на тестовые прогоны, что требует перехода к стратегии очистки.

Один из приемов, который удобен при использовании баз данных, состоит в том, чтобы проводить тесты внутри транзакции, а затем откатывать транзакцию в конце теста. Таким образом, менеджер транзакций очищает вас, уменьшая вероятность ошибок [5] .

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

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

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

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


Асинхронное поведение

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

Но в тестировании асинхронность может быть проклятием. Распространенная ошибка здесь — бросить во сне:

 //pseudo-code
        makeAsyncCall;
        sleep(aWhile);
        readResponse;

 

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

Никогда не используйте бодрствующий сон для ожидания асинхронных ответов: используйте обратный вызов или опрос.

Есть в основном две тактики, которые вы можете использовать для тестирования асинхронного ответа. Во-первых, асинхронная служба принимает обратный вызов, который может быть вызван по завершении. Это лучшее, так как это означает, что вам никогда не придется ждать дольше, чем нужно [6] . Самая большая проблема в этом заключается в том, что среда должна быть в состоянии сделать это, а затем поставщик услуг должен это сделать. Это одно из преимуществ интеграции команды разработчиков с тестированием — если они могут предоставить обратный вызов, они это сделают.

Второй вариант — опросить ответ. Это больше, чем просто смотреть один раз, но смотреть регулярно, как-то так

 //pseudo-code
        makeAsyncCall
        startTime = Time.now;
        while(! responseReceived) {
          if (Time.now - startTime > waitLimit) 
            throw new TestTimeoutException;
          sleep (pollingInterval);
        }
        readResponse

 

Смысл этого подхода в том, что вы можете установить pollingInterval довольно маленькое значение и знать, что это максимальное количество мертвого времени, которое вы потеряете, ожидая ответа. Это означает, что вы можете установить waitLimit очень высоко, что сводит к минимуму вероятность попадания в него, если что-то серьезное не пошло не так. [7]

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

Значения времени, в частности waitLimit, никогда не должны быть буквальными значениями. Убедитесь, что они всегда являются значениями, которые можно легко установить массово, либо с помощью констант, либо с помощью среды выполнения. Таким образом, если вам нужно настроить их (и вы захотите), вы можете настроить их все быстро.

Весь этот совет удобен для асинхронных вызовов, когда вы ожидаете ответа от поставщика, но как насчет тех, где нет ответа. Это вызовы, когда мы вызываем команду для чего-либо и ожидаем, что это произойдет без какого-либо подтверждения. Это самый сложный случай, поскольку вы можете проверить ожидаемый ответ, но ничего не поделаешь, чтобы обнаружить ошибку, кроме тайм-аута. Если провайдер является чем-то, что вы создаете, вы можете справиться с этим, обеспечив провайдеру какой-либо способ указать, что это сделано — по сути, некоторую форму обратного вызова. Даже если он используется только в тестируемом коде, это того стоит — хотя часто вы обнаружите, что этот вид функциональности полезен и для других целей [8], Если поставщик является чужой работой, вы можете попробовать убедить, но в противном случае может застрять. Хотя это также тот случай, когда стоит использовать Test Doubles для удаленных сервисов (об этом я подробнее расскажу в следующем разделе).

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

Вы также можете полностью обойти асинхронность полностью. Шаблон Humble Object Джерарда Месароса говорит, что всякий раз, когда у вас есть какая-то логика, которая находится в сложной для тестирования среде, вы должны изолировать логику, необходимую для тестирования из этой среды. В этом случае это означает, что большая часть логики, которую вам необходимо протестировать, помещается в место, где вы можете протестировать ее синхронно. Асинхронное поведение должно быть настолько минимальным (скромным), насколько это возможно, чтобы вам не нужно было его много тестировать.


Удаленные Услуги

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

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

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

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

Для написания таких тестовых двойников я большой поклонник Self Initializing Fakes — так как ими очень просто управлять.

Некоторые люди категорически против использования Test Doubles в функциональных тестах, полагая, что вы должны тестировать с реальным соединением, чтобы гарантировать сквозное поведение. Хотя я сочувствую их аргументам, автоматические тесты бесполезны, если они недетерминированы. Поэтому любое преимущество, которое вы получаете, общаясь с реальной системой, перегружено необходимостью искоренения недетерминизма [9] .


Время

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

Здесь важно убедиться, что вы всегда оборачиваете системные часы подпрограммами, которые можно заменить на начальное значение для тестирования. Заготовка часов может быть установлена ​​на определенное время и заморожена в это время, что позволяет вашим тестам полностью контролировать свои движения. Таким образом, вы можете синхронизировать свои тестовые данные со значениями в посеянных часах. [11] [12]

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

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

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

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


Утечки ресурсов

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

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

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

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