Rails 3.0.9 + Devise + Cucumber + Capybara the inf

2020-05-20 03:00发布

问题:

I am using devise 1.4.2 with rails 3.0.9, cucumber-rails 1.0.2, capybara 1.0.0. I got No route matches "/users/sign_out" error when I clicked logout. I added :method => :delete to link_to tag after going through this so question ( no-route-matches-users-sign-out-devise-rails-3 ).

Since I replaced prototype with jquery, I also had to change

config.action_view.javascript_expansions[:defaults] = %w(jquery rails)

to

config.action_view.javascript_expansions[:defaults] = %w(jquery jquery_ujs)

to get around rails.js not found error.

Although with above changes I am able to successfully sign out and redirected to root, when I look at response of localhost:3000/users/sign_out request in FireBug it shows the same routing error message click here to see the screenshot with notes

After successfully implementing authentication to rails 3 app through devise, When I added feature and specs using Cucumber + Capybara + RSpec following this tutorial (github.com/RailsApps/rails3-devise-rspec-cucumber/wiki/Tutorial), I got following error

When I sign in as "user@test.com/please"                              # features/step_definitions/user_steps.rb:41
Then I should be signed in                                            # features/step_definitions/user_steps.rb:49
And I sign out                                                        # features/step_definitions/user_steps.rb:53
  No route matches "/users/sign_out" (ActionController::RoutingError)
  <internal:prelude>:10:in `synchronize'
  ./features/step_definitions/user_steps.rb:55:in `/^I sign out$/'
  features/users/sign_out.feature:10:in `And I sign out'
And I should see "Signed out"                                         # features/step_definitions/web_steps.rb:105
When I return next time                                               # features/step_definitions/user_steps.rb:60
Then I should be signed out  

with the following step_definition for 'I sign out'

Then /^I sign out$/ do
    visit('/users/sign_out')
end

I searched a lot and found that this is because of unobrusive javascript in Rails 3 being used for 'data-method' attributes, but I also read somewhere that Capybara does check for data-method attributes and behaves accordingly. But it did not work for me, so following this post Capybara attack: rack-test, lost sessions and http request methods I changed my step definition to following:

Then /^I sign out$/ do
    rack_test_session_wrapper = Capybara.current_session.driver
    rack_test_session_wrapper.process :delete, '/users/sign_out'
end

but I got undefined method process for Capybara::RackTest::Driver (NoMethodError).

Following this lead I changed the above step definition as following:

Then /^I sign out$/ do
    rack_test_session_wrapper = Capybara.current_session.driver
    rack_test_session_wrapper.delete '/users/sign_out'
end

This at least passed the 'I sign out' step, but it did not redirected to the home page after signing out and the next step failed:

And I should see "Signed out"                                         # features/step_definitions/web_steps.rb:105
  expected there to be content "Signed out" in "YasPiktochart\n\n  \n      Signed in as user@test.com. Not you?\n      Logout\n  \n\n    Signed in successfully.\n\n  Home\n  User: user@test.com\n\n\n\n" (RSpec::Expectations::ExpectationNotMetError)
  ./features/step_definitions/web_steps.rb:107:in `/^(?:|I )should see "([^"]*)"$/'
  features/users/sign_out.feature:11:in `And I should see "Signed out"'

After all this I had to resort to adding 'GET' method for logout in the routes file:

devise_for :users do get 'logout' => 'devise/sessions#destroy' end

modified my view from

<%= link_to "Logout", destroy_user_session_path, :method => :delete %>

to

<%= link_to "Logout", logout_path %>

and changed my step definition to following:

Then /^I sign out$/ do
    visit('/logout')
end

This obviously solved all the problems, all the tests passed and firebug did not show any error on sign_out. But I know that using 'get' request for destroying sessions is not a good practice, because it's a state-changing behavior.

Could this be due to particular version or Rails, Devise, Cucumber-Rails, or Capybara I am using? I want to use Devise's default sign_out route instead of overriding it with get method and be able to do BDD using Cucumber and RSpec. I am new to using Cucumber+Capybara, does there exists another method to send POST request instead of using "visit('/users/sign_out')", which only uses GET method?

回答1:

So I have found that

<%= link_to "Logout", destroy_user_session_path, :method => :delete %>

rails helper generates following html

<a rel="nofollow" data-method="delete" href="/users/sign_out">Sign out</a>

and jquery_ujs.js has following method to convert the links with data-method="delete" attribute to a form and submit at runtime:

// Handles "data-method" on links such as:
// <a href="/users/5" data-method="delete" rel="nofollow" data-confirm="Are you sure?">Delete</a>
handleMethod: function(link) {
var href = link.attr('href'),
method = link.data('method'),
csrf_token = $('meta[name=csrf-token]').attr('content'),
csrf_param = $('meta[name=csrf-param]').attr('content'),
form = $('<form method="post" action="' + href + '"></form>'),
metadata_input = '<input name="_method" value="' + method + '" type="hidden" />';
if (csrf_param !== undefined && csrf_token !== undefined) {
metadata_input += '<input name="' + csrf_param + '" value="' + csrf_token + '" type="hidden" />';
}
form.hide().append(metadata_input).appendTo('body');
form.submit();
}

And Capybara helper visit('/users/sign_out') simply clicks the link and send a GET request to the server which does not have any route for this request.

As opposed to link_to helper the button_to helper adds the required form within the html when page is rendered instead of relying on javascript:

<%= button_to "Logout", destroy_user_session_path, :method => :delete %>

generates following html

<form class="button_to" action="/users/sign_out" method="post">
    <div>
        <input type="hidden" name="_method" value="delete">
        <input type="submit" value="Logout">
        <input type="hidden" name="authenticity_token" value="0Il8D+7hRcWYfl7A1MjNPenDixLYZUkMBL4OOoryeJs=">
    </div>
</form>

with this I can easily use Capybara helper click_button('Logout') in my 'I sign out' step definition.

"link_to with a method anything other than GET is actually a bad idea, as links can be right clicked and opened in a new tab/window, and because this just copies the url (and not the method) it will break for non-get links..."

As Max Will explained right clicking and opening the link_to link with non-get data-method in new tab results in a broken link.

Some more useful discussion on link_to helper with ':method => :delete' and capybara issue can be found on this link

For now I would stick to simple link_to helper without :method attribute, and would prefer using button_to if I want to switch to non-get method for deleting.

At the same time I think there should be a capybara helper equivalent to Visit to cater for data-method attribute to send post request, so that one could avoid using javascript based driver for integration testing. Or may be there already is one which I am not aware of. Correct me if I am wrong.



回答2:

The easiest way to correct this problem (albeit probably not the most correct one) is to modify your routes file to match the rest of the application. E.g. make the GET version of destroy_user_session_path work. You can do this by modifying the routes file as follows

Remove:

devise_for :users

Add:

devise_for :users do
  get "/users/sign_out" => "devise/sessions#destroy", :as => :destroy_user_session
end

This is a bit dirty. I'm sure that Devise deprecated the GET route for good reason. However, fixing it any other way is beyond my Cucumber knowledge at this point, as every test in that suite ultimately relies on visit('/users/logout') which just isn't possible with the out-of-the-box Devise routes.

UPDATE

You can also fix this by commenting out the following in config/initialers/devise.rb

#config.sign_out_via = :delete


回答3:

Devise 1.4.1 (27 June 2011) changed the default behavior for sign out requests:

https://github.com/plataformatec/devise/commit/adb127bb3e3b334cba903db2c21710e8c41c2b40

Jose Valim explained why: "GET requests should not change the state of the server. When sign out is a GET request, CSRF can be used to sign you out automatically and things that preload links can eventually sign you out by mistake as well."

Cucumber wants to test GET requests not DELETE requests for destroy_user_session_path. If you intend to use Cucumber with Devise, change the Devise default from DELETE to GET for the Rails test environment only with this change to config/initializers/devise.rb:

config.sign_out_via = Rails.env.test? ? :get : :delete

Don't try to tweak the routes.rb file to make the fix. It isn't necessary. If you're not going to use Cucumber, leave Devise's new default (DELETE) in place.

The example source code here:

https://github.com/RailsApps/rails3-devise-rspec-cucumber

now includes the change to the Devise initializer for Cucumber.

The application template here:

https://github.com/RailsApps/rails3-application-templates

now detects the collision between Devise and Cucumber and alters the Devise initializer as needed.

These changes were tested with with Rails 3.1.0.rc4 but the behavior should be the same with Rails 3.0.9. Please add comments here if the issue is unresolved or if you have more information.



回答4:

The right way to solve this problem is explained in the devise's wiki page: https://github.com/plataformatec/devise/wiki/How-To:-Test-with-Capybara

Basically, once you have included on your user_step.rb file:

include Warden::Test::Helpers
Warden.test_mode!

You may replace visit '/users/sign_out' with logout(:user)



回答5:

I'm actually having the same exact problem but with a Rails/Sinatra app. I've got Devise set up for Rails and the logout works. I've got a GuestApp Sinatra app running in lib, which works great except for the logout link. I'm trying to force data-method="delete" on the sinatra logout link, but nothing i do will make the request a delete request.

I think this might me a sinatra problem for me, but I thought what ever requests come in are processed by rails routes first until they reach my sinatra route. I'm about to manually add the GET route for logout, but I'd rather not have to do that.

Here's my devise routes:

devise_for :member, :path => '', :path_names => {
  :sign_in => "login",
  :sign_out => "logout",
  :sign_up => "register" }

Here's my link:

%a{:href => '/logout', :"data-method" => 'delete', :rel => 'nofollow'}Log Out
<a href="/logout" data-method="delete" rel="nofollow">Log Out</a>

#- realized it should be method instead, but still not reaching routes.rb as delete
<a href="/logout" method="delete" rel="nofollow">Log Out</a>


回答6:

When i need use something like this in test.env:

  visit destroy_user_session_path

it's work for me, but maybe this is not right)

config/init/devise.rb

  # The default HTTP method used to sign out a resource. Default is :delete.
  if Rails.env.test?
    config.sign_out_via = :get
  else
    config.sign_out_via = :delete
  end