Testing in Rails: Part 2 - Unit Testing Ruby Classes

Cars

This is part of an ongoing series of posts about how to get started writing tests for Ruby on Rails. The series begins with the introduction and overview of the ideas behind testing.

Unit Testing Classes and Instances

Any Ruby code can be tested with TestUnit. Working with classes and instances of objects is no different. Let’s create definition for a Car class (keeping with the car theme from the last section). Then we can write tests that will test the Car class. We will also spend some time considering what we should test and how we can test it.

For starters, create a new text file called “car_test.rb”, put the following code inside and save it someplace you can find easily.

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 'test/unit'

class Car
 
  attr_accessor :model, :year, :color
 
  def initialize(options={})
    self.model = options[:model] || 'Volvo'
    self.year = options[:year] || 2007
    self.color = options[:color] || 'unknown'
  end
 
  def self.colors
    ['blue', 'black', 'red', 'green']
  end
 
  def full_name
    "#{self.year.to_s} #{self.model} (#{self.color})"
  end
 
end

class CarTest < Test::Unit::TestCase

  def setup
    @volvo = Car.new(:color => 'green')
  end
 
  def test_truth
    assert(@volvo.kind_of?(Car))
  end
 
end

It does not matter that the class definition and the test are in the same file. (You could also use ‘require’ to put them in different files.) The important part is that any tests that were defined will be performed once reading the file is complete. In order: the unit test library gets included, the Car class gets defined, the test class gets defined, and then the tests are run.

Our test definition is just a skeleton at this point, we have not actually tested anything meaningful. It should be pretty obvious that if we assign a new Car to an instance variable when we test that instance variable it will be of the class Car. While it does not test much, this simple assertion does reassure us that all the basics are working (the Car object, our test case, and setup). If you are ever concerned that your tests are not working at all, this is a simple way to check. But most of the time we will be confident that our testing environment is functioning so we can remove that test and write some better ones.

Notice that I named the instance variable ‘@volvo’ instead of ‘@car’. Testing will be easiest if you use descriptive names. ‘@green_car’ or ‘@green_volvo’ might have been other choices. You might even find it helpful to go further and turn them into a narrative of sorts, like ‘@dads_volvo’ or ‘@marys_first_car’. Meaningful names are easier for your brain to hold and work with than abstract names. When your tests get more complicated, it will be easier to “test that since Dad’s volvo is older than Mary’s first car, Dad’s trade-in value should be less than Mary’s” than to “test that car1 is worth less than car2″.

Let’s add two other cars to our setup method. That way we will always have three cars available to use in our tests.

1
2
3
4
5
def setup
    @volvo = Car.new(:color => 'green')
    @honda = Car.new(:model => 'Honda', :year => 2004, :color => 'blue')
    @dodge = Car.new(:model => 'Dodge', :year => 2003, :color => 'yellow')
  end

Try running your test file from the command line with ruby car_test.rb. (Assuming you are already in the same directory as ‘car_test.rb’.)

What Should You Test?

Now that we have a Car class and a skeleton for our test class we have to decide what tests to write. This is the stumbling point for a lot of beginners. What should you test? It can feel a bit like looking at a blank page when you have writer’s block.

We know we want to test everything we can about Car. We know that in every test we want to find out if the Car class behaves as we expect. When we do X does it do Y? Our tests are really just a list of those X to Y relationships. If we can come up with a list of all of Car’s behaviors, we would have a good outline for writing our tests.

What are the behaviors of the Car class? 1) The class can create an instance that has been initialized with default or custom values. 2) The class can report a list of colors. 3) An instance of the class can tell us its full name. Those three behaviors correspond directly to the three methods of Car. So let’s create an outline with each method as a test!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CarTest < Test::Unit::TestCase

  def setup
    ...
  end
 
  def test_initialize
  end

  def test_colors
  end
 
  def test_full_name
  end
 
end

With our outline in place, now we can focus on each test in turn.

Developing Useful Tests

Let’s start by writing a definition for ‘test_initialize’. What should the initialize method of Car do? When does it get called? What are its behaviors? What are its requirements? What are the results? Try to say the sentences out loud in words before you try to put it into testing code.

Here is what I would answer: After a new instance of Car is created, the initialize method should be called and passed any arguments originally passed to Car.new. Initialize should accept an optional hash of options. Initialize should set model, year, and color attributes on the instance, either to a value provided in the options hash or to a default value.

The first trait I listed—that initialize gets called after Car.new—is not something we need to test in this case. This is a behavior of the Ruby language and we can assume that Ruby is working properly. But it is still worth considering because, in other cases, it might be making use of a custom method instead of a standard Ruby method.

But the other traits of initialize do need testing. We said it should accept a hash of options. Does it? Is the hash optional? We said it should set the attributes of the instance. Does it set them to the correct values in the option hash? Does it use default values when the option hash does not include a value? Write the code that will test those statements.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def test_initialize
    # try creating a new object without the optional options hash
    default_car = Car.new
    # test that default values were used
    assert_equal('Volvo', default_car.model)
    assert_equal(2007, default_car.year)
    assert_equal('unknown', default_car.color)

    # let's try again but with values in an option hash
    custom_car = Car.new(:model => 'Toyota', :year => 2005, :color => 'white')
    # test that options values were used instead of defaults
    assert_equal('Toyota', custom_car.model)
    assert_equal(2005, custom_car.year)
    assert_equal('white', custom_car.color)
  end

We now have a standard usage test for the initialize method. If it passes those tests, then we have the fundamental usage sorted out. Could we have broken that test into two tests? Definitely. It is only a matter of style. Some programmers would prefer to have “test_initialize_default_values” and “test_initialize_with_custom_values”. Make your outline using one test for each method, but feel free to expand on that outline as you go along—it is only meant to be a guide. If a test starts to get long and complicated, it might be better to break it up into smaller tests than to allow it slow down your programming and debugging. (On the flip-side, keep in mind that there is some small overhead from running setup each time.)

But before we can call test_initialize done, it is important to stop and think about what could go wrong with the initialize method. We do not want to just make sure that it works in a “best case scenario”, we want to make sure that it is robust and will work in every scenario.

For example, as I review the code, I notice that I am always referencing :model, :year, and :color in the same order. Have I inadvertently made an assumption about their order? Their usage here is so simple that I probably haven’t, but if had used a method like hash.shift, hash.keys, array[0], or array.last then testing might expose a problem. In a similar vein, we are testing sending no options and all options, but we are not testing for when sending a few options. Both are easy to test.

1
2
3
4
5
6
7
8
9
def test_initialize
    ...
   
    # leaving out :year; sending :color and :model out of order
    shuffled_car = Car.new(:color => 'red', :model => 'Kia')
    assert_equal('Kia', shuffled_car.model)
    assert_equal(2007, shuffled_car.year)
    assert_equal('red', shuffled_car.color)
  end

Of course we could test many other combinations on this theme. We do not need to try them all. We only need enough tests to feel secure that omitting a value and reordering values is not going to break initialize.

Let’s consider if there are more ways that initialize could break. I notice that another assumption in the code is that :year will be passed in as an integer. It is subtle, because we do not do much with the value besides set an attribute. But from the default value, it is clear we are expecting an integer. Let’s write a test for it.

1
2
3
4
5
6
7
def test_initialize
    ...
   
    # testing :year as a string
    string_car = Car.new(:year => '2000')
    assert_equal(2000, string_car.year)
  end

The test failed. Do you change your test or change your code? If you need to rely on string_car.year always being an integer, then I would suggest changing your code. If you want string_car.year to be able to accept either a string or an integer, then you change your test assertion so that “a string in results in a string out”. In most cases, it will probably the former.

1
2
3
4
5
6
7
8
9
10
11
class Car
  ...
 
  def initialize(options={})
    self.model = options[:model] || 'Volvo'
    self.year = (options[:year] || 2007).to_i
    self.color = options[:color] || 'unknown'
  end
 
  ...
end

And now our tests pass again. (If it is not immediately clear why I did not use self.year = options[:year].to_i || 2007, try it for yourself and run the tests.) You see now how the act of writing tests can test your assumptions and make your code more error resistant.

Should we also test that :model and :color are strings? Again, it depends on your usage and your programming habits. For me, partly because form and URL values are submitted as strings, I do not find that I have the same typecasting problems with strings that I do with integers. Most times, I would feel comfortable assuming that :model => 1034 would not be a case that needed consideration or tests. It might be necessary in a more complex method, but I do not think it is necessary here.

We spent a lot of time on our first set of tests because I wanted to walk you through the mental process that goes along with developing good tests. We will not need to spend as much time from now on. Just remember to ask yourself:

  • What should this method should do?
  • What are this method’s characteristics?
  • What are the assumptions this method makes?
  • What assumptions might I have made inadvertently when creating, using or testing this method?
  • What usage cases might cause this method to malfunction?

Try writing the tests for test_colors and test_full_name on your own. In the next section I’ll walk you through how I wrote them and you can check your results.

UPDATE: The series continues in Part 3.

Bookmark and Share

2 Responses to “Testing in Rails: Part 2 - Unit Testing Ruby Classes”

  1. Jeff Schoolcraft Says:

    I’ve been following along, and it’s been a fun series. I sat down to start writing out your examples and I guess I’m missing something because I’m a ruby neophyte.

    in your test_initialize, when you do default_car = Car.new and don’t pass it the hash, that doesn’t work for me.

    I get an ArgumentException:

    1) Error:
    test_initialize(CarTest):
    ArgumentError: wrong number of arguments (0 for 1)
    car_test.rb:34:in `initialize’
    car_test.rb:34:in `new’
    car_test.rb:34:in `test_initialize’

    I’m set up almost identically to your example:

    class Car
    attr_accessor :model, :year, :color

    def initialize(options)
    self.model = options[:model] || ‘Volvo’
    self.year = (options[:year] || 2008).to_i
    self.color = options[:color] || ‘unknown’
    end

    end

    and CarTest:

    def test_initialize
    [34] default_car = Car.new
    assert_equal(’volvo’, default_car.model)
    assert_equal(2008, default_car.year)
    assert_equal(’unknown’, default_car.color)

    end

    Line [34] is marked.

    my ruby version: ruby 1.8.6 (2007-06-07 patchlevel 36) [i486-linux]

    Everything else works as expected, as long as I’m passing in some hash. What is it that I’m not getting?

  2. Kevin Skoglund Says:

    Jeff, I made a mistake. My code and my blog post weren’t identical. Thanks for catching it. I fixed it in the tutorial now.

    Change the initialize method in your model to be:

    def initialize(options={})
    self.model = options[:model] || ‘Volvo’
    self.year = options[:year] || 2007
    self.color = options[:color] || ‘unknown’
    end

    Notice the addition of the curly braces after ‘options’. The initialize method had one argument declared and was giving you an error if you didn’t send an argument. Now, if no argument is sent, it will not give an error, but instead set options equal to an empty hash. In Ruby it is always true that the number of arguments sent to a method must match the number declared, unless you provide default values to fall back on.

    On the bright side, now you see firsthand how testing can identify problems! :-P

Leave a Reply