• No results found

Testing Controllers

Controllers direct the show. They receive incoming web requests (typically user input), interact with models to gather application state, and then respond by causing the appropriate view to display something back to the user. So when we’re testing controllers, we’re making sure that a given request is answered with an appropriate response. We still need models, but we already have them covered with unit tests.

Rails calls things that test controllers functional tests. The Depot appli-

cation has three controllers, each with a number of actions. There’s a lot here that we could test, but we’ll work our way through some of the high points. Let’s start where the user starts—logging in.

Login

It wouldn’t be good if anybody could come along and administer the Depot. While we may not have a sophisticated security system, we’d like to make sure that the login controller at least keeps out the riffraff.

Report erratum

TESTINGCONTROLLERS 149

As the LoginControllerwas created with the generate controller script, Rails

has a test stub waiting for us in thetest/functionaldirectory.

File 117 require File.dirname(__FILE__) + '/../test_helper'

require 'login_controller'

# Re-raise errors caught by the controller.

class LoginController; def rescue_action(e) raise e end; end class LoginControllerTest < Test::Unit::TestCase

def setup

@controller = LoginController.new

@request = ActionController::TestRequest.new @response = ActionController::TestResponse.new

end

# Replace this with your real tests.

def test_truth assert true end

end

Notice that the setup( ) method has already initialized the three primary

objects we’ll need to test a controller: @controller,@request, and@response.

These are especially handy because using them means we don’t have to fire up the web server to run controller tests. That is, functional tests don’t necessarily need a web server or a network.

Index: For Admins Only

Great, now let’s take the hint and replace the generatedtest_truth( ) method

with our first controller test—a test that simply “hits” the login page.

File 117 def test_index

get :index

assert_response :success

end

Theget( ) method, a convenience method loaded by the test helper, simu-

lates a web request (think HTTP GET) to theindex( ) action of theLoginCon-

trollerand captures the response. Theassert_response( ) method then checks

whether the response was successful. If you think assert_response( ) looks

like a custom assertion, you’ve been paying attention. More on that later. OK, let’s see what happens when we run the test.

depot> ruby test/functional/login_controller_test.rb

Loaded suite test/functional/login_controller_test Started

F

Finished in 0.064577 seconds.

1) Failure: test_index(LoginControllerTest) . . .

Expected response to be a <:success>, but was <302> 1 tests, 1 assertions, 1 failures, 0 errors

Report erratum

TESTINGCONTROLLERS 150

That seemed simple enough, so what happened? A response code of 302 means the request was redirected, so it’s not considered a success. But

why did it redirect? Well, because that’s the way we designed theLoginCon-

troller. It uses abefore filterto intercept calls to actions that aren’t available to users without an administrative login.

File 110 before_filter :authorize, :except => :login

The before filter makes sure that the authorize( ) method is run before the

index( ) action is run.

File 109 def authorize #:doc:

unless session[:user_id]

flash[:notice] = "Please log in"

redirect_to(:controller => "login", :action => "login")

end end

Since we haven’t logged in, a valid user isn’t in the session, so the request

gets redirected to thelogin( ) action. According to authorize( ), the resulting

page should include aflashnotice telling us that we need to log in. OK, so

let’s rewrite the functional test to capture that flow.

File 117 def test_index_without_user

get :index

assert_redirected_to :action => "login"

assert_equal "Please log in", flash[:notice]

end

This time when we request the index( ) action, we expect to get redirected

to thelogin( ) action and see a flash notice generated by the view.

depot> ruby test/functional/login_controller_test.rb

Loaded suite test/functional/login_controller_test Started

.

Finished in 0.104571 seconds.

1 tests, 2 assertions, 0 failures, 0 errors

Indeed, we get what we expect. Now that we know the administrator-only

actions are off limits until a user has logged in (thebefore filteris working),

we’re ready to try logging in.

Login: Invalid User

Recall that the login page shows a form that allows a user to enter their

user name and password. When the user clicks the login button, the

information is packaged up as request parameters and posted to thelogin

action. Theloginaction then creates aUserand tries to log the user in.

@user = User.new(params[:user]) logged_in_user = @user.try_to_login

Report erratum

TESTINGCONTROLLERS 151

The test, then, simply stuffs the user information in the request and sends it on to thelogin( ) action.

File 117 def test_login_with_invalid_user

post :login, :user => {:name => 'fred', :password => 'opensesame'} assert_response :success

assert_equal "Invalid user/password combination", flash[:notice]

end

This time we use the post( ) method, another convenience method loaded

by the test helper, to send a request to the login( ) action, which differ-

entiates its behavior depending on the HTTP access method. Along with the request, we send a hash of request parameters representing the user. Since the user is invalid, the test expects the login to fail with a flash notice on the resulting page.

depot> ruby test/functional/login_controller_test.rb

Loaded suite test/functional/login_controller_test Started

.

Finished in 0.128031 seconds.

2 tests, 4 assertions, 0 failures, 0 errors

Sure enough, the test passes. Fred can’t possibly log in because we don’t have any users in the database. Now, we could try adding Fred using the add_user( ) action, but we have to be logged in as an administrator to do

that. We could also create a valid Userobject and save it to the database

just for this test case, but we’re likely to need User objects in other test

cases. Instead, we’ll use our old friend the fixture, this time defined in the filetest/fixtures/users.yml.

File 116 fred:

id: 1 name: fred

hashed_password: <%= Digest::SHA1.hexdigest('abracadabra') %>

The users table wants the hashed password, not the plain-text password. Therefore, we embed Ruby code in the fixture file to generate a hashed password from a plain-text one. (Remember, this is the test database, so putting the plain-text password in the fixture file shouldn’t set off alarms

for the security police.) Then we have to explicitly load theusers fixture in

theLoginControllerTest.

File 117 fixtures :users

We rerun the test_login_with_invalid_user( ) test, and again it passes—Fred

still can’t log in. This time he hasn’t supplied the proper password. At this

point, we change the test_login_with_invalid_user( ) test to use a user name

that’s not in the database. We also write atest_login_with_invalid_password( )

test that tries to log in Fred (who is now in the database, courtesy of the

Report erratum

TESTINGCONTROLLERS 152

fixture) using a bad password. Both of those tests pass, so we’ve got our bases covered.

Login: Valid User

Next we write a test that verifies that Fred can log in given the correct

password listed in the fixture file.

File 117 def test_login_with_valid_user

post :login, :user => {:name => 'fred', :password => 'abracadabra'} assert_redirected_to :action => "index"

assert_not_nil(session[:user_id]) user = User.find(session[:user_id]) assert_equal 'fred', user.name

end

In this case, we expect to get Fred logged in and redirected to the index( )

action. While we’re at it, we check that the user in the session is indeed Fred. That’s Fred’s meal ticket to getting around the admin side of the application.

We run the test, and it passes! Before moving on, we have an opportunity here to make writing more controller tests easier. We’ll need to be logged in to do any sort of testing of the admin features. Now that we have a login

test that works, let’s extract it into a helper method in thetest_helper.rbfile.

File 122 def login(name='fred', password='abracadabra')

post :login, :user => {:name => name, :password => password} assert_redirected_to :action => "index"

assert_not_nil(session[:user_id]) user = User.find(session[:user_id])

assert_equal name, user.name, "Login name should match session name" end

By default, callinglogin( ) uses Fred’s name and password, but these values

can optionally be overridden. The login( ) method will raise an exception if

any of its assertions fail, causing any test method that callslogin( ) to fail if

the login is unsuccessful.

Functional Testing Conveniences

That was a brisk tour through how to write a functional test for a con- troller. Along the way, we used several handy assertions included with Rails that make your testing life easier. Before we go much further, let’s look at some of the Rails-specific conveniences for testing controllers.

Report erratum

TESTINGCONTROLLERS 153

HTTP Request Methods

The following methods simulate an incoming HTTP request method of the same name and set the response.

• get( ) • post( ) • put( ) • delete( ) • head( )

Each of these methods takes the same four parameters. Let’s take a look

atget( ), as an example.

get(action, parameters = nil, session = nil, flash = nil)

Executes an HTTP GET request for the given action and sets the response. The parameters are as follows.

• action: the action of the controller being requested • parameters: an optional hash of request parameters • session: an optional hash of session variables • flash: an optional hash of flash messages Examples:

get :index

get :add_to_cart, :id => @version_control_book.id get :add_to_cart, :id => @version_control_book.id,

:session_key => 'session_value', :message => "Success!"

Assertions

In addition to the standard assertions provided by Test::Unit, functional tests can also call custom assertions after executing a request. We’ll be

using the following custom assertions.2

assert_response(type, message=nil)

Asserts that the response is a numeric HTTP status or one of the fol- lowing symbols. These symbols can cover a range of response codes (so:redirectmeans a status of 300–399).

• :success • :redirect • :missing • :error

2More assertions are documented athttp://api.rubyonrails.com/classes/Test/Unit/Assertions.html.

Report erratum

TESTINGCONTROLLERS 154

Examples:

assert_response :success assert_response 200

assert_redirected_to(options = {}, message=nil)

Asserts that the redirection options passed in match those of the redirect called in the last action. You can also pass a simple string, which is compared to the URL generated by the redirection.

Examples:

assert_redirected_to :controller => 'login'

assert_redirected_to :controller => 'login', :action => 'index'

assert_redirected_to "http://my.host/index.html"

assert_template(expected=nil, message=nil)

Asserts that the request was rendered with the specified template file. Examples:

assert_template 'store/index' assert_tag(conditions)

Asserts that there is a tag (node) in the body of the response that

meets all of the given conditions.3 (I affectionately refer to this asser-

tion as thechainsawbecause of the way it mercilessly hacks through

a response.) The conditions parameter must be a hash of any of the

following optional keys.

• :tag: a value used to match a node’s type

assert_tag :tag => 'html'

• :content: a value used to match a text node’s content

assert_tag :content => "Pragprog Books Online Store"

• :attributes: a hash of conditions used to match a node’s attributes

assert_tag :tag => "div", :attributes => { :class => "fieldWithErrors" }

• :parent: a hash of conditions used to match a node’s parent

assert_tag :tag => "head", :parent => { :tag => "html" }

• :child: a hash of conditions used to match at least one of the node’s immediate children

assert_tag :tag => "html", :child => { :tag => "head" }

• :ancestor: a hash of conditions used to match at least one of the node’s ancestors

assert_tag :tag => "div", :ancestor => { :tag => "html" }

3Behind the scenes,assert_tag( ) parses the response into a document object model.

Report erratum

TESTINGCONTROLLERS 155

• :descendant: a hash of conditions used to match at least one of the node’s descendants

assert_tag :tag => "html", :descendant => { :tag => "div" }

• :children: a hash for counting the children of a node, using any of the following keys:

:count: a number or a range equaling (or including) the num- ber of children that match

:less_than: the number of children must be less than this number

:greater_than: the number of children must be greater than this number

:only: a hash (yes, this is deep) containing the keys used to match when counting the children

assert_tag :tag => "ul",

:children => { :count => 1..3,

:only => { :tag => "li" } }

Variables

After a request has been executed, functional tests can make assertions against the following variables.

assigns(key=nil)

Instance variables that were assigned in the last action.

assert_not_nil assigns["items"]

Theassignshash must be given strings as index references. For exam- ple, assigns[:items]will not work because the key is a symbol. To use symbols as keys, use a method call instead of an index reference.

assert_not_nil assigns(:items)

session

A hash of objects in the session.

assert_equal 2, session[:cart].items

flash

A hash of flash objects currently in the session.

assert_equal "Danger!", flash[:notice]

cookies

A hash of cookies being sent to the user.

assert_equal "Fred", cookies[:name]

Report erratum

TESTINGCONTROLLERS 156

redirect_to_url

The full URL that the previous action redirected to.

assert_equal "http://test.host/login", redirect_to_url

We’ll see more of these assertions and variables in action as we write more tests, so let’s get back to it.

Buy Something Already!

The next feature we’d be wise to test is that a user can actually place an order for a product. That means switching our perspective over to the storefront. We’ll walk through each action one step at a time.

Listing Products for Sale

Back in theStoreController, theindex( ) action puts all the salable products

into the @products instance variable. It then renders the index.rhtml view,

which uses the@productsvariable to list all the products for sale.

To write a test for theindex( ) action, we need some products. Thankfully,

we already have two salable products in ourproducts fixture. We just need

to modify the store_controller_test.rb file to load the products fixture. While

we’re at it, we load theorders fixture, which contains one order that we’ll

need a bit later.

File 119 require File.dirname(__FILE__) + '/../test_helper'

require 'store_controller'

# Reraise errors caught by the controller.

class StoreController; def rescue_action(e) raise e end; end class StoreControllerTest < Test::Unit::TestCase

fixtures :products, :orders

def setup @controller = StoreController.new @request = ActionController::TestRequest.new @response = ActionController::TestResponse.new end def teardown LineItem.delete_all end end

Notice that we’ve added a new method called teardown( ) to this test case.

We do this because some of the test methods we’ll be writing will indirectly

cause line items to be saved in the test database. If defined, theteardown( )

method is called after every test method. This is a handy way to clean up the test database so that the results of one test method don’t affect

another. By calling LineItem.delete_all( ) in teardown( ), the line_items table

in the test database will be cleared after each test method runs. If we’re

Report erratum

TESTINGCONTROLLERS 157

using explicit test fixtures, we don’t need to do this; the fixture takes care of deleting data for us. In this case, though, we’re adding line items but we aren’t using a line items fixture, so we have to tidy up manually.

Then we add a test_index( ) method that requests the index( ) action and

verifies that thestore/index.rhtmlview gets two salable products.

File 119 def test_index

get :index

assert_response :success

assert_equal 2, assigns(:products).size assert_template "store/index"

end

You may be thinking we have gratuitous overlap in testing here. It’s true,

we already have a passing unit test in theProductTesttest case for salable

items. If the index( ) action simply uses the Product to find salable items,

aren’t we covered? Well, our model is covered, but now we need to test that the controller action handles a web request, creates the proper objects for the view, and then renders the view. That is, we’re testing at a higher level than the model.

Could we have simply tested the controller and, because it uses the model, not written unit tests for the model? Yes, but by testing at both levels we can diagnose problems quicker. If the controller test fails, but the model test doesn’t, then we know there’s a problem with the controller. If, on the other hand, both tests fail, then our time is best spent focusing on the model. But enough preaching.

Adding to the Cart

Our next task is to test the add_to_cart( ) action. Sending a product id

in the request should put a cart containing a corresponding item in the

session and then redirect to thedisplay_cart( ) action.

File 119 def test_add_to_cart

get :add_to_cart, :id => @version_control_book.id cart = session[:cart]

assert_equal @version_control_book.price, cart.total_price assert_redirected_to :action => 'display_cart'

follow_redirect

assert_equal 1, assigns(:items).size assert_template "store/display_cart" end

The only tricky thing here is having to call the methodfollow_redirect( ) after

asserting that the redirect occurred. Callingfollow_redirect( ) simulates the

browser being redirected to a new page. Doing this makes theassignsvari-

Report erratum

TESTINGCONTROLLERS 158

able and assert_template( ) assertion use the results of the display_cart( )

action, rather than the original add_to_cart( ) action. In this case, the

display_cart( ) action should render the display_cart.rhtml view, which has

access to the@itemsinstance variable.

The use of the symbol parameter inassigns(:items)is also worth discussing.

For historical reasons, you cannot indexassignswith a symbol—you must

use a string. Because all the cool dudes use symbols, we instead use the

method form ofassigns, which supports both symbols and strings.

We could continue to walk through the whole checkout process by adding

successive assertions in test_add_to_cart( ), using follow_redirect( ) to keep

the ball in the air. But it’s better to keep the tests focused on a single request/response pair because fine-grained tests are easier to debug (and read!).

Oh, while we’re adding stuff to the cart, we’re reminded of the time when the customer, while poking and prodding our work, maliciously tried to