Integration Testing in Rails 1.1
Integration tests are a new feature of Rails 1.1 that take testing your applications to a new level. They are the next logical progression in the existing series of available tests:
- Unit tests are very narrowly focused on testing a single model
- Functional tests are very narrowly focused on testing a single constroller and the interactions between the models it employs
- Integration tests are broad story-level tests that verify the interactions between the various actions supported by the application, across all controllers
This makes it easier to duplicate (in tests) bugs with session management and routing. Consider: what if you had a bug that was triggered by certain cruft accumulating in a user’s session? Hard to mimic that with functional tests.
For an example, consider a fictional financial application. We have a set of “stories” that describe how the application ought to function:
- Bob wants to sign up for access. He goes to the login page, clicks the “signup” link, and fills out the form. After submitting the form, a new ledger is created for him, and he is automatically logged in and taken to the overview page.
- Jim, an experienced user, has received a new credit card and wants to set up a new account for it. He logs in, selects the ledger he wants to add the account to, and adds the account. He is then forwarded to the register for that account.
- Stacey is a disgruntled user. She has decided to cancel her account. Logging in, she goes to the “account preferences” page and cancels her account. Her data is all deleted and she is forwarded to a “sorry to see you go” page.
Starting with the first one, we might write something like the following. We’ll create this file (“stories_test.rb”) in the test/integration
directory:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
require "#{File.dirname(__FILE__)}/../test_helper" class StoriesTest < ActionController::IntegrationTest fixtures :accounts, :ledgers, :registers, :people def test_signup_new_person get "/login" assert_response :success assert_template "login/index" get "/signup" assert_response :success assert_template "signup/index" post "/signup", :name => "Bob", :user_name => "bob", :password => "secret" assert_response :redirect follow_redirect! assert_response :success assert_template "ledger/index" end end |
We can run this by typing rake test:integration
, or by invoking the file directly via ruby
.
The code is pretty straightforward: first, we get the ”/login” url and assert that the response is what we expect. Then we get the ”/signup” url, then post the data to it, and then follow the redirect through to the ledger.
However, one of the best parts of the integration framework is the ability to extract a testing DSL out of your actions, making it really easy to tell stories like this. At the simplest, we can do that by adding some methods to the test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
require "#{File.dirname(__FILE__)}/../test_helper" class StoriesTest < ActionController::IntegrationTest fixtures :accounts, :ledgers, :registers, :people def test_signup_new_person go_to_login go_to_signup signup :name => "Bob", :user_name => "bob", :password => "secret" end private def go_to_login get "/login" assert_response :success assert_template "login/index" end def go_to_signup get "/signup" assert_response :success assert_template "signup/index" end def signup(options) post "/signup", options assert_response :redirect follow_redirect! assert_response :success assert_template "ledger/index" end end |
Now, you can reuse those actions in other tests, making your tests very readable and easy to build. But it can be even neater! Taking advantage of the provided open_session
method, you can create your own session instances and decorate them with custom methods. Consider this example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
require "#{File.dirname(__FILE__)}/../test_helper" class StoriesTest < ActionController::IntegrationTest fixtures :accounts, :ledgers, :registers, :people def test_signup_new_person new_session do |bob| bob.goes_to_login bob.goes_to_signup bob.signs_up_with :name => "Bob", :user_name => "bob", :password => "secret" end end private module MyTestingDSL def goes_to_login get "/login" assert_response :success assert_template "login/index" end def goes_to_signup get "/signup" assert_response :success assert_template "signup/index" end def signs_up_with(options) post "/signup", options assert_response :redirect follow_redirect! assert_response :success assert_template "ledger/index" end end def new_session open_session do |sess| sess.extend(MyTestingDSL) yield sess if block_given? end end end |
The new_session
method at the bottom simply uses open_session
to create a new session and decorate it by mixing in our DSL module. By adding more methods to the MyTestingDSL
module, you build up your DSL and make your tests richer and more expressive. You can even use named routes in your tests to ensure consistency between what your application is expecting and what your tests are asserting!
1 2 3 4 |
def goes_to_login get login_url ... end |
Note that the new_session
method will actually return the new session as well. This means you could define a test that mimicked the behavior of two or more users interacting with your system at the same time:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
def test_multiple_users jim = new_session_as(:jim) bob = new_session_as(:bob) stacey = new_session_as(:stacey) jim.adds_account(...) bob.goes_to_preferences stacey.cancels_account end private module MyTestingDSL ... attr_reader :person def logs_in_as(person) @person = people(person) post authenticate_url, :user_name => @person.user_name, :password => @person.password is_redirected_to "ledger/list" end ... end def new_session_as(person) new_session do |sess| sess.goes_to_login sess.logs_in_as(person) yield sess if block_given? end end |
Just to further demonstrate how these DSL’s can be built, let’s implement the second of the three stories described at the beginning of this article: Jim adding a credit-card account.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
require "#{File.dirname(__FILE__)}/../test_helper" class StoriesTest < ActionController::IntegrationTest fixtures :accounts, :ledgers, :registers, :people ... def test_add_new_account new_session_as(:jim) do |jim| jim.selects_ledger(:jims) jim.adds_account(:name => "credit card") end end private module MyTestingDSL ... attr_accessor :ledger def is_redirected_to(template) assert_response :redirect follow_redirect! assert_response :success assert_template(template) end def selects_ledger(ledger) @ledger = ledgers(ledger) get ledger_url(:id => @ledger.id) assert_response :success assert_template "ledger/index" end def adds_account(options) post new_account_url(:id => @ledger.id), options is_redirected_to "register/index" end end ... end |
Note individual integration tests run slower than individual unit or functional tests, but that’s because they test so much more. Note that each of the tests shown above test multiple requests. Most functional tests only test one. Also, integration tests run through the entire stack, from the dispatcher, through the routes, into the controller and back. Functional tests skip straight to the controller.
Reader Comments
new_session_as
rather thannew_session
? Also, what do you see the role of regression/acceptance tests (like SeleniumOnRails) being versus the new integration tests? Great article, thanks.9 Mar 2006
9 Mar 2006
9 Mar 2006
10 Mar 2006
10 Mar 2006
11 Mar 2006
11 Mar 2006
11 Mar 2006
12 Mar 2006
28 Mar 2006
28 Mar 2006
29 Mar 2006
31 Mar 2006
4 Apr 2006
4 Apr 2006
4 Apr 2006
4 Apr 2006
6 Apr 2006
7 Apr 2006
7 Apr 2006
26 Apr 2006
7 May 2006
7 May 2006
12 May 2006
18 May 2006
31 Aug 2006
31 Aug 2006
10 Sep 2006
After reading your article, I have to change my habits and do my "programming language talk" in Ruby. So, I can now ask my boss to express his needs in a DSL--- somehow an ad-hoc language :-) .
--- I'm still in the Java camp but I have to recongnise that what I saw it's really beautiful.
So thanks a lot, Jamis! Best regards from Romania!13 Sep 2006
Hi Jamis,
I don’t know if you’re still checking these comments… trying to apply this pattern, I’m getting a weird error, pasted here:
http://pastie.caboo.se/27278
A global search and replace in vendor/rails/actionpack/lib/action_controller/integration.rb fixes the problem, e.g.: - attr_accessor :accept + attr_accessor :http_accept
For some reason, it seems accept is not being interpreted as an attribute but as a function. I’d appreciate any pointers you can give :)
12 Dec 2006