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

от автора

Привет, Хабр! Давно манят меня лавры быть автором, и вот, наконец, настал тот светлый час, когда я допинал себя представить на твой суд мой небольшой опус.
Изучая на досуге 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 за идею и пример.

ссылка на оригинал статьи http://habrahabr.ru/post/271427/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *