Tuesday, November 23, 2010

Testing Pattern: Data Generated Specs

Just thought of sharing this DRY Ruby testing pattern that facilitates generating a lot of similar specs with different data.

Recently, I wrote specs for a parsing engine that handled each test case with a context describing how the different attribute on a model (Benefit) would be evaluated:

context "Single: $10,000 Group: $20,000" do
describe "single_value" do
it "returns 10000 for Single: $10,000 Group: $20,000" do
benefit = Benefit.new(:value => "Single: $10,000 Group: $20,000")
benefit.single_value.should == 10000
end
end
describe "group_value" do
it "returns 10000 for Single: $10,000 Group: $20,000" do
benefit = Benefit.new(:value => "Single: $10,000 Group: $20,000")
benefit.group_value.should == 20000
end
end
describe "single_value_includes_deductible?" do
it "returns 10000 for Single: $10,000 Group: $20,000" do
benefit = Benefit.new(:value => "Single: $10,000 Group: $20,000")
benefit.single_value_includes_deductible?.should be_true
end
end
describe "group_value_includes_deductible?" do
it "returns 10000 for Single: $10,000 Group: $20,000" do
benefit = Benefit.new(:value => "Single: $10,000 Group: $20,000")
benefit.group_value_includes_deductible?.should be_true
end
end
describe "group_calculation_algorithm" do
it "returns 10000 for Single: $10,000 Group: $20,000" do
benefit = Benefit.new(:value => "Single: $10,000 Group: $20,000")
benefit.group_calculation_algorithm.should == :cap
end
end
end

But, I kept getting more and more business rules for parsing the benefit string values, and I had to add a context block like the one above for every case, so you can imagine how this got out of control very fast and had a ridiculous amount of redundancy:

context "$10,000" do
describe "single_value" do
it "returns 10000 for $10,000" do
benefit = Benefit.new(:value => "Single: $10,000")
benefit.single_value.should == 10000
end
end
describe "group_value" do
it "returns 10000 for $10,000" do
benefit = Benefit.new(:value => "Single: $10,000")
benefit.group_value.should == 10000
end
end
describe "single_value_includes_deductible?" do
it "returns 10000 for $10,000" do
benefit = Benefit.new(:value => "$10,000")
benefit.single_value_includes_deductible?.should be_true
end
end
describe "group_value_includes_deductible?" do
it "returns 10000 for $10,000" do
benefit = Benefit.new(:value => "$10,000")
benefit.group_value_includes_deductible?.should be_true
end
end
describe "group_calculation_algorithm" do
it "returns 10000 for $10,000" do
benefit = Benefit.new(:value => "$10,000")
benefit.group_calculation_algorithm.should == :as_is
end
end
end

context "Single: $10,000 per Member" do
describe "single_value" do
it "returns 10000 for Single: $10,000 per Member" do
benefit = Benefit.new(:value => "Single: $10,000 per Member")
benefit.single_value.should == 10000
end
end
describe "group_value" do
it "returns 10000 for Single: $10,000 per Member" do
benefit = Benefit.new(:value => "Single: $10,000 per Member")
benefit.group_value.should == nil
end
end
describe "single_value_includes_deductible?" do
it "returns 10000 for Single: $10,000 per Member" do
benefit = Benefit.new(:value => "Single: $10,000 per Member")
benefit.single_value_includes_deductible?.should be_true
end
end
describe "group_value_includes_deductible?" do
it "returns 10000 for Single: $10,000 per Member" do
benefit = Benefit.new(:value => "Single: $10,000 per Member")
benefit.group_value_includes_deductible?.should be_true
end
end
describe "group_calculation_algorithm" do
it "returns 10000 for Single: $10,000 per Member" do
benefit = Benefit.new(:value => "Single: $10,000 per Member")
benefit.group_calculation_algorithm.should == :per_person
end
end
end

Finally, I refactored the specs applying what I am calling the "Data Generated Specs" pattern, by writing only one spec as a prototype and feeding in the input and expected output data dynamically through a hash:

{
"Single: $10,000 Group: $20,000" => {
"single_value" => 10000,
"group_value" => 20000,
"single_value_includes_deductible?" => true,
"group_value_includes_deductible?" => true,
"group_calculation_algorithm" => :cap,
},
}.each do |input_value, expected_value|
expected_value.keys.each do |attribute|
describe attribute do
it "returns #{expected_value[attribute]} for #{input_value}" do
benefit = Benefit.new(:value => input_value)
benefit.send(attribute).should == expected_value[attribute]
end
end
end
end

Each test case became represented with a hash key/value block instead of a 30-line code context block, making it much easier to add test cases:

{
"Single: $10,000 Group: $20,000" => {
"single_value" => 10000,
"group_value" => 20000,
"single_value_includes_deductible?" => true,
"group_value_includes_deductible?" => true,
"group_calculation_algorithm" => :cap,
},
"$10,500" => {
"single_value" => 10500,
"group_value" => 10500,
"single_value_includes_deductible?" => true,
"group_value_includes_deductible?" => true,
"group_calculation_algorithm" => :as_is,
},
"Single: $10,500 per Member" => {
"single_value" => 10500,
"group_value" => nil,
"single_value_includes_deductible?" => true,
"group_value_includes_deductible?" => true,
"group_calculation_algorithm" => :per_person,
},
"Single: $10,500 per Member (Deductible not included)" => {
"single_value" => 10500,
"group_value" => nil,
"single_value_includes_deductible?" => false,
"group_value_includes_deductible?" => false,
"group_calculation_algorithm" => :per_person,
},
"See brochure for details" => {
"single_value" => nil,
"group_value" => nil,
"single_value_includes_deductible?" => false,
"group_value_includes_deductible?" => false,
"group_calculation_algorithm" => nil,
},
}.each do |input_value, expected_value|
expected_value.keys.each do |attribute|
describe attribute do
it "returns #{expected_value[attribute]} for #{input_value}" do
benefit = Benefit.new(:value => input_value)
benefit.send(attribute).should == expected_value[attribute]
end
end
end
end

Since specs are generated with their titles, when they fail, you get a nice clear description for the failure that includes the attribute name as well as the input and output values:

1) Benefit attribute single_value returns 0 for Single: $10,500 per Member
Failure/Error: benefit.send(attribute).should == expected_value[attribute]
expected: 10500,
got: 0 (using ==)
# ./spec/models/benefit_spec.rb:464

I generated about 1000+ tests with this technique. While reviewing some of the cases with a non-technical client, she liked reading the spec data so much that she requested a copy for herself to review and validate against the business rules. Specs as acceptance tests FTW!

1 comment:

just3ws said...

Neat, it reminds me of the Cucumber Scenario Outlines https://github.com/aslakhellesoy/cucumber/blob/master/examples/i18n/en/features/addition.feature