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