Ruby on Rails

Теория: Тестирование

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

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

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

Какие виды тестирования существуют

Перед написанием тестов важно определиться с подходом к ним — как, что и зачем мы тестируем. Rails дает нам возможность тестировать приложения как мы хотим, но предлагает стандартные виды тестов. Они лежат внутри директории test. Примеры:

  • controllers — тесты контроллеров. Сфокусированы на тестировании HTTP-запросов и ответов. Мы отправляем запрос к контроллеру и проверяем, совпадает ли ответ с нашими ожиданиями. В этих тестах не используется браузер, но мы все равно можем проверять, есть ли на странице нужный нам HTML
  • integration — более объемные тесты контроллеров. Вместо конкретного контроллера мы тестируем целые сценарии, в которых пользователь делает запросы к разным путям в приложении
  • system — тесты с использованием браузера. Эмулируют пользователя, дают тестировать взаимодействие пользователя со страницей и делать скриншоты
  • models — тесты бизнес-логики в моделях. Быстрые и легковесные тесты, помогают разгрузить тесты контроллеров

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

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

Как тестировать контроллеры

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

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

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

Посмотрим, как работает самый простой тест контроллера. Сгенерируем PresentationsController:

$ bin/rails generate controller PresentationsController create app/controllers/presentations_controller.rb invoke erb create app/views/presentations invoke test_unit create test/controllers/presentations_controller_test.rb invoke helper create app/helpers/presentations_helper.rb invoke test_unit

Мы видим, что генератор сразу создал файл для тестов контроллера: test/controllers/presentations_controller_test.rb. Этот файл уже подключает все необходимое для тестов. Нам остается только написать наш тест. Сделаем это: проверим, что на странице есть заголовок <h1>Collection of my presentations</h1>:

# test/controllers/presentations_controller_test.rb
require "test_helper"

class PresentationsControllerTest < ActionDispatch::IntegrationTest
  test '/presentations contains heading saying Collection of my presentations' do # Описываем тест-кейс
    get '/presentations' # Делаем GET-запрос к /presentations
    assert_response :success # Проверяем, что запрос успешный
    assert_select 'h1', 'Collection of my presentations' # Проверяем, что на странице есть <h1> с нужным текстом
  end
end

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

Сейчас мы запустим тест и увидим ошибку:

$ bin/rails test Running 1 tests in a single process (parallelization threshold is 50) Run options: --seed 53024 # Running: E Error: PresentationsControllerTest#test_/presentations_contains_Collection_of_my_presentations_heading: ActionController::RoutingError: No route matches [GET] "/presentations" test/controllers/presentations_controller_test.rb:5:in `block in <class:PresentationsControllerTest>' rails test test/controllers/presentations_controller_test.rb:4 Finished in 0.260770s, 3.8348 runs/s, 0.0000 assertions/s.

В ошибке написано, что нет обработчика для [GET] "/presentations". Это правильно — мы на самом деле не сделали обработчик. Чтобы тест прошел, мы должны создать экшен контроллера и добавить роут:

# config/routes.rb
Rails.application.routes.draw do
  # ...
  get '/presentations', to: 'presentations#index'
end

После этого добавим шаблон с текстом, который мы описали в тесте:

<%# app/views/presentations/index.html.erb %>
<h1>Collection of my presentations</h1>

Убедимся, что все наш код соответствует тому, что мы хотели. Для этого запустим тест:

bin/rails test # Running: . Finished in 0.716726s, 1.3952 runs/s, 2.7905 assertions/s. 1 runs, 2 assertions, 0 failures, 0 errors, 0 skips

После тестирования в консоли будет статистика по тестам: сколько файлов, сколько в них суммарно проверок и сколько из них не прошло.

Наш тест прошел успешно, в нем было две проверки.

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

Как добавить тестовые данные

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

В Rails эти данные описываются в фикстурах — тестовых данных, которые мы заполняем для нужных нам юзкейсов. Файлы фикстур генерируются вместе с моделями и загружаются в базу перед запуском тестов.

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

Расширим наш контроллер и добавим туда счетчик презентаций. Сначала напишем тест: проверим, что на странице есть счетчик внутри <p>:

# test/controllers/presentations_controller_test.rb
require 'test_helper'

class PresentationsControllerTest < ActionDispatch::IntegrationTest
  # ...

  test 'presentation index page contains counter' do
    get '/presentations'

    assert_select 'p', /Presentation count: \d+/
  end
end

Запускаем — тест не проходит. В консоли видим ошибку — такого текста нет на странице:

$ bin/rails test Running 2 tests in a single process (parallelization threshold is 50) Run options: --seed 36864 # Running: F Failure: PresentationsControllerTest#test_presentation_index_page_contains_counter [/Users/moroz/projects/hexlet/proving_ground/SimpleBlog/test/controllers/presentation_controller_test.rb:13]: Expected at least 1 element matching "p", found 0.. Expected 0 to be >= 1. rails test test/controllers/presentation_controller_test.rb:10 . Finished in 0.243858s, 8.2015 runs/s, 12.3022 assertions/s. 2 runs, 3 assertions, 1 failures, 0 errors, 0 skips

Исправим и реализуем нужную функциональность. Сгенерируем модель Presentation с полями title и url и сразу применим миграции:

$ bin/rails generate model Presentation title:text url:text invoke active_record create db/migrate/20230626014824_create_presentations.rb create app/models/presentation.rb invoke test_unit create test/models/presentation_test.rb create test/fixtures/presentations.yml $ bin/rails db:migrate == 20230626014824 CreatePresentations: migrating ============================== -- create_table(:presentations) -> 0.0034s == 20230626014824 CreatePresentations: migrated (0.0035s) =====================

Мы видим, что генератор создал файл фикстур: test/fixtures/presentations.yml. Посмотрим на него — уже сразу сгенерировано две фикстуры — one и two:

# test/fixtures/presentations.yml
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html

one:
  title: MyText
  url: MyText

two:
  title: MyText
  url: MyText

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

# test/fixtures/presentations.yml
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html

railway_oriented_programming:
  title: Railway Oriented Programming
  url: https://www.slideshare.net/ScottWlaschin/railway-oriented-programming

Добавим счетчик в контроллер:

# app/controllers/presentations_controller.rb

class PresentationsController < ApplicationController
  def index
    @presentation_count = Presentation.count
  end
end

Выведем счетчик в шаблоне:

<%# app/views/presentations/index.html.erb %>
<h1>Collection of my presentations</h1>

<p>Presentation count: <%= @presentation_count %></p>

Запустим наш тест и убедимся, что он проходит:

$ bin/rails test # Running: .. Finished in 0.277942s, 7.1957 runs/s, 10.7936 assertions/s. 2 runs, 3 assertions, 0 failures, 0 errors, 0 skips

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

Как разобраться, почему тест не проходит

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

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

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

# app/controllers/users_controller.rb

class UsersController < ApplicationController
  def index
    @user_count = User.count
    binding.irb
  end
end

После этого запустим тесты через bin/rails test. Как только binding.irb сработает, мы окажемся в интерактивной консоли. Она напишет, в какой части кода мы сейчас находимся:

$ bin/rails test Running 2 tests in a single process (parallelization threshold is 50) Run options: --seed 43702 # Running: From: /Users/moroz/projects/hexlet/proving_ground/SimpleBlog/app/controllers/presentations_controller.rb @ line 4 : 1: class PresentationsController < ApplicationController 2: def index 3: @presentation_count = Presentation.count => 4: binding.irb 5: end 6: end

Здесь нам доступны все переменные, которые объявили выше по коду, например, @presentation_count. Также можно посмотреть на параметры запроса params или узнать информацию о запросе в request:

irb(#<PresentationsController:0x0...):001:0> params => #<ActionController::Parameters {"controller"=>"presentations", "action"=>"index"} permitted: false> irb(#<PresentationsController:0x0...):002:0> request => #<ActionDispatch::Request GET "http://www.example.com/presentations" for 127.0.0.1> irb(#<PresentationsController:0x0...):003:0> @presentation_count => 1 irb(#<PresentationsController:0x0...):004:0> Presentation.all => [#<Presentation:0x000000010bf21ff0 id: 446425978, title: "Railway Oriented Programming", url: "https://www.slideshare.net/ScottWlaschin/railway-oriented-programming", created_at: Mon, 26 Jun 2023 01:53:55.189175000 UTC +00:00, updated_at: Mon, 26 Jun 2023 01:53:55.189175000 UTC +00:00>]

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

Чтобы закрыть REPL, введем exit или нажмем Ctrl+D. Тесты продолжат выполнение и снова откроют REPL:

From: /Users/moroz/projects/hexlet/proving_ground/SimpleBlog/app/controllers/presentations_controller.rb @ line 4 : 1: class PresentationsController < ApplicationController 2: def index 3: @presentation_count = Presentation.count => 4: binding.irb 5: end 6: end irb(#<PresentationsController:0x0...):001:0>

Это происходит, потому что REPL открывается каждый раз, когда выполняется binding.irb. У нас два теста шлют запросы к /presentations, поэтому код срабатывает дважды — по одному разу на тест. Снова выйдем из REPL, введя exit:

asciicast

Теперь посмотрим, как тестировать без HTTP — потестируем модели.

Как протестировать логику в моделях

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

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

Тесты моделей генерируются вместе с моделью и лежат в test/models. Добавим валидацию в нашу модель пользователя — проверим, что нельзя создать презентацию без названия и ссылки. Начнем с теста. Проверим, что модель с пустым названием невалидна, а также убедимся, что при сохранении будет ошибка:

# test/models/presentation_test.rb
require "test_helper"

class PresentationTest < ActiveSupport::TestCase
  test 'can not create presentation without title and url' do
    presentation = Presentation.new(title: '', url: '')

    assert_not_predicate presentation, :valid?

    assert_raises(ActiveRecord::RecordInvalid) { presentation.save! }
  end
end

Запустим тест модели. Чтобы не запускать все тесты проекта на каждое изменение, передадим путь к нашему тесту в bin/rails test:

$ bin/rails test test/models/presentation_test.rb Running 1 tests in a single process (parallelization threshold is 50) Run options: --seed 45755 # Running: F Failure: PresentationTest#test_can_not_create_presentation_without_title_and_url [/Users/moroz/projects/hexlet/proving_ground/SimpleBlog/test/models/presentation_test.rb:7]: Expected #<Presentation id: nil, title: "", url: "", created_at: nil, updated_at: nil> to not be valid?. rails test test/models/presentation_test.rb:4 Finished in 0.028449s, 35.1506 runs/s, 35.1506 assertions/s. 1 runs, 1 assertions, 1 failures, 0 errors, 0 skips

Мы видим, что сообщение об ошибке дает больше информации — описывает данные, которые не прошли проверку. Это помогает понять причину провала.

Реализуем функциональность, описанную в тесте, — добавим валидации:

# app/models/presentation.rb
class Presentation < ApplicationRecord
  validates :title, presence: true
  validates :url, presence: true
end

Теперь запуск теста будет успешным:

# bin/rails test test/models/presentation_test.rb Running 1 tests in a single process (parallelization threshold is 50) Run options: --seed 64379 # Running: . Finished in 0.042923s, 23.2975 runs/s, 46.5951 assertions/s. 1 runs, 2 assertions, 0 failures, 0 errors, 0 skips

Выводы

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

  • Тесты контроллеров проверяют, как приложение обрабатывает HTTP запрос — какой статус, ответ и заголовки возвращаются в ответ на запрос
  • Тесты моделей подходят для логики, которая не зависит от конкретного контроллера и хранится в модели
  • Если для работы тестов нам нужны данные в базе данных — добавляем их в фикстуры
  • Фикстуры в Rails транзакционные — мы можем безопасно редактировать и удалять их из базы, а после выполнения тест-кейса база вернется в начальное состояние
  • Если непонятно, почему тест не работает, добавляем в код binding.irb, чтобы запустить REPL

Завершено

0 / 8

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845