Cookin' with Ruby on Rails - More Designing for Testability

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.

CB: Hi, Paul. You ready to get started on our Functional tests?

Paul: You bet, CB. But I've been thinking about the Unit tests we worked on last time, and I have this nagging feeling that maybe we called them done too soon. Before we get started on the Functional tests, would you mind if we took a quick look at the app?

CB: Of course not! Let me get it fired up. What's bugging you?

Paul: Remember when we came across that problem (Figure 22, "Cookin' with Ruby on Rails: Designing for Testability") with the categories fixture not loading because there were records in the recipes table? You reminded me that we'd actually seen that problem before when we were working on the app with Boss. You said you'd put a quick fix in place that we really needed to revisit. We fixed the fixture loading problem, but we never revisited that code. I'm thinking we ought to take a quick look at that and see if we're really done with our Unit tests.

CB: I'm glad you reminded me, Paul. I do remember now. I think I said something like, "let's tackle it in two pieces," but we got focused on getting the tests working and I completely forgot to go back and do the second part. If I remember right, we were talking about the code that prevented visitors from deleting a category that had recipes assigned to it. Let's take a look at the code in our category controller.

our "don't delete categories with recipes" hack
Figure 1

Yep. I'd say we need to fix this now, before it gets any worse. What we're doing here works, but it really ought to be handled in the model instead of the controller.

Paul: Why do you say that? That it ought to be handled in the model, I mean?

CB: Mainly because this is business logic. The question we need to come back to Boss with is: "How do you want to handle it if a visitor tries to delete a category that has recipes?" There are lots of technical options. We could delete the category and all the recipes under it. We could have some sort of default category like "Unassigned" and re-assign all the recipes for the category being deleted. Maybe he only wants to allow certain visitors to delete categories. The point is, that's his call because those are business decisions. And in the MVC pattern, business logic belongs in the model. Another way to think about it is that this is really a validation. Last time we got together, we put validations in place to prevent category objects from being saved to the database unless they met certain conditions. This is a validation to prevent category records from being deleted from the database unless they meet certain conditions.

Paul: Cool. I was pretty much thinking the same things, but I wanted to make sure we were on the same page. So, how do we proceed?

CB: Let's start by doing a reset on where we are. Let's rerun our Unit tests for the category model. I'll open a command window and...

ruby test\unit\category_test.rb

rerunning the category Unit tests
Figure 2

Note to reader: If you get errors when running the category Unit test at this point, it probably means you have leftover recipe records in your test database. Run recipe_test.rb to remove them.

Now let's take the wrapper off that code in the category controller so we'll have an app that breaks.

wrapper removed
Figure 3

And now let's check our app. Start Mongrel, browse to http://localhost:3000/category/list, and then try to delete one of the categories.

Yep, we broke it.
Figure 4

CB: Yep. We broke it. So now, let's rerun our tests.

Oops. Our tests don't catch it.
Figure 5

Paul: So our app's broke, but our tests don't catch it.

CB: Exactly. If we'd written our tests before we wrote the code, we probably wouldn't be here. The truth is, though, there'll always be times when we'll miss a test we should have written and have to go back and add it after we've found a new way to crash our app. But writing the tests first, thinking it through before we write the code, will make it happen a lot less than doing it after the fact. So let's write a failing test that shows us the problem and tells us how to fix it.

Let's think this through a little before we start writing our test. We want to make sure we write a test that ensures our app works the way we intend it to work. We need a test to make sure that a category record that has a child record can't be deleted. Problem is, from a test perspective, we've made sure that there aren't any child records. We added a teardown method to our recipe fixture to make sure that all our recipe records got cleared out of our test database after the recipe tests were run. Otherwise, our category fixture wouldn't load. So, to start, we're going to have to make sure the category record we're trying to delete has a child recipe.

We know from the error we've already seen that, when we try to delete the category record, Rails is going to raise an exception. In Rails, some exceptions are worse than others. If we try to find a record that doesn't exist, for example, Rails raises a RecordNotFound exception. We can use that if we want, or we can just check the result to see if it's empty. The StamentInvalid exception we're getting here crashes our app, and it'll crash our tests too. Luckily, Rails gives us a built-in assertion to handle it in our tests so that the test method reports a failure instead of crashing the test case.

First, let's open up the categories fixture and pick a record.

records to pick from
Figure 6

CB: I think I'll use the beverages record. So, now I'll open up test\unit\category_test.rb and add a new test method.

def test_cannot_delete_record_with_child

   category_with_child = Category.find_by_name("beverages")
   assert_not_nil category_with_child
   new_recipe = => "test drink",
                            :category_id =>
   recipe_exists =
   assert recipe_exists
   assert_nothing_raised(ActiveRecord::StatementInvalid) {category_with_child.destroy}
   category_with_child = Category.find_by_name("beverages")
   assert_not_nil category_with_child


Note to reader: The format for the assert_nothing_raised method is: assert_nothing_raised (exception) {block}. Make sure that {category_with_child.destroy} appears on the same line as the rest of the method in your code, not broken onto a second line as shown above.

Pages: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11

Next Pagearrow