(For more resources related to this topic, see here.)
Making specs more concise (Intermediate)
So far, we’ve written specifications that work in the spirit of unit testing, but we’re not yet taking advantage of any of the important features of RSpec to make writing tests more fluid. The specs illustrated so far closely resemble unit testing patterns and have multiple assertions in each spec.
How to do it…
- Refactor our specs in
spec/lib/location_spec.rb
to make them more concise:require "spec_helper" describe Location do describe "#initialize" do subject { Location.new(:latitude => 38.911268, :longitude => -77.444243) } its (:latitude) { should == 38.911268 } its (:longitude) { should == -77.444243 } end end
- While running the spec, you see a clean output because we’ve separated multiple assertions into their own specifications:
Location #initialize latitude should == 38.911268 longitude should == -77.444243 Finished in 0.00058 seconds 2 examples, 0 failures
The preceding output requires either the
.rspec
file to contain the--format doc
line, or when executing rspec in the command line, the--format doc
argument must be passed. The default output format will print dots (.
) for passing tests, asterisks (*
) for pending tests,E
for errors, andF
for failures. - It is time to add something meatier. As part of our project, we’ll want to determine if
Location
is within a certain mile radius of another point. - In
spec/lib/location_spec.rb
, we’ll write some tests, starting with a new block calledcontext
. The first spec we want to write is the happy path test. Then, we’ll write tests to drive out other states. I am going to re-use ourLocation
instance for multiple examples, so I’ll refactor that into another new construct, alet
block:require "spec_helper" describe Location do let(:latitude) { 38.911268 } let(:longitude) { -77.444243 } let(:air_space) { Location.new(:latitude => 38.911268,: longitude => -77.444243) } describe "#initialize" do subject { air_space } its (:latitude) { should == latitude } its (:longitude) { should == longitude } end end
- Because we’ve just refactored, we’ll execute
rspec
and see the specs pass. - Now, let’s spec out a
Location#near?
method by writing the code we wish we had:describe "#near?" do context "when within the specified radius" do subject { air_space.near?(latitude, longitude, 1) } it { should be_true } end end end
- Running
rspec
now results in failure because there’s noLocation#near?
method defined. - The following is the naive implementation that passes the test (
in lib/location.rb
):def near?(latitude, longitude, mile_radius) true end
- Now, we can drive a failure case, which will force a real implementation in
spec/lib/location_spec.rb
within thedescribe "#near?"
block:context "when outside the specified radius" do subject { air_space.near?(latitude * 10, longitude * 10, 1) } it { should be_false } end
- Running the specs now results in the expected failure.
- The following is a passing implementation of the haversine formula in
lib/location.rb
that satisfies both cases:R = 3_959 # Earth's radius in miles, approx def near?(lat, long, mile_radius) to_radians = Proc.new { |d| d * Math::PI / 180 } dist_lat = to_radians.call(lat - self.latitude) dist_long = to_radians.call(long - self.longitude) lat1 = to_radians.call(self.latitude) lat2 = to_radians.call(lat) a = Math.sin(dist_lat/2) * Math.sin(dist_lat/2) + Math.sin(dist_long/2) * Math.sin(dist_long/2) * Math.cos(lat1) * Math.cos(lat2) c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)) (R * c) <= mile_radius end
- Refactor both of the previous tests to be more expressive by utilizing predicate matchers:
describe "#near?" do context "when within the specified radius" do subject { air_space } it { should be_near(latitude, longitude, 1) } end context "when outside the specified radius" do subject { air_space } it { should_not be_near(latitude * 10, longitude * 10, 1) } end end
- Now that we have a passing spec for
#near?
, we can alleviate a problem with our implementation. The#near?
method is too complicated. It could be a pain to try and maintain this code in future. Refactor for ease of maintenance while ensuring that the specs still pass:R = 3_959 # Earth's radius in miles, approx def near?(lat, long, mile_radius) loc = Location.new(:latitude => lat,:longitude => long) R * haversine_distance(loc) <= mile_radius end private def to_radians(degrees) degrees * Math::PI / 180 end def haversine_distance(loc) dist_lat = to_radians(loc.latitude - self.latitude) dist_long = to_radians(loc.longitude - self.longitude) lat1 = to_radians(self.latitude) lat2 = to_radians(loc.latitude) a = Math.sin(dist_lat/2) * Math.sin(dist_lat/2) +Math.sin(dist_long/2) * Math.sin(dist_long/2) *Math.cos(lat1) * Math.cos(lat2) 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)) end
- Finally, run
rspec
again and see that the tests continue to pass. A successful refactor!
How it works…
The subject
block takes the return statement of the block—a new instance of Location
in the previous example—and binds it to a locally scoped variable named subject
. Subsequent it
and its
blocks can refer to that subject
variable. Furthermore, the its
blocks implicitly operate on the subject
variable to produce more concise tests.
Here is an example illustrating how subject
is used to produce easier-to-read tests:
describe "Example" do subject { { :key1 => "value1", :key2 => "value2" } } it "should have a size of 2" do subject.size.should == 2 end end
We can use subject
from within the it
block and this will refer to the anonymous hash returned by the subject
block. In the preceding test, we could have been more concise with an its
block:
its (:size) { should == 2 }
We’re not limited to just sending symbols to an its
block—we can use strings too:
its ('size') { should == 2 }
When there is an attribute of subject
you want to assert but the value cannot easily be turned into a valid Ruby symbol, you’ll need to use a string. This string is not evaluated as Ruby code; it’s only evaluated against the subject under test as a method of that class.
Hashes, in particular, allow you to define an anonymous array with the key value to assert the value for that key:
its ([:key1]) { should == "value1" }
There’s more…
In the previous code examples, another block known as the context
block was presented. The context
block is a grouping mechanism for associating tests. For example, you may have a conditional branch in your code that changes the outputs of a method. Here, you may use two context
blocks, one for a value and the second for another value. In our example, we’re separating the happy path (when a given point is within the specified mile radius) from the alternative (when a given point is outside the specified mile radius). context
is a useful construct that allows you to declare let
and other blocks within it, and those blocks apply only for the scope of the containing context
.
Summary
This article demonstrated to us the idiomatic RSpec code that makes good use of the RSpec Domain Specific Language (DSL).
Resources for Article :
Further resources on this subject:
- Quick start – your first Sinatra application [Article]
- Behavior-driven Development with Selenium WebDriver [Article]
- External Tools and the Puppet Ecosystem [Article]