📜 ⬆️ ⬇️

Automated controller testing in Rails

Hi, Habr! It has long attracted me to the laurels of being an author, and then, finally, that bright hour came when I finished showing myself to present my small opus to your judgment.

Studying at leisure Ruby and Rails, trying RSpec, then suddenly Minitest, I got to create a web application with a JavaScript front end and a Ruby backend, sticking out a REST API based on regular Rails controllers. I use Rails, although this is completely irrelevant. The approach described below can be applied to anything.

There should be a small digression. By nature I am a demanding person, and in every way I struggle for proven code stability (in words, for sure). And when it comes to the safety of users in my application, without tests, at least showing that anyone who does not receive my data, I feel completely uncomfortable. I begin to be sad and generally do not write any code. Even if I'm the one and only user.
')
It would seem that everything is very simple: we take RSpec and write tests. But it's boring! For each controller, for each supported method, check, at a minimum, that without the previously issued token, the user will receive a turn from the gate - this is how many identical codes must be written! And then how? There are more and more controllers, copying tests are boring, and in the possibilities of changing approaches, I remain limited. Go ahead and rewrite all these tests if I want, for example, to transfer the API version not to the URL, but to the title, or vice versa. In general, I decided to write a generator.

Formulation of the problem


For each of the existing controllers, I had two test cases: checking that the application returns code 401 when trying to access without access token, and with non-existing access token, code 403. It is necessary to make sure that with proper access token, the application gives the data of the owner of this token and no others, but this is beyond the scope of this article. That is, there was something like this:

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 

Well, the desire more than two times is not to write.

What to do?


Ruby is a language with extensive metaprogramming capabilities. Thanks to them, in particular, there is RSpec DSL, the use of which is demonstrated in the example above. What is RSpec DSL? Sugar for writing classes, which the library then simply starts. So, you can generate them yourself! Fortunately, with only one base class for all controllers, solving this problem is a matter of technology. Having tortured Google a little bit, StackOverflow and my own head (I don’t have to set tasks for her, I’ll have to solve them too), I came to this code snippet:

 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 = /[AZ]+/.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 

I hasten, however, to rejoice that this code does not work.

How so?


Very simple. It's all about lazy loading. Rails doesn’t waste memory in that it may not be necessary. Therefore, the descendants array for Api :: V2 :: ControllerBase is empty. Fortunately, it is easily treated:

 Rails.application.eager_load! 

Inserted after the very first describe, this magic line turns the situation upside down:

image

Forgive me, vim and console lovers for using RubyMine.

Wrote and forgot


The original idea was to quietly create and not worry about the very basics of security. I add a controller, and the number of green tests magically increases. But, as the picture above shows, they are not always immediately green.

There are methods that do not fall under the general rule. C'est la vie. In my case, for example, this is POST / api / user-sessions /, because it is foolish to require the correct access token from the method that is following it. Without hesitation, I added this:

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

and this:
 next if excluded_actions.key?(c) && excluded_actions[c].include?(action.to_sym) 

in the code of your meta test. Everything turned green at once.

True, absolutely forget about it now will not work.

Conclusion


Ruby is great for its metaprogramming capabilities, RSpec is great for its intelligence (I doubted that it would be so easy for me to generate and immediately run the generated test cases), and Rails are great, by definition. With proper skill, the automatic generation of test cases can be used to solve various tasks without depriving, at the same time, the readability of the test code. I am sure that this decision will be useful to someone.

Thanks for attention.

PS I have hidden the final decision code under the spoiler.

The final code of the meta test
 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 = /[AZ]+/.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 


PPS Thanks to Elisabeth Hendrickson for the idea and example .

Source: https://habr.com/ru/post/271427/


All Articles