Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

rspec design pattern for testing multiple data points

I often find that in writing tests for a method I want to throw a bunch of different inputs at the method and simply check whether the output is what I expected.

As a trivial example, suppose I'm testing my_square_function which squares numbers and intelligently handles nil.

The following code seems to do the job, but I'm wondering whether there's a best practice that I should be using (e.g. using subject, context):

describe "my_square_function" do
  @tests = [{:input => 1, :result => 1},
            {:input => -1, :result => 1},
            {:input => 2, :result => 4},
            {:input => nil, :result => nil}]
  @tests.each do |test|
    it "squares #{test[:input].inspect} and gets #{test[:result].inspect}" do
      my_square_function(test[:input]).should == test[:result]
    end
  end
end

Suggestions?

Thanks!

(Related: rspec refactoring?)

like image 872
brahn Avatar asked Dec 06 '22 18:12

brahn


2 Answers

I would associate the input and the expected result in a hash in a simpler way that shown, and iterate on the hash:

describe "my_square_function" do
  @tests = {1  => 1,
            -1  => 1,
            2  => 4,
            nil => nil}

  @tests.each do |input, expected|
    it "squares #{input} and gets #{expected}" do
      my_square_function(input).should == expected
    end
  end
end
like image 127
philant Avatar answered Dec 09 '22 15:12

philant


Sorry for such a long answer, but I thought my thought process would be more coherent if I went through it all.

Since this question is tagged with TDD, I'll assume you are writing the method TDD style. If that it the case, you may want to start with:

 describe "my_square_function" do
   it "Squares a positive number" do
     my_square_function(1).should == 1
   end
 end

Having a failing test, you may implement my_square_function as follows:

 def my_square_function(number)
   1
 end

Now that the test is passing you want to refactor out duplication. In this case, the duplication is between the code and the test, that is the literal 1. Since argument carries the value of the test, we can remove the duplication by using the argument instead.

 def my_square_function(number)
   number
 end

Now that duplication has been removed and the tests still pass, we can move to the next test:

 describe "my_square_function" do
   it "Squares a positive number" do
     my_square_function(1).should == 1
   end

   it "Squares a negative number" do
     my_square_function(-1).should == 1
   end
 end

Running the tests you are again greeted with a failing test, so we make it pass:

 def my_square_function(number)
   number.abs   # of course I probably wouldn't really do this but
                # hey, it's an example. :-)
 end

Now this test passes and it's time to move on to another test:

 describe "my_square_function" do
   it "Squares a positive number" do
     my_square_function(1).should == 1
   end

   it "Squares a negative number" do
     my_square_function(-1).should == 1
   end

   it "Squares other positive numbers" do
     my_square_function(2).should == 4
   end
 end

At this point, your newest test will no longer pass, so now to make it pass:

 def my_square_function(number)
   number.abs * number
 end

Oops. That didn't quite work, it caused our negative number test to fail. Fortunately the failure pointed us back to the exact test that didn't work, we know it failed due to the "negative" test. Back to the code:

 def my_square_function(number)
   number.abs * number.abs
 end

That's better, all of our tests pass now. It's time to refactor again. Here we see some other uneccessary code in those calls to abs. We can get rid of them:

 def my_square_function(number)
   number * number
 end

The tests still pass and we see some more duplication with that pesky argument. Let's see if we can get rid of it:

 def my_square_function(number)
   number ** 2
 end

The test pass and we don't have that duplication any longer. Now that we have a clean implementation, let's handle the nil case next:

 describe "my_square_function" do
   it "Squares a positive number" do
     my_square_function(1).should == 1
   end

   it "Squares a negative number" do
     my_square_function(-1).should == 1
   end

   it "Squares other positive numbers" do
     my_square_function(2).should == 4
   end

   it "Doesn't try to process 'nil' arguments" do
     my_square_function(nil).should == nil
   end
 end

Ok, we're back to failing again and we can go ahead and implement the nil check:

 def my_square_function(number)
   number ** 2 unless number == nil
 end

This test passes and it's pretty clean, so we'll leave it as is. Now we go back to the spec and see what we've got and verify we like what we see:

 describe "my_square_function" do
   it "Squares a positive number" do
     my_square_function(1).should == 1
   end

   it "Squares a negative number" do
     my_square_function(-1).should == 1
   end

   it "Squares other positive numbers" do
     my_square_function(2).should == 4
   end

   it "Doesn't try to process 'nil' arguments" do
     my_square_function(nil).should == nil
   end
 end

My first inclination is that we're really describing the behavior of "squaring" a number, not the function itself, so we'll change that:

 describe "How to square a number" do
   it "Squares a positive number" do
     my_square_function(1).should == 1
   end

   it "Squares a negative number" do
     my_square_function(-1).should == 1
   end

   it "Squares other positive numbers" do
     my_square_function(2).should == 4
   end

   it "Doesn't try to process 'nil' arguments" do
     my_square_function(nil).should == nil
   end
 end

Now, the three example names are a little squishy when put into that context. I'm going to start with the first example, it seem's a little cheesy to square 1. This is a choice I'm going to make to reduce the number of examples in the code. I really want the examples to be interesting in some fashion or I won't test them. The difference between squaring 1 and 2 is uninteresting so I'll remove the first example. It was useful at first, but not any longer. That leaves us with:

 describe "How to square a number" do
   it "Squares a negative number" do
     my_square_function(-1).should == 1
   end

   it "Squares other positive numbers" do
     my_square_function(2).should == 4
   end

   it "Doesn't try to process 'nil' arguments" do
     my_square_function(nil).should == nil
   end
 end

The next thing I'm going to look at is the negative example as it relates to the context in the describe block. I'm going to give it and the rest of the examples new descriptions:

 describe "How to square a number" do
   it "Squaring a number is simply the number multiplied by itself" do
     my_square_function(2).should == 4
   end

   it "The square of a negative number is positive" do
     my_square_function(-1).should == 1
   end

   it "It is not possible to square a 'nil' value" do
     my_square_function(nil).should == nil
   end
 end

Now that we've limited the number of test cases to the most interesting ones, we don't really have too many to deal with. As we saw above, it was nice knowing exactly which line the failures occurred on in case it was another test case that we didn't expect to fail. By building a list of scenarios to run through we lose that feature making it harder to debug failures. Now, we could replace the examples with dynamically generated it blocks as was mentioned in another solution, however we start to lose the behavior we're trying to describe.

So, in summary, by limiting your tested scenarios to only those that describe interesting characteristics of the system, your need for too many scenarios will be reduced. On a more complex system having that many scenarios likely highlights that the object model probably needs another look.

Hope that helps!

Brandon

like image 41
bcarlso Avatar answered Dec 09 '22 15:12

bcarlso