RSpec On Rails

Liquid error: undefined method `login' for nil:NilClass : October 29th, 2006

RSpec is mere days away from a new release with greatly improved Rails support. Since people are currently paying me to write Rails code, rather than plain old standalone Ruby (hint hint), I've been waiting for these features before making serious use of RSpec. As an exercise, I 'ported' the acts_as_authenticated controller tests to RSpec. The results were fairly interesting. Subjectively, I find it more readable than the test/unit version. Objectively, one of the test/unit test cases doesn't get run because there are two methods with the same name. In RSpec, you make method calls to define specs, rather than defining methods with specific names. This makes them 'first class' Ruby constructs, immune to this kind of invisible error. Here's the code from acts_as_authenticated: def test_should_fail_cookie_login users(:quentin).remember_me users(:quentin).update_attribute :remember_token_expires_at, 15.minutes.ago.utc @request.cookies["auth_token"] = cookie_for(:quentin) get :index assert !@controller.send(:logged_in?) end def test_should_fail_cookie_login users(:quentin).remember_me @request.cookies["auth_token"] = auth_token('invalid_auth_token') get :index assert !@controller.send(:logged_in?) end Only the second method is actually run, because it redefines the first method. test/unit has no way of knowing this, of course. Here's the RSpec equivalent: specify "should fail to login with expired cookie" do users(:quentin).remember_me users(:quentin).update_attribute :remember_token_expires_at, 15.minutes.ago request.cookies["auth_token"] = cookie_for(:quentin) get :index controller.should_not_be_logged_in end specify "should fail to login with invalid cookie" do users(:quentin).remember_me request.cookies["auth_token"] = auth_token('invalid_auth_token') get :index controller.should_not_be_logged_in end So far, so good. Anyone who thinks that RSpec is 'only' about a different set of terminology should give it a serious try first. Another fun trick is the output of 'rake spec:doc':
The Account controller
- should be an AccountController
- should redirect after successful login
- should not redirect after failed login
- should allow signup
- should require login on signup
- should require password on signup
- should require password confirmation on signup
- should require email on signup
- should log out when requested
- should remember me
- should not remember me
- should delete auth token on logout
- should login with cookie
- should fail to login with expired cookie
- should fail to login with invalid cookie
Just in case anyone is actually interested, here's the full spec:
require File.dirname(__FILE__) + '/../spec_helper'

def login_as(user)
  request.session[:user] = user ? users(user).id : nil
end

def create_user(options = {})
  post :signup, :user => { :login => 'quire', :email => 'quire@example.com', 
    :password => 'quire', :password_confirmation => 'quire' }.merge(options)
end

def auth_token(token)
  CGI::Cookie.new('name' => 'auth_token', 'value' => token)
end

def cookie_for(user)
  auth_token users(user).remember_token
end

context "The Account controller" do
  fixtures :users
  controller_name :account

  specify "should be an AccountController" do
    controller.should_be_an_instance_of AccountController
  end

  specify "should redirect after successful login" do
    post :login, :login => 'quentin', :password => 'test'
    session[:user].should_not_be_nil
    response.should_be_redirect
  end

  specify "should not redirect after failed login" do
    post :login, :login => 'quentin', :password => 'bad password'
    session[:user].should_be_nil
    response.should_be_success
  end

  specify "should allow signup" do
    expected_users = User.count + 1
    create_user
    response.should_be_redirect
    User.count.should == expected_users
  end

  specify "should require login on signup" do
    expected_users = User.count
    create_user(:login => nil)
    assigns(:user).errors.on(:login).should_not_be_nil
    response.should_be_success
  end

  specify "should require password on signup" do
    expected_users = User.count
    create_user(:password => nil)
    assigns(:user).errors.on(:password).should_not_be_nil
    response.should_be_success
  end

  specify "should require password confirmation on signup" do
    expected_users = User.count
    create_user(:password_confirmation => nil)
    assigns(:user).errors.on(:password_confirmation).should_not_be_nil
    response.should_be_success
  end

  specify "should require email on signup" do
    expected_users = User.count
    create_user(:email => nil)
    assigns(:user).errors.on(:email).should_not_be_nil
    response.should_be_success
  end

  specify "should log out when requested" do
    login_as :quentin
    get :logout
    session[:user].should_not_be_nil
    response.should_be_redirect
  end

  specify "should remember me" do
    post :login, :login => 'quentin', :password => 'test', :remember_me => '1'
    response.cookies["auth_token"].should_not_be_nil
  end

  specify "should not remember me" do
    post :login, :login => 'quentin', :password => 'test', :remember_me => '0'
    response.cookies["auth_token"].should_be_nil
  end

  specify "should delete auth token on logout" do
    login_as :quentin
    get :logout
    response.cookies["auth_token"].should == []
  end

  specify "should login with cookie" do
    users(:quentin).remember_me
    request.cookies["auth_token"] = cookie_for(:quentin)
    get :index
    controller.should_be_logged_in
  end

  specify "should fail to login with expired cookie" do
    users(:quentin).remember_me
    users(:quentin).update_attribute :remember_token_expires_at, 15.minutes.ago
    request.cookies["auth_token"] = cookie_for(:quentin)
    get :index
    controller.should_not_be_logged_in
  end

  specify "should fail to login with invalid cookie" do
    users(:quentin).remember_me
    request.cookies["auth_token"] = auth_token('invalid_auth_token')
    get :index
    controller.should_not_be_logged_in
  end
end

14 Responses to “RSpec On Rails”

  1. Jeff Dean Says:

    Thanks for the great write-up. I recently made the switch to rspec and I’ve had a great time porting tests to specs and starting to think in terms of BDD.

    What I’m most excited about is the the ease with which non-coders can start writing specs that I can turn into code (that and the easy integration with RCov).

    I’ve just checked out the project to see if I can help add some support for edge rails and restful routes. Given how easy and intuitive it is I’d love to see this make it’s way towards core.

  2. Luke Redpath Says:

    Nice to see RSpec getting more press, though I do have some problems with that controller spec. One of the points of BDD, which is what RSpec is for, is that you break down your contexts by fixture/state. Not only are controllers way too large in scope to be considered a “unit” in traditional testing terms, but in the case of BDD a single action can often be broken down into several contexts – the context being the request that is sent and the parameters sent with that request.

    One of the nice things about the new RESTful approach being advocated is that it makes breaking your controller specs up into contexts a piece of cake. Consider a sessions controller, used for restful authentication:

    context "Requesting /sessions/create via POST with valid credentials" do
      controller_name :sessions
    end
    def setup
      # setup a user fixture
      @user = User.create(:username => 'joebloggs', :password => 'password')
      post :create, :username => 'joebloggs', :password => 'password'
    end
    specify "should store authenticated user id in session" do
      session[:user_id].should_equal @user.id
    end
    specify "should flash a success message" do
      flash[:notice].should_equal "Login successful" 
    end
    specify "should redirect to home page" do
      response.redirect_url.should_equal home_url
    end

    And for a failed authentication:

    context "Requesting /sessions/create via POST with invalid credentials" 
      controller_name :sessions
    end
    def setup
      post :create, :username => 'nosuchuser', :password => 'foobar'
    end
    specify "should flash a login failed error message" 
      flash[:error].should_equal "Login failed" 
    end
    specify "should re-render login form" 
      response.should_render :new
    end

    This allows for much finer-grained contexts and is more in the spirit of BDD IMO.

  3. Luke Redpath Says:

    Something else I should have mentioned is the emphasis of behaviour (hence behaviour-driven development). We aren’t just interested in testing that we can successfully login with our sessions controller (and therefore have the kind of meaningless test names that are typical with Rails e.g. “test_can_login”), but what actual behaviour is expected when we successfully login, i.e. we flash a success message, we redirect to the home page, we store the user’s id etc…

  4. Defiler Says:

    Luke: I agree with you. My code isn’t meant to be an example of RESTful authentication, etc.. just a way to integrate acts_as_authenticated into an RSpec project.

    Your specs look good, though. Maybe you should release a sessions plugin/generator? :)

  5. rick Says:

    Well, it is a different set of terminology. Though, I can see the value of something like @name.should_equal ‘bob’ vs assert_equal ‘bob’, @name.

  6. Luke Redpath Says:

    Defiler: sorry, I know that your code wasn’t meant to be RESTful, I don’t think I was clear in that respect. I simply took a RESTful sessions controller as an example as it is something I did recently and was the first thing that came to mind. I have been meaning to follow up my original BDD article and have also been meaning to cover controller specs so I might integrate a simple sessions controller into part two. As for a plugin, I’ve not used it but doesn’t Rick’s restful authentication plugin deal with this?

    I do have a simple, crypted authentication plugin that is covered by BDD-style specs (but using test::unit for portability reasons), although that only takes care of the model side of things. You can find that here.

    What I was trying to get at is that its important, when approaching things using BDD, to isolate and assert different behaviour depending on context, and in the case of controllers, that could be many different contexts, I’d say at least 2 per action, sometimes more.

  7. J. W. Says:

    I am still in the dark ages and using test/unit + an agiledox rake script to generate documents.

    I have found it useful to use my tests to define all features, including those which are not implemented but will be in the future. Rather than keep a TODO list, I just create a new test. But the test is named differently…

    def in_the_future_should_foo; end

    The tests ignore it, but the the agiledox script documents it as a future feature. Perhaps others would find this concept useful, or maybe there is something similar in existance?

    With RSpec instead of using `specify` you could have `eventually` or `will` or some other command to define unimplemented features to be documented but not tested.

  8. Carlos Gabaldon Says:

    @Defiler, nice article on RSpec, I have been using RSpec for awhile now for my Rails development and I could not go back to test unit. I do agree with Luke, “The Account controller” is do big of a context, I usually end up with about 4 contexts per action.

    @J.W., I disagree with the approach of defining not implemented features in tests. Your tests should be testing the current behavior of the system. I think if you want to capture all the specs of your application in your tests, then your tests should be failing until those features are implemented, otherwise you are just hiding behavior in your tests.

  9. Shalev Says:

    Luke: I’ve been eagerly awaiting a BDD followup on your site ever since you’ve posted the first article. I think I may just jump in and start using BDD with my next project, but more info as to how you use it with rails would be appreciated.

    In that vein, this post was also extremely useful. Examples are always very informative.

  10. Meekish Says:

    “RSpec is mere days away from a new release with greatly improved Rails support.”

    How are you keeping up with the development of rSpec? Are you just watching the changelogs? I am very interested in BDD and the development of rSpec, but I can’t seem to find very much information on it.

  11. George Says:

    Meekish: are you subscribed to the mailing lists (devel and users)?

  12. Ivan Says:

    Nice

  13. Antonios Says:

    Nice…

  14. Odysseus Says:

    interesting

Sorry, comments are closed for this article.