Testing in Rails: Part 3 - Unit Testing Ruby Classes (cont.)

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.

Testing the colors Method

Now that we have examined the thought process behind writing tests, I will move more quickly through test_colors and test_full_name. test_colors is simple: invoking colors on the class Car will return an array of colors. How much and what kind of testing this requires is up to you and really depends on your application’s usage. Here are some examples:

def test_colors
    assert(Car.colors.kind_of?(Array))
    assert_equal(4, Car.colors.length)
    assert_equal(['blue', 'black', 'red', 'green'], Car.colors)
    assert(Car.colors.include?(@volvo.color))
    assert(Car.colors.include?(@honda.color))
    assert(!Car.colors.include?(@dodge.color))
    assert(!Car.colors.include?(Car.new.color))
  end

For your usage, you may think that it is only important to know that it is an Array. Or maybe you just want to test to make sure that its length is greater than 0. By testing that the length is exactly 4 and that the colors are exactly ['blue', 'black', 'red', 'green'], we have made our tests “brittle”, meaning that they will break easily. A month from now if another developer on your team adds ‘orange’ as a color, your tests will start failing. Maybe that is desirable—maybe you want to have a red flag raised if those values change. Or maybe you would decide to leave those two tests out to make your tests list brittle. Again, it will depend entirely on what you need the colors method to do reliably for your application. You may decide not to test the colors method at all.

You probably noticed that colors is a class method, not an instance method. Should we test that somehow? No. You do not need to make sure that an invalid method call like @volvo.colors fails. Of course it fails; it is bad Ruby. If you call it wrong you will get an error. You do not need to feed bad programming into your tests to make sure that you get errors out. Trust me, you will. What you want is for testing to reveal any places in your code where you have already made mistakes like calling @volvo.colors.

Testing the full_name Method

Writing test_full_name is simple. It follows a pattern I use a lot: test the initial state, then change all the values that should matter, then test the final state.

def test_full_name
    assert_equal('2007 Volvo (green)', @volvo.full_name)
    @volvo.model = 'Nissan'
    @volvo.year = 2006
    @volvo.color = 'black'
    assert_equal('2006 Nissan (black)', @volvo.full_name)
  end

If the usage is simple enough I sometimes find it helpful to use abstract values.

def test_full_name
    assert_equal('2007 Volvo (green)', @volvo.full_name)
    @volvo.model = 'foo'
    @volvo.year = 1
    @volvo.color = 'bar'
    assert_equal('1 foo (bar)', @volvo.full_name)
  end

Testing Attributes

We have written at least one test for each method in the Car class. Those are the most important tests. But we still have not tested everything. What about the attr_accessor declaration at the top? It would be a good idea to test that too.

You might think, “Attributes are simple. What could go wrong with them?” You might have used attr_accessor when you meant to use attr_reader, or attr_writer when you meant to use attr_accessor. You might have misspelled the attribute name or used incorrect syntax. If our goal is to test everything that needs to work in our application, then the attribute declaration needs testing.

You might think, “But if those attributes are not working, then my other unit tests will fail.” That assumes that our class code references the attribute. It does not necessarily reference all of them. Let’s suppose that we added the attribute :for_sale to our class. Our unit tests would give you no indication if :for_sale was working properly.

To see how this could cause problems, let’s suppose that we create another class called CarLot which keeps track of which cars are for sale. CarLot would access and depend on the attribute :for_sale in Car. You would not want to depend on CarLot’s unit tests to reveal a problem in the Car class. What if CarLot was a legacy application on another server without any unit tests? You could easily end up in a situation where both classes assume that the other class is performing the unit test on the :for_sale attribute, but neither is testing it.

Instead, it is better to write some simple tests that state the obvious—that an attribute exists, that you can write to it, and that you can read from it.

def test_attributes
    new_car = Car.new
    # write to the attributes
    new_car.model = 'Test'
    new_car.year = 25
    new_car.color = 'test color'
    # read from the attributes
    assert_equal('Test', new_car.model)
    assert_equal(25, new_car.year)
    assert_equal('test color', new_car.color)
  end

Testing an attr_reader and attr_writer is a little tricker. You will need to set the value of the attr_reader somewhere else, like initialize. Let’s add two attributes to try it out.

class Car
  attr_reader :wheels
  attr_writer :doors
 
  def initialize(options={})
    ...
    @wheels = 4
  end
 
end

In order to test that an attr_reader can’t be written and that an attr_writer can’t be read, we will need to make use of a new assertion that tests whether an error message is raised.

def test_attributes
    ...
    #test the attr_reader and attr_writer
    # :wheels can be read but not written
    assert_equal(4, new_car.wheels)
    assert_raise(NoMethodError) { new_car.wheels = 6 }
   
    # :doors can be written but not read
    new_car.doors = 2
    assert_raise(NoMethodError) { new_car.doors }
  end

Notice that assert_raise first takes an argument which is the exception expected, in this case NoMethodError, and then takes a block for the code which should raise that error. Like all blocks it can be a multi-line statement too. assert_raise also has an opposite assertion, assert_nothing_raised, which you could make use of in the cases when the attributes should work.

def test_attributes
    ...
    #test the attr_reader and attr_writer
    # :wheels can be read but not written
    assert_nothing_raised(NoMethodError) { new_car.wheels }
    assert_raise(NoMethodError) { new_car.wheels = 6 }

    # :doors can be written but not read
    assert_nothing_raised { new_car.doors = 2 }
    assert_raise(NoMethodError) { new_car.doors }
  end

Notice in the third assertion I left out the Exception name argument. This is the form to use to indicate that no exceptions of any kind should be raised.

It is up to you, but it probably makes more sense to use assert_raise and assert_nothing_raised than it does to use assert_equal. Proof that an attribute is working is that it does not raise an error. Using assert_raise and assert_nothing_raised will “catch” those errors and return true or false, not testing errors. Using assert_equal will identify attribute problems, but it will do that by returning those errors as testing errors, not failed assertions. In my opinion it is better form to have tests that function but fail instead of tests that stop functioning. (That goes for all three attribute types including attr_accessor.)

These attribute tests may seem trivial, and I will not pretend that they are as important as the others, but they are worth doing all the same. Then you will know you have tested everything.

UPDATE: The series continues in Part 4.

3 Responses to “Testing in Rails: Part 3 - Unit Testing Ruby Classes (cont.)”

  1. Richard OnRails Says:

    Hi Kevin,

    This is another great chapter.

    I had to wonder when I encountered:
    “assert_equal(['blue', 'black', 'red', 'green'], Car.colors)”,
    whether Array comparisons are order sensitive.

    I’m sure you know they are, and that Sets are the easy solution. But I imagine you thought that introducing Sets would make this tutorial unnecessarily difficult for beginners.

    Best wishes,
    Richard

  2. skg Says:

    Hi
    Very Nice Tutorial.Thanks for publishing this.One thing, when i tried
    assert_raise(NoMethodError) { new_car.wheels=6 }
    it does not give any exceptions.Is that what expected?But if give
    ret_val=assert_raise(NoMethodError) { new_car.wheels=6 }
    puts ret_val then the result is
    Loaded suite car_test
    Started
    undefined method `wheels=’ for #
    .F…
    Finished in 0.030879 seconds.
    1) Failure:
    test_colors(CarTest) [car_test.rb:57]:
    is not true.
    5 tests, 27 assertions, 1 failures, 0 errors

  3. Kevin Skoglund Says:

    @skg: With assert_raise you won’t see the exception, assert_raise catches it. You should only get a passed or failed test. It sounds like your test passed.

Leave a Reply