Published on O'Reilly (http://oreilly.com/)
 See this if you're having trouble printing code examples

Cookin' with Ruby on Rails - Integration Tests

by Bill Walton

NOTE TO READER: This tutorial is intended for use with Rails 1.2.6. It has not yet been updated to support Rails 2.x.

It's just about lunch time and CB's trying to decide whether to hit the cafeteria or go out for lunch. And just in time... in walk Paul and Boss.

CB: Hi, Paul. Hi, Boss. Whatchall been up to? Thought I'd see you sooner.

Paul: Hi, CB. Been fighting fires. I been trying to get over here, but I wanted Boss to come along so we could introduce him to Rails's Integration tests like you suggested. Unfortunately, he's been caught up in the same mess as me. It looks like we've got it under control now, so here we are!

The upside of the last few weeks is that Boss' interest in automated tests probably couldn't be any higher than it is right now. I'm convinced we could have avoided most of this latest mess if we'd been doing the kind of testing you and I've been working on. Even more so if we had automated tests the business users could understand; maybe even write with a little help.

CB: Well then... it sounds like the wait was worth it. Timing is everything! ;-)

Speaking of timing... I was just about to go grab something to eat. Whatdaya think Boss? You up for another pizza?

Boss: No problem, CB. Like Paul said, I'm very, very interested to hear and see more about the tests Paul's been telling me about. I'll be honest though. I'm a bit sceptical about customers being able to really understand automated tests. As I understand it, you and Paul have been writing code; pretty much just like you were writing code for the application itself. Our business partners aren't programmers.

CB: The tests Paul and I've been working on have been at a lower level than the ones we're going to work on today, Boss. The tests we'll be tackling today are known as Integration tests in Rails-speak. I'm pretty sure you'll be surprised at how easy they are to understand. It's an area where Rails really shines. Our Integration tests will be written as a "domain specific language," DSL for short. A DSL is a language that's aimed at solving a specific type of problem. We'll use the Rails test framework to create a DSL to describe the way our application works and how the business uses it; in a language the business users will understand.

Boss: I think you're going to have to show me, CB. I definitely am not imagining the business reacting positively to the notion that we're going to be creating a new language. Some of them already think that we're deliberately, and unnecessarily, using words they don't understand just to make them feel... let's see... what's a good word?... schtupit?

CB: Actually, Boss, what I'm talking about is creating a "programming" language that we'll use to write tests that will very closely mimic the way they think and talk about the application and how they use it. But I think you're right. It'll be easier to show you. You gonna make that call?

Boss: The pie? Absolutely. Why don't you go ahead and get set up while I make the call?

CB has previously put a copy of cookbook2 on his laptop and he's already decided to use both it and the copy on his desktop for this meeting with Boss. He opens a command window on his laptop, moves into the cookbook2 directory and starts Mongrel, and then opens a browser window. On his desktop, he opens another command window and navigates into the cookbook2/test directory, and then opens his favorite text editor.

Boss: Pizza's on its way! Paul tells me you've already created a bunch of tests, CB. You want to show me those?

CB: I could, Boss, but I think to give you a feel for how this would work with the business it'll be better if we start out with the Integration tests. Then, when you want to look under the hood, we can go back to the lower-level Unit and Functional tests. Is that OK with you?

Boss: Sure, CB. But before we start, what do you mean by "lower-level"?

CB: The Rails test framework lets us take a layered approach to testing our applications, where each layer builds on the one below. The lowest layer is the Model, which is responsible for the application's data and the business logic that governs how that data can be used. We use Unit tests to test that our application's Models work correctly. Controllers sit between the application's Models and Views, passing data back and forth and controlling the application flow. Rails calls the tests that verify that a specific controller is functioning correctly Functional tests. Views present data to and collect data from our site's visitors via web pages. As visitors use our app, they'll interact with multiple controllers through multiple views. Rails calls the tests at this top level Integration tests because they integrate all the pieces and allow us to test our application as a whole.

Boss: Interesting. All right, then. Let's get started.

CB: OK then. Paul, would you mind driving the app? What I've got in mind, for today's session, is for you to work through the app with Boss on the laptop while I work next to you on my desktop system and write the Integration tests the two of you come up with. That way, Boss can sit between us and follow along on both fronts. Later, as we move forward with the app and you get comfortable writing the tests yourself, you can work with Boss to prototype our enhancements and write their basic Integration tests, and then you and I can work together to write the app code and the Unit and Functional tests.

Paul: Sure. That makes sense.

CB: Great. I've already started Mongrel on the laptop, Paul. Would you browse over to our application?

Our application
Figure 1

CB: Thanks, Paul. That reminds me... we're still starting our app by explicitly invoking either the Recipe controller or the Category controller. We're need to decide what URL we're going to use for our home page. We'll want to visitors to be able to get to our app using a URL like http://www.cookbook2.com. So what do you think, Boss? Do we know what our home page is going to be?

Boss: That's a good question, CB. Right now, I'd say it'll be the Recipe page Paul just brought up. But that could change in the future. Is that going to cause us problems?

CB: Not really, but the more we think it through now, the less rework we'll have to think through in the future. Rails makes it easy to change the page we start with, but if we're not careful our Integration tests could be impacted. Let's go ahead and make the change to the app side and then keep the fact that the home page could change in mind as we add Integration tests. The change to the app side is easy. Rails lets us specify the default page in a configuration file named routes.rb.

Figure 2

All we need to do is make two small changes. As the instruction in the file tells us, we need to delete the public/index.html file from the public directory change. I'll do that first. Paul, would you repoint the browser?

The web server doesn't have a page to render
Figure 3

CB: So at this point, the web server doesn't have a static page to render and, when it passes the request to Rails, it doesn't know what to do with it either. So let's uncomment the line in routes.rb that tells Rails what to do if there's no controller specified and point it to our Recipe controller...

map.connect '', :controller => "recipe"

adding a default route for our site
Figure 4

CB: So now we've told Rails what to do with a URL that doesn't specify a controller. Rails now knows that the root action of our application is the index method in our recipe controller. Paul, would you hit the refresh button for us?

now Rails knows what to do with an empty url
Figure 5

Paul: And if we change our mind about the default URL for our site in the future, all we have to do is change that one line in routes.rb? Nice!

CB: That's Rails! ;-)

CB: OK. So let's move on to our Integration tests. The first thing to note is that while Rails creates a test/integration directory for us, it doesn't attempt to scaffold any test code for us. So all we've got at this point is an empty directory. Let's put something in it. First, I'll create the skeleton for our test case. I'll name the file cookbook2_integration_test.rb

empty integration test case
Figure 6

Paul: That looks a lot like the other test cases we started with.

CB: Yep. We'll need our fixtures, so I've included them. And I put our standard "test truth" method in there just so we can test our skeleton before we start adding any actual test methods. The only real difference is in the class definition. In both the Unit and Functional tests we've worked on, the class we used was Test::Unit::Testcase. For Integration tests, we use a new class:


The IntegrationTest class inherits from Test::Unit, which gives us access to the same methods we have access to in our Unit and Functional tests. Let's run our skeleton test case just to make sure I haven't forgotten or fat-fingered anything. I'll change into the test/integration directory just to save myself some typing. Then...

ruby cookbook2_integration_test.rb

testing our test case skeleton - no problems!
Figure 7

CB: And it looks like we're good to go! So let's write some tests that'll let us add to and otherwise change our app in the future without the fear of unintentionally breaking things. Paul, you want to take over?

Paul: Sure, CB. Boss, you ready?

Boss: You bet. How's this going to work?

CB: I'd recommend you start with just walking through the app and having a conversation about it. We want to make the tests speak your language and the best way I know to learn that language is to hear you use it.

Boss: That sounds easy. OK. I guess it's really pretty simple at this point. We want to make sure that when a visitor goes to our home page, it has the right title, the right footer, and the right stuff in the middle.

CB: Well then, let's write a test that makes sure our home page satisfies those requirements!

def test_the_home_page

Boss: Rails knows how to do all that?

CB: Naw. Rails is good, but that'd be real magic! ;-) Rails, and that includes the Test Framework we're using, is, at one level, really just a layer consisting of a bunch helpers that make it a lot easier to write the Ruby code that runs at the layer below. I find it easiest to think of the test framework as a mirror of the application framework.

Test Framework Application Framework
DSL test code <--> DSL app code
test framework <--> app framework
Ruby test code <--> Ruby app code

Our tests are just code written in a different language to describe the same thing the application code describes. Code is code, and we'll apply the same principles and patterns to our test code that we apply to our application code; especially modularity and encapsulation. So, now I need to write the test methods that our test_the_home_page test invokes.

The first thing we need to do is browse to the home page.

def browse_to_the_home_page
  get "/"
  assert_response :success
  assert_template "recipe/list"

The first line creates a GET request for the page at the application root. Our mocked web server treats the request pretty much just like Mongrel would if we entered "http://localhost:3000/" in our browser. And just like Mongrel, our mocked web server invokes the Rails controller/action we specified in routes.rb. The application constructs an HTML page in response and passes it back to the web server to deliver to the browser that requested it.

The second line is an assertion that tests the response object to verify that when it delivers the response to the browser, the web server sends along a "200 OK" status code. And the final line asserts that the view template that Rails used to render the page was the list.rhtml template in the app\views\recipe folder.

Now that the home page has been rendered, we can examine it to ensure that it contains the items we expect. And the next item is one that does a good job of illustrating what I was saying earlier about making our tests speak the same language our business partners use. In programmer speak, Boss, title is usually taken to mean the wording that appears on the very top of the browser window. Paul, hit the home button on the browser for me, please. The title here is "Mozilla Firefox Start Page - Mozilla Firefox."

showing Boss the browser title
Figure 8

CB: But I'm pretty sure that's not what you meant, Boss. Am I right?

Boss: You're right, CB. I was talking about the bold "Online Cookbook" line at the top of the page being rendered inside the browser window.

CB: That's what I thought. One of the nice things about Rails and its test framework is that we can very easily accommodate using language that the business understands in a way that makes it unlikely that the intent will be misunderstood by the developers. In this case, our test code will be ...

def check_the_home_page_title
  assert_select "h1", {:text=>"Online Cookbook"}

The assert_select line tells the framework to look for a set of <h1> tags on the rendered page surrounding the text "Online Cookbook." So the business isn't forced to understand the technical difference between a page title and a page heading, and the developers can very easily see what's actually expected. And now, on to our footer...

def check_the_home_page_footer
   assert_select "a", {:text=>"Create new recipe", href=>"recipe/new"}
   assert_select "a", {:text=>"Show all recipes", href=>"recipe/list"}
   assert_select "a", {:text=>"Show all categories", href=>"category/list"}

The assertions here tell the framework to find the three hyperlinks we've put on the page for navigation.

And finally, we create the method that checks that the page contains a table.

def check_the_home_page_content
  assert_select "table"

So, at this point, our test case looks like...

initial test case
Figure 9

And when we run it...

ruby cookbook2_integration_test.rb

we get...

a successful test run
Figure 10

Paul: Correct me if I'm wrong, CB, but it seems to me like this test would pass if these links were anywhere on the page. What if we wanted them to show up in more than one place? Could we test for that? What if we had some of the same links on a left nav bar, for example?

CB: That's a good question, Paul. And you're right on both counts. The way it stands now, the test would pass if the framework found the links anywhere on the page. So, if the footer links were there, but the ones on the left nav were missing, we wouldn't find that out by running this test. In fact, this is a real good example of how our tests, and the act of creating them first, can guide the development of the app.

The assert_select methods can be nested to constrain the scope of the examination the test framework conducts. Let's make it look for these links inside a set of <div> tags that have an id of "footer." Let's write the test first. I'll wrap our existing assertions in a new one to constrain the search....

def check_the_home_page_footer
  assert_select "div#footer" do
   assert_select "a", {:text=>"Create new recipe", :href=>"recipe/new"}
   assert_select "a", {:text=>"Show all recipes", :href=>"recipe/list"}
   assert_select "a", {:text=>"Show all categories", :href=>"category/list"}

So, now the test will only pass if the links we're looking for are inside a <div> with an id="footer". And now I'll rerun the test case.

we have a failing test!
Figure 11

And now that we have a failing test, let's write some code! I'll open the application layout file, since that's where we put the footer code so it would be used for all our app's pages.

application layout prior to change
Figure 12

And now I'll just change the paragraph tags to div tags, giving the opening tag the footer id. So, now we have...

application layout with footers identified
Figure 13

And now when I rerun our test case, we get confirmation that our change was successful, and that we didn't unintentionally break something else in our app; at least nothing that we've got tests written for.

post-change test run
Figure 14

Boss: It seems to me we ought to have more "beef" in the content test, CB. We're checking to see that there's a table on the page and that's good. But I don't see anything that makes sure that the right headings are there, or that the contents have the links they need to have.

CB: Good point, Boss. We can definitely do that. But the real point, from my perspective, is that you could see that our tests need to be beefed up.

Boss: You know... you're right, CB. I didn't have a lot of confidence that the folks from the business would be able to participate the way you said they would, but these tests are really easy to understand.

CB: Excellent! So now let's add the test code to make sure our table has the right contents.

def check_the_home_page_content
  assert_select "table" do
   assert_select "tr" do
     assert_select "th", {:text=>"Recipe"}
     assert_select "th", {:text=>"Category"}
     assert_select "th", {:text=>"Date"}
   assert_select "tr" do
     assert_select "td" do
     assert_select "a", {:text=>"pizza", :href=>"/recipe/show/1"}
     assert_select "a", {:text=>"(delete)", :href=>"/recipe/destroy/1"}
     assert_select "td" do
     assert_select "a", {:text=>"main course", :href=>"/recipe/list?category_id=1"}
     assert_select "td", {:text=>"2007-05-09"}
  assert_select "tr" do
     assert_select "td" do
     assert_select "a", {:text=>"iced tea", :href=>"/recipe/show/2"}
     assert_select "a", {:text=>"(delete)", :href=>"/recipe/destroy/2"}
    assert_select "td" do
     assert_select "a", {:text=>"beverages", :href=>"/recipe/list?category_id=2"}
    assert_select "td", {:text=>"2007-05-09"}

And now we'll rerun our test case...

test results after adding the beef to our content test
Figure 15

CB: What do you think, Boss? You want to walk us through the changes I made to the test?

Boss: Well... I think I understand some of it. I know enough HTML to see that we're testing the table a row at a time. And for each row we're looking for three divisions, and we're looking at the content within each division. One thing I don't understand though is the content you're looking for. Like the dates. Those aren't the same dates I'm seeing in the browser I'm looking at with Paul.

Paul: I can explain that, Boss. It's something we didn't talk about with you yet. The content we're looking for in the tests is coming from what're called test fixtures. Let me open up the recipes fixture and show you.

recipes fixture
Figure 16

The tests use a copy of the database that's just for testing. It's got exactly the same structure as the database the app's using to display what you're seeing in the browser, but the test database is getting its content from the test fixtures. The test database gets emptied and reloaded before each test method is executed. If it weren't for that, we'd have to put a lot more effort into planning and controlling the order of execution of the test methods in order to fully test the application, and we'd need to create a lot more test data. With Rails, we can delete a record in one test method, and then use that same record in another.

Boss: Thanks, Paul. That answers my question. And it's probably about as much as I need to know: same results for less work. I like it. But speaking of deleting records, we need to make sure the app can do that, don't we? And add them?

CB: You bet, Boss. I'll go ahead and add a new test method for the delete.

def verify_a_recipe_can_be_deleted
  recipe_count_before_delete = Recipe.find(:all).size
  post "recipe/destroy/1"
  recipe_count_after_delete = Recipe.find(:all).size
  assert_equal(recipe_count_after_delete, recipe_count_before_delete - 1)  

Boss: Hmmm....

CB: What's wrong, Boss?

Boss: If I understand that code, you're checking the number of records in the database. Right? And that's important. But what I need to know is not what's in the database. I need to know that what's on the page in the browser is right.

CB: You're absolutely right, Boss. My bad. And now that I think about it, that focus gives me an idea that'll make our test code better. I'm going to chop up the method that checks our table content so we can reuse parts of it here. So, now we've got...

test method refactored into component parts
Figure 17

Now, I'll rerun the tests to make sure I haven't messed up anything with the change...

same results
Figure 18

And now I'll change our test method for the recipe deletion so that it checks the page, not the database, to verify that there's no link to the deleted recipe.

def verify_a_recipe_can_be_deleted
  post "recipe/destroy/1"
  assert_response :redirect
  assert_template "recipe/list"
  assert_select "tr", :count => 2
  assert_raise(Test::Unit::AssertionFailedError) {assert_select "a", {:text=>"pizza"}}

NOTE to Readers: Make sure that the {assert_select ...} block appears in your code on the same line as the assert_raise() method it belongs to. Rails will throw a syntax error if it doesn't.

CB: And now I'll add the method call to the top level test method so that the new tests get executed.

our updated top-level test method
Figure 19

Boss: I like the way Rails lets us structure these into more than one level. I'm sure any of our business folks will be able to look at that top level test and get comfortable with their understanding of what's being tested. And I'm just as sure that most of them wouldn't be comfortable at all with their understanding of the low-level test you just wrote.

Paul: They wouldn't be alone, Boss. You mind walking me through those new test methods, CB?

CB: Sure, Paul. Let's take a closer look.

our new methods
Figure 20

Remember that the verify_a_recipe_can_be_deleted method is called from within our test_the_homepage method. That's important because verify_a_recipe_can_be_deleted assumes that the page has already been retrieved and is in-scope. The verify_pizza_row and assert_select methods would all fail if that weren't true.

We've already talked about the verify_pizza_row code a bit, so let's start at the next line. I'm adding one to the count of recipe records to account for the row of headings on our table. It's not technically necessary, but since Boss wanted to focus on the page I thought it would be better from a communication perspective. Next we send a post request to our app to delete the pizza record. We used a post instead of a get because we've got code in our controller to make sure that any requests that'll change the state of the database are post-type requests. If we didn't take that precaution, Boss, Google and other web-crawlers could create problems for us just by following our site's links. When a recipe record gets deleted from our database, our code does a redirect_to to the list action. The three lines that follow the post pretty much mirror the code in the controller. The last couple of lines are where things might seem a little tricky. The assert_select line uses the :count option to verify that when the page gets rendered after the deletion, it has one less row than it did before. The last line is where we make sure that the row with the link to the pizza recipe is the one that actually got deleted. Paul, you might remember that we used assert_nothing_raised in our Unit tests to make avoid crashing our test case with the method that tested to make sure we couldn't delete a Category if there were Recipes assigned to it.

Paul: I do remember that, now. It trapped the exception that MySQL raised when we tried to do that.

CB: Exactly. And that's what we're doing here too. When we assert that there's an item on the page that's not there, the Test Framework raises an exception that's translated into a failure. We're testing a negative condition here: we want to test that an item is not on the page. So, we use assert_raise to verify that the exception is raised as expected. Of course, if the business folks wanted to see how we're verifying that a record can be deleted, but this level of detail was confusing to them, we could easily just move this code down a level; maybe create a new method we'd call from here with a name like verify_pizza_row_no_longer_appears_in_the_table. And we could just as easily move the check for the header row down a level too. It's just a matter of understanding what level of detail our customers are comfortable with.

Boss: That's pretty powerful stuff, CB. I like it. Communication is definitely key. And I have to agree with what Paul said earlier. I think we could have avoided a lot of this last mess we just had to deal with if we'd created automated tests up front in our development process to get this level of communication happening early.

CB: Excellent! I'm really glad to hear you say that, Boss. Let's take another look at our home page and see what else we need to test from there.

another look at our home page
Figure 21

I think we probably ought to talk just a bit about how I'm thinking we'll structure the Integration tests. I like to approach it a page at a time; first verifying that everything that's supposed to be there is there. Then I like to verify the working of the things on that page that're going to leave us on or bring us back to the same page So it makes sense to me to include the verify_a_recipe_can_be_deleted method in test_the_home_page. Clicking the Category links also leave us on this page; just showing a different set of recipes, so it makes sense to me to test them in our test_the_home_page method too. What do you think, Boss?

Boss: Yeah. I like that. It's pretty much the way I'd do it manually; make sure everything I expect is there, then test to make sure they work, then move on to another page and do it again.

CB: OK. So we've verified the content of the home page. Now, let's add another test to verify our Category filter. When we click on a Category link, the page is supposed to get refreshed showing only the recipes for that category. I could included this in the test_the_home_page method, but since we've already modified the content of our test database by deleting a record, I'm going to create a new test method so that we start fresh. So Boss... how should we test that behavior?

Boss: Well, I'd say you need to start with having more than one category on the page, then select one of them, and then verify that only that category is on the page afterwards,

CB: Excellent. So here's my take on the high-level test for the category filtering functionality.

def test_category_filtering

What do you think, Boss?

Boss: That looks good.

CB: Cool. Then I'll add the lower-level test methods.

def verify_both_categories_present
  assert_select "tr" do
   assert_select "td" do
     assert_select "a", {:text=>"main course", :count=>1}
     assert_select "a", {:text=>"beverages", :count=>1}
def select_one_category
  get "recipe/list?category_id=1"
  assert_response :success
  assert_template "recipe/list"
def verify_that_only_that_category_is_present
  assert_select "tr" do
   assert_select "td" do
    assert_select "a", {:text=>"main course", :count=>1}
  assert_raise(Test::Unit::AssertionFailedError) {assert_select "a", {:text=>"beverages"}}

And now we run our test...

results of testing our filtering
Figure 22

CB: Houston, we have a launch! ;-)

Seriously, though, I think we're just about done with the home page. The only other link on the page that's going to leave us back here is the "Show all recipes" link. Since we know that browsing to the home page currently takes us to the recipes controller index method, I'm not sure we really need to test that. What do you think, Boss?

Boss: I think we need to test it. First, I'd like to make sure that we test the home page very thoroughly. And it just occurred to me that we didn't make sure that all the recipes were shown in our test of the home page contents. So I definitely think we need to test this. And I want to make sure that the number of recipes in the database gets compared to the number of recipes shown on the page.

CB: Not a problem, Boss. How about this?

def test_show_all_recipes_link

Boss: That looks good. I see you're reusing the browse_to_the_home_page method. That's good. I'm already comfortable that I know what it does. What do the other ones look like?

CB: Well, the click_the_show_all_recipes_link is real simple. From a technical perspective, it's really not worth putting into a separate method. But our Integration tests are as much about communication with the business folks as they are about testing. So, I thought I'd go ahead and put the one-liner into its own method just to keep things at the same level.

def click_the_show_all_recipes_link
  get "recipe/list"

The other method is going to address the concern you just expressed that we're actually testing that all the recipes in the database get displayed on the page.

def verify_all_recipes_shown
  recipe_count = Recipe.count
  header_row = 1
  assert_select "tr", :count=>(recipe_count + header_row)

First I get the count of all the recipes in our database. The second line is just for clarity; reminding us that one of the rows in the table is a header row. And then we compare the number of rows displayed against the number of recipes in the table. What do you think? Will that do it?

Boss: It'll do for now. I may want to add more later, but for now, let's move on to the other pages.

CB: Excellent! But first, let's run the new tests to make sure they're working.

done with the home page
Figure 23

Alllll righty, then! Let's get on with it! I think I'll go ahead and test the "Create a new recipe" link now. That way, we'll be done with Recipes altogether when we test the "Show all categories" link. Let's take a look at where we are.

cookbook2_integration_test contents
Figure 24

IMPORTANT NOTE TO READER: The two assert_raise lines in the image above have been formatted on two lines to accomodate the width restrictions for images for O'Reilly online publications. Please make sure that your code has the assert_raise statement all on one line. As in...

assert_raise(Test::Unit::AssertionFailedError) {assert_select "a", {:text=>"pizza"}}

So now let's add the methods to test our "Create a recipe" code. We've already tested that the correct link is on the page in our check_the_home_page_footer method. So here, we could pick up by browsing to that page, or we could start by browsing to the home page to use that link. What do you think, Boss?

Boss: Hmmm... It seems like wasted effort to start by revisiting the home page. A link is a link is a link. It's going to take us to the same place every time the URL is used. Or does Rails include the infamous "branch on occassion" method? ;-)

Paul: I'm glad you said that, Boss. I was wondering why CB started all the high-level methods by invoking the browse_to_the_home_page method. Why'd you do that, CB? Seems to me like that was just like this; we'd already shown that the links were on the page. We could have just invoked them straight off and proved just as much couldn't we?

CB: From a purely technical perspective, you're absolutely right Paul. But Integration tests, or more precisely the development of the Integration tests, are about more than just technical adequacy. At least in my opinion. I think of their adequacy as best measured by the confidence the customer walks away with that the system will work the way they intend. Don't forget that, moving forward, I expect these tests to be constructed before the code gets written. And here, I want to make sure the customer is comfortable with how the home page works. When the customer does what Boss just did, says "we don't need to do that," it signals a good comfort level not just with the application, but with their understanding of the tools and the process we're going to use to test the application. And that's worth a few keystrokes! ;-)

Paul: Ahh... you clever devil you ;-)

CB: (grinning) OK, then. Let's write that new test. And actually, there's a real good reason to start the method with the browse_to_the_home_page method. It gives us a context for our test that will let us check the before state of the number of recipes on the page. It signals that we're testing the home page. We'll test the page where we create the recipe later. For now, we're still testing the behavior of the home page. Anyway, we already know from our Unit tests that we can add a recipe to the database. So now let's test that adding a recipe to the database actually adds it to the page. So...

def test_create_new_recipe_link
  recipes_on_page_before_create = count_recipes_before_create
  recipes_on_page_after_create = count_recipes_after_create
  verify_recipe_was_added_to_page(recipes_on_page_before_create, recipes_on_page_after_create)

And here are the new sub methods...

def count_recipes_before_create
  header_row = 1
  rows = css_select "tr"
  all_rows = rows.size
  recipe_row_count = all_rows - header_row
  return recipe_row_count

def click_the_create_new_recipe_link
  get "recipe/new"
  assert_response :success
  assert_template "recipe/new"

def create_new_recipe
  post "recipe/create", :recipe=>{:category_id=>1, :title=>"new_recipe"}

def count_recipes_after_create
  get "recipe/list"
  header_row = 1
  rows = css_select "tr"
  all_rows = rows.size
  recipe_row_count = all_rows - header_row
  return recipe_row_count   

def verify_recipe_was_added_to_page(recipes_on_page_before_create, recipes_on_page_after_create)
  assert_equal(recipes_on_page_after_create, recipes_on_page_before_create + 1)

The assert_select method allows us to get the count of specific elements on the page. It gives us an easy way to verify whether or not the number of elements is what we expected. But if we don't know how many of something are already on the page, then we need to use css_select. It returns an array of all the elements selected without making an assertion. Then we can use that array to, for example, feed an assertion. It gives us a way to make our tests less brittle. If we'd hardcoded the number of rows we expected and later added a recipe to our fixture, this test would have broken. Now it's more general and won't need to be changed just for that.

Also notice that when we create the new recipe here, we don't do a direct add to the database with Recipe.new and Recipe.sav like we did in our Unit tests. We use the Test Framework to send Rails just what it would see if the request came in from a browser like we did in our Functional tests.

OK, then. I think that covers the home page. We've validated the presence of all the content, including all the links we expect to find here, and we've validated the functionality of the links that we expect to return us here; the delete and create links. So, let's move on to the pages that the other links take us to. Since we just finished verifying its functionality as seen from the home page, why don't we make sure the page itself contains the elements it will need to have to do that? And any links to other that return it there or send the visitor to other pages we haven't seen yet too, of course. We should start at the highest level, like we did with the home page. You ready, Boss?

Boss: Sure, CB. The "Create a recipe" page? When I go to the "Create a recipe" page I want it to have the same title as the home page. And it should have boxes to enter the information. And a button to save it.

CB: Paul, would you mind browsing over to that page? If memory serves, I think there's another link on that page too.

create recipe page
Figure 26

Boss: Oh yeah. That's right. There's a Back link too. We'd better make sure we have a test for that too.

CB: I agree. The question we need to answer, though, is whether we treat it as a content test or a functionality test. In general, I like to treat the test structure recursively. Go to a page, check the content of the page, and check the functionality of the links or buttons on the page that, from a visitor perspective, leave us on the same page. Then, for each link or button on the page that takes us to another page, do the same thing. If that page has links or buttons that take us to pages we haven't already tested, follow them in a similar fashion.

So, for the Back link here, I think all we need to do is make sure the link is there and that it points to the right place. You OK with that approach, Boss?

Boss: That sounds good, CB. If you take that approach, I can see that you'll cover all the site's pages and functionality. And, it feels like it'll be easy to review too; it's a pattern. Just start at the home page and follow the pattern 'til you reach the end. I like it. And it means I can take off now. I've got a meeting to go to. Could you finish up with Paul? Later in the week he and I will go over the rest of the tests you two come up with and decide where we go from there. How's that sound to you?

CB: Sounds good, Boss. I think we've covered all the bases in terms of the statements and techniques we need right now. Paul and I will just do more of the same to flush out the rest of the site's tests. I'll look forward to hearing from you real soon to see what we do next.

Boss: Thanks, CB. With a little luck, it won't take me as long to get back to you as it did this time ;-)

Boss takes off for his next meeting, and CB and Paul finish writing the rest of the tests for their app as it exists today. Readers can download the cookbook2 app, including all the tests, here.

Articles in this series

Bill Walton is a software development/project management consultant/contractor.

Return to O'Reilly Ruby.

Copyright © 2009 O'Reilly Media, Inc.