Pull to refresh

Автоматизированное тестирование контроллеров в Rails

Reading time 5 min
Views 8.1K
Привет, Хабр! Давно манят меня лавры быть автором, и вот, наконец, настал тот светлый час, когда я допинал себя представить на твой суд мой небольшой опус.

Изучая на досуге Ruby и Rails, пробуя то RSpec, то вдруг Minitest, дошёл я до создания web-приложения с фронтэндом на JavaScript и бэкендом на Ruby, торчащим наружу REST API на базе обычных контроллеров Rails. Я использую Rails, хотя это совершенно не принципиально. Описанный ниже подход применить можно к чему угодно.

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

Казалось бы, всё очень просто: берём RSpec и пишем тесты. Но это же скучно! Для каждого контроллера, для каждого поддерживаемого метода проверить, как минимум, что без выданного ранее токена пользователь получит от ворот поворот — это ж сколько одинакового кода надо написать! А дальше как? Контроллеров всё больше, тесты копировать скучно, да и в возможностях подходы менять я остаюсь ограничен. Пойди-ка все эти тесты потом перепиши, если я захочу, например, версию API передавать не в URL, а в заголовке, или наоборот. В общем, задумал я написать генератор.

Постановка задачи


Для каждого из уже имеющихся контроллеров было у меня два тест-кейса: проверка на то, что при попытке доступа без access token приложение возвращает код 401, а с несуществующим access token — код 403. При соблюдении этих правил остаётся убедиться только в том, что при правильном access token приложение отдаёт данные владельца этого токена и никакие другие, но это уже за рамками данной статьи. То есть, было что-то такое:

describe Api::V2::UserSessionsController do
  let (:access_token) {SecureRandom.hex(64)}

  describe 'DELETE /user-sessions/:id' do
    context 'without an access token' do
      before { delete :destroy, id: access_token }

      it 'responds with 401' do
        expect(response).to have_http_status :unauthorized
      end
    end

    context 'with non-existent access token' do
      before {@request.headers['X-API-Token'] = SecureRandom.hex(64)}
      before {delete :destroy, id: access_token}

      it 'responds with 403' do
        expect(response).to have_http_status :forbidden
      end
    end
  end
end

Ну и желание больше двух раз такое не писать.

Что делать?


Ruby — язык с широкими возможностями метапрограммирования. Благодаря им, в частности, существует и RSpec DSL, использование которого демонстрируется в примере выше. А что такое RSpec DSL? Сахарок для написания классов, которые библиотека потом просто запускает. А значит, можно их сгенерировать самому! К счастью, при наличии всего лишь одного базового класса для всех контроллеров решение этой задачи — уже дело техники. Немного помучив Google, StackOverflow и собственную голову (не всё же ей задачи ставить — решать их тоже надо), пришёл я вот к такому фрагменту кода:

describe Api::V2::ControllerBase do
  Api::V2::ControllerBase.descendants.each do |c|
    describe "#{c.name}" do
      Rails.application.routes.set.each do |route|
        if route.defaults[:controller] == c.controller_path
          action = route.defaults[:action]
          request_method = /[A-Z]+/.match(route.constraints[:request_method].to_s)[0]
          param_placeholders = Hash[route.parts.reject { |p| p == :format }.map { |p| [p, ":#{p}"] }]
          spec_name = "#{request_method} #{route.format(param_placeholders)}"

          describe "#{spec_name}" do
            before { self.controller = c.new }

            context 'without an access token' do
              before { process(action, request_method, param_placeholders) }

              it 'responds with 401' do
                expect(response).to have_http_status :unauthorized
              end
            end

            context 'with non-existent access token' do
              before { @request.headers['X-API-Token'] = SecureRandom.hex(64) }
              before { process(action, request_method, param_placeholders) }

              it 'responds with 403' do
                expect(response).to have_http_status :forbidden
              end
            end
          end
        end
      end
    end
  end
end

Спешу, однако, обрадовать, что этот код не работает.

Как так?


Очень просто. Всё дело в ленивой загрузке. Rails не сорит в памяти тем, что, быть может, и не понадобится. Поэтому массив descendants у Api::V2::ControllerBase пуст. К счастью, это легко лечится:

Rails.application.eager_load!

Вставленная после самого первого describe эта магическая строчка переворачивает ситуацию с ног на голову:

image

Да простят меня любители vim и консоли за использование RubyMine.

Написал и забыл


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

Есть методы, под общее правило не подпадающие. C'est la vie. В моём случае, например, это POST /api/user-sessions/, потому что глупо требовать правильный access token от метода, который за ним обращается. Не долго думая, я добавил вот это:

  def self.excluded_actions
    {
        Api::V2::UserSessionsController => [:create],
        Api::V2::UserCalendarsController => [:oauth2callback_no_ajax]
    }
  end

и это:
next if excluded_actions.key?(c) && excluded_actions[c].include?(action.to_sym)

в код своего мета-теста. Всё сразу позеленело.

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

Заключение


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

Спасибо за внимание.

P.S. Финальный код решения я спрятал под спойлер.

Финальный код мета-теста
describe Api::V2::ControllerBase do

  Rails.application.eager_load!

  def self.excluded_actions
    {
        Api::V2::UserSessionsController => [:create],
        Api::V2::UserCalendarsController => [:oauth2callback_no_ajax]
    }
  end

  Api::V2::ControllerBase.descendants.each do |c|
    describe "#{c.name}" do
      Rails.application.routes.set.each do |route|
        if route.defaults[:controller] == c.controller_path
          action = route.defaults[:action]
          next if excluded_actions.key?(c) && excluded_actions[c].include?(action.to_sym)

          request_method = /[A-Z]+/.match(route.constraints[:request_method].to_s)[0]
          param_placeholders = Hash[route.parts.reject { |p| p == :format }.map { |p| [p, ":#{p}"] }]
          spec_name = "#{request_method} #{route.format(param_placeholders)}"

          describe "#{spec_name}" do
            before { self.controller = c.new }

            context 'without an access token' do
              before do
                process(action, request_method, param_placeholders)
              end

              it 'responds with 401' do
                expect(response).to have_http_status :unauthorized
              end
            end

            context 'with non-existent access token' do
              before {@request.headers['X-API-Token'] = SecureRandom.hex(64)}
              before { process(action, request_method, param_placeholders) }

              it 'responds with 403' do
                expect(response).to have_http_status :forbidden
              end
            end
          end
        end
      end
    end
  end
end


P.P.S. Спасибо Elisabeth Hendrickson за идею и пример.
Tags:
Hubs:
+6
Comments 12
Comments Comments 12

Articles