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

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

Затем, в 1961 году, Советский Союз успешно отправил космонавта Юрия Гагари на первой пилотируемой ракете, сделав его первым человеком в истории, совершившим путешествие в открытый космос.

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

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

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

Зачем тестировать программное обеспечение

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

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

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

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

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

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

Когда модульные тесты объединяются и выполняются с реальными данными между несколькими производственными системами, они становятся интеграционными тестами. Вот где фреймворки Behavior Driven Development (BDD), такие как RSpec, могут быть как исключительно мощными, так и разочаровывающими.

Как и Rails, RSpec в сочетании с такими библиотеками, как factory_bot, DatabaseCleaner, Capybara, Timecop и VCR предоставляет функциональность, которая легко может напоминать Ruby Magic ™. Но как только вы ознакомитесь с этими инструментами, становится возможным тестировать каждую систему вместе и изолированно.

Потратив время на написание (и переписывание) многих тестов RSpec, я хочу указать на тонкие и не очень тонкие проблемы, которые у меня возникают при написании и отладке тестов.

Как тесты RSpec терпят неудачу

Тесты не проходят постоянно или периодически.

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

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

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

Вот почему тесты RSpec терпят неудачу и как их исправить.

Забывчивость

Слишком много заглушек или непреднамеренно заглушка.

Заглушки предоставляют стандартные ответы на звонки, сделанные во время теста, обычно не отвечая ни на что, кроме того, что запрограммировано для теста. - Мартин Фаулер, Моки - это не заглушки.

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

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

Недвижимость 101 говорит нам, что цены на жилье только растут.

Теперь мы можем захотеть проверить это предположение в RSpec:

Когда мы запускаем этот тест, к нашему удивлению, он завершается ошибкой со следующей ошибкой:

Failure/Error: expect(house.price).to be > 649000
       expected: > 649000
       got:        649000

Как оказалось, сопоставитель receive заглушает целевой метод, если мы не используем and_call_original для отмены заглушки. Это не принято appreciate_a_lot, и в результате цена дома остается неизменной.

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

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

TL / DR: помните, заглушка - это поведение по умолчанию для сопоставления receive; используйте and_call_original для отмены заглушки, но сохраните ожидаемые сообщения.

Расхождения в памяти и в базе данных

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

В конце концов, с Rails 4.2 появился GlobalID, универсальный идентификатор ресурса (URI) для всего приложения. В результате функции Rails включают активную поддержку заданий с прямой передачей моделей.

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

Активное задание использует GlobalID::Locator.locate внутри для десериализации модели GlobalID. Затем он выполняет запрос к базе данных с использованием первичного ключа.

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

Первый тест завершается неудачно с такой ошибкой:

Failure/Error: expect(user).to receive(:perform_send_welcome_email!)
     
   (#<User:#..>).perform_send_welcome_email!(*(any args))
           expected: 1 time with any arguments
           received: 0 times with any arguments

Второй тест тоже не проходит, но с другой ошибкой:

Failure/Error: expect(user.welcome_email_sent?).to eq(true)
     
       expected: true
            got: false
     
       (compared using ==)

Оба этих теста терпят неудачу, потому что испытуемый, конкретный User, находится в той же базе данных, но не в памяти. Изменения базы данных не распространяются автоматически на модели в памяти.

Вместо этого в первом тесте можно использовать сопоставление типа expect_any_instance_of(User), в то время как второй тест можно легко выполнить с помощью #reload, который повторно синхронизирует активную запись с базой данных.

TL / DR: помните о несоответствиях в памяти и в базе данных; при необходимости используйте #reload для повторной синхронизации записи с базой данных.

Недетерминизм

Иногда проходит, иногда не получается.

Случайность

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

Эти «случайные» источники затем объединяются в одном месте (например, /dev/random) для приложений, требующих псевдослучайного поведения.

Независимо от того, действительно ли поведение является случайным или псевдослучайным, проверка случайности может быть довольно сложной. Неудивительно, что это также очевидный источник недетерминированного поведения. Рассмотрим Dice класс:

Вызов roll должен возвращать случайное число от одного до шести, как настоящие кости.

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

Мы запускаем наши тесты локально, и они проходят. Они снова переходят в CI. Через несколько дней мы замечаем периодический сбой во втором тесте. Самым простым решением было бы вообще избежать случайности. Когда это невозможно, мы можем вместо этого предоставить «семя» для класса Random.

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

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

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

Уникальные ограничения

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

Вероятность столкновения с ObjectId или аналогичными UUID исключительно низка, но вероятность дублирования с описанием составляет 1: 1000. Когда необходимы случайно сгенерированные свойства, самое простое решение - выбрать из большего пула случайных.

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

TL / DR: избегайте проверки на случайность. Если вы не можете, укажите начальное число и / или используйте больше random random, например SecureRandom.

Сохранение состояния между тестами

В статье Арне Хартерца это хорошо резюмируется: Использование before(:all) в RSpec доставит вам много проблем, если вы не знаете, что делаете.

before(:all) создает данные вне блоков транзакций, сохраняя изменения между тестами.

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

Вот один пример того, как нельзя использовать before(:all):

Если тесты запускаются в том порядке, в котором они определены, второй тест завершится неудачно:

Failure/Error: raise AlreadyVotedError

# ./voter.rb:26:in `vote'

Вот почему RSpec предлагает методы let с отложенной оценкой и let! helper с активной оценкой. Когда использовать RSpec let предлагает более подробную информацию, но вкратце:

Всегда предпочитайте let переменной экземпляра.

TL / DR: всегда используйте let, не используйте before(:all).

Сетевые вызовы

Большинство приложений полагаются на сторонние сервисы, такие как Stripe или Square для обработки платежей, Splunk для регистрации или New Relic для мониторинга.

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

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

# --- Caused by: ---
# Net::HTTPServerException:
#   503 "Service Unavailable"

Вот тут и пригодятся такие инструменты, как Видеомагнитофон!

VCR позволяет разработчикам «записывать и воспроизводить» HTTP-взаимодействия, в том числе созданные сторонними инструментами. Это делает тесты быстрыми, детерминированными, точными и не зависят от доступности сети или внешних сервисов.

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

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

TL / DR: используйте предварительно записанные HTTP-ответы в тестах, чтобы повысить общую скорость, надежность и точность.

Время

Незамерзшее время.

В месяце 28–31 день, 365 дней в году и 2027 дней, когда Никсон был президентом. На самом деле в юлианском году около 365,25 дней (точнее, 365,2422).

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

Некоторые страны, такие как США (и территории), охватывают 11 часовых поясов, в то время как другие, такие как Китай, придерживаются только одного часового пояса (UTC + 8). Затем по какой-то безумной причине два раза в год горстка стран увеличивает или уменьшает свои часы, пытаясь согласовать рабочее время с солнечным светом.

Тесты работают надежно с 01:00 до 23:00.

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

Чтобы сделать эти тесты более надежными, лучше всего стать Повелителем времени… или хотя бы научиться останавливать и перемещать время в RSpec.

Вот пример модели Venue, у которой много Events. Если нам нужен список предстоящих концертов, мы можем сравнить время начала с текущим днем.

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

Этот тест выглядит нормально. Он даже получает время запуска из одного источника, чтобы гарантировать согласованные дельты. Проблема возникает, если этот тест выполняется на границе дня, например, незадолго до полуночи (23:59:59).

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

Самый простой способ решить эту проблему с помощью Timecop - заморозить время before :each и вернуть after :each. Таким образом, время проходит между, но не во время тестов.

TL / DR: используйте Timecop для предсказуемого замораживания и перемещения времени в RSpec; проверяйте временные границы и крайние случаи в целях защиты.

Побочные эффекты

Обратные вызовы активных записей

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

Вот тут-то и пригодится skip_callback. Его можно использовать как в заводских спецификациях, так и в индивидуальных тестах. Рассмотрим модель User:

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

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

Может оказаться, что для того, чтобы Address считался жилым, требуется модель User.

Однако создание и связывание User с Address внутри блока perform_enqueued_jobs запускает обратный вызов после создания и, в конечном итоге, задание SendWelcomeEmail.

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

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

TL / DR: skip_callback можно избежать обратных вызовов активных записей, но используйте их с осторожностью.

Активные адаптеры очереди заданий

Распространенный вариант использования обратных вызовов активных записей - постановка активного задания в очередь. Под капотом активное задание настраивается на использование определенного QueueAdapter. Этот адаптер определяет порядок очереди (например, FIFO, LIFO и т. Д.).

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

Однако TestAdapter по умолчанию не выполняет эту работу!

В зависимости от того, что вы тестируете, существуют другие адаптеры, такие как InlineAdapter, которые немедленно выполняют задания, обрабатывая perform_later вызовы как perform_now.

В качестве альтернативы TestAdapter имеет метод perform_enqueued_jobs, который, как следует из названия, фактически выполняет поставленные в очередь задания синхронно.

Как и в случае с обратными вызовами, есть смысл в тестировании как с фактическим выполнением задания, так и без него. RSpec предоставляет полезные активные сопоставители заданий, такие как сопоставление have_enqueued_job.

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

TL / DR: используйте TestAdapter для отслеживания и выполнения активных заданий в очереди.

Слишком конкретный

Непоследовательно упорядоченные данные.

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

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

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

Помощник create_list factory_bot создает три игрушки, связанные с нашей кошкой, Лови.

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

Для последовательных идентификаторов это не должно создавать проблемы, поскольку порядок создания или последовательности должен быть идентичным. Однако первые четыре цифры ObjectID представляют секунды с эпохи Unix.

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

TL / DR: используйте hash_including, include и match_array при сравнении коллекций независимо от порядка.

Отрицательные ожидания тестирования

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

Тест на price_per_sq_ft может выглядеть примерно так:

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

Предупреждение:

Использование expect { }.not_to raise_error(SpecificErrorClass) сопряжено с риском ложных срабатываний, так как буквально любая другая ошибка может привести к выполнению ожидания, включая те, которые были вызваны Ruby (например, NoMethodError, NameError и ArgumentError), а это означает, что код, который вы собираетесь протестировать, может даже не быть достигнут.

Вместо этого рассмотрите возможность использования expect { }.not_to raise_error` or `expect { }.to raise_error(DifferentSpecificErrorClass).

Предупреждение RSpec четко объясняет проблему с этим тестом.

В приведенном выше примере второй тест действительно вызывает TypeError: nil can’t be coerced into Fixnum, поскольку нигде мы не определили @sq_ft! Это проблема слишком специфичных отрицательных тестов, они могут пропустить настоящие проблемы, подобные этой.

TL / DR: отдавайте предпочтение положительным тестам перед отрицательными; писать отрицательные тесты более широко, особенно когда дело касается обработки ошибок.

Ресурсы

Есть много отличных статей о тестировании с Ruby, Rails, active record, RSpec, factory_bot и автоматическом тестировании в целом.

Вот лишь некоторые из них, которые я нашел особенно информативными.

Последние мысли

Горечь низкого качества сохраняется еще долго после того, как сладость соблюдения графика была забыта.

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

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

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

В конце концов, именно для этого и предназначены тесты: выявлять ошибки ранее в SDLC. Лучше (и дешевле) вылавливать ошибки во время тестирования, чем в продакшене.

Удачного устранения ошибок!