Isolated subclass specs in Ruby
Unit tests should isolate their subject, but tests of subclass functionality usually test the class as a whole. One reason for this is that in Ruby there is no way to dynamically change the superclass, but more importantly, it’s because you don’t want to modify the thing you’re testing. Sometimes however, isolating a subclass from its parent makes sense.
Schemabot, our tool for applying database changes in a replication friendly way, is based on the Sequel gem and its migration DSL. We added a preview function by subclassing the MySQL adapter and having it ignore any SQL that would change the database.
A simplified version of our implementation (allowing just SELECT
and DESCRIBE
queries) looks like this:
module Sequel
module MySQLPreview
class Database < Sequel::MySQL::Database
def execute(sql, opts={})
if sql.match(/^SELECT/) || sql.match(/^DESCRIBE/)
puts "RUNNING: #{sql}"
super(sql, opts)
else
puts "PREVIEW: #{sql}"
end
end
end
end
end
Satisfied with the assumption that all queries go through #execute
and the fact that Sequel itself is already well tested, we wanted to add a set of specs that would simply validate that our #execute
method passed queries on to the superclass or ignored them as appropriate.
Set an expectation on #execute or on #super?
Our #execute
calls the #execute
in the superclass, and that action is what we want to set an expection on. RSpec lets us set message expectations on a class (ClassName.should_receive
), on an instance (ClassName.new.should_receive
), or all instances (ClassName.any_instance.should_receive
), but the #execute
we care about is an instance method on the superclass. It’s not a class method, and it’s not a method on any regular instance.
Instead of checking calls to the superclass’s #execute
, it would be nice to set expectation on the call to super
. Although Ruby language features are often implemented as methods (require
, method_missing
, etc.) super
is actually a keyword. It is possible to create a method named super
, but mocking such a method would be no help, as it would remain completely separate from the super
keyword, and require a different, more explicit syntax to invoke.
RSpec’s David Chelimsky has commented on this issue:
Since we don’t get into the class hierarchy in rspec’s mock/stub framework, there is no facility for managing this.
So, it’s up to us to intercept the call if we want to verify that the subclass is behaving correctly.
Sneak a module into the inheritance chain
Somehow we need to get some in between the subclass and its superclass. Including a module in a class will not modify its superclass (which can’t be changed), but the module will be inserted into the class’s list of ancestors, and be able to intercept super
before the actual superclass is reached.
To demonstrate this, let’s start with a simple parent class and subclass, both implementing #execute
methods:
class ParentClass
def execute
puts "ParentClass#execute"
end
end
class SubClass < ParentClass
def execute
puts "SubClass#execute"
super
end
end
The output of SubClass.new.execute
will be:
SubClass#execute
ParentClass#execute
Including a module changes the ancestors list:
module SneakyModule
def execute
puts "SneakyModule#execute"
super
end
end
SubClass.ancestors # => [SubClass, ParentClass, Object, Kernel, BasicObject]
SubClass.instance_eval { include SneakyModule }
SubClass.ancestors # => [SubClass, SneakyModule, ParentClass, Object, Kernel, BasicObject]
And SubClass.new.execute
shows the sequence of execution to be:
SubClass#execute
SneakyModule#execute
ParentClass#execute
This technique allows us to programatically verify the behaviour of the subclass’s #execute
method by having the sneaky module in the middle record and report on method calls it intercepts. Here’s an example implementation:
module FakeMySQLAdapter
class << self
attr_accessor :last_execute
end
def execute(*args)
FakeMySQLAdapter.last_execute = args
end
end
With this done, we can write an example to verify that super
is being invoked appropriately:
before(:all) { subject.class.instance_eval { include FakeMySQLAdapter } }
before(:each) { FakeMySQLAdapter.last_execute = nil }
it "should pass on SELECT statements" do
subject.execute("SELECT * FROM foo;")
FakeMySQLAdapter.last_execute.should == ["SELECT * FROM foo;", {}]
end
You can see a full implemenation of this on GitHub.
This is a solution. It exercises the logic of our subclass’s #execute
and verifies its query handling. It’s not a great solution. One problem is that this code modifies the subclass being tested (by including the module), and another is that the subclass stays modified. It’s not possible to uninclude the module. If we had other tests that relied on the subclass, running the subclass’s specs first might break others.
There is a cleaner way, as we’ll see next.
Extract a module with the method override
Rather than overriding #execute
in the subclass directly, we can override it by including a module that implements #execute
. This structure allows us to include the logic of interest in a dummy class to test it in isolation.
First, we refactor our preview adapter to the following:
module Sequel
module PreviewMySQL
module MethodOverrides
def execute(sql, opts={})
if sql.match(/^SELECT/) || sql.match(/^DESCRIBE/)
puts "RUNNING: #{sql}"
super(sql, opts)
else
puts "PREVIEW: #{sql}"
end
end
end
class Database < Sequel::MySQL::Database
include MethodOverrides
end
end
end
Before we test the module, let’s write an assertion that Database
is actually using the module:
describe Sequel::PreviewMySQL::Database do
it "should include the override methods" do
subject.class.ancestors.should include(Sequel::PreviewMySQL::MethodOverrides)
end
end
After that, we can test the module. We start by creating a dummy superclass for the module to be included in. We’ll use method_missing
to record all method calls passed up to the superclass by its subclasses.
By using Class.new
to create anonymous subclasses that include the module being tested, we can ensure that each example in our spec runs with a fresh copy of the subject.
describe Sequel::PreviewMySQL::MethodOverrides do
class FakeSuperClass
attr_reader :last_super_call
def method_missing(method, *args)
@last_super_call = [method, args]
end
end
subject do
Class.new(FakeSuperClass).instance_eval do
include Sequel::PreviewMySQL::MethodOverrides
end.new
end
#...
end
This solution is easier to read than the one in the previous post, and it avoids the problems of modifying the subject. Although we are now testing a module rather than the actual subclass we’ll be using it in, our assertion that the subclass should include the module of override methods completes the loop and will automatically warn us if we make changes to our hookup code.
With this in place, our individual examples are clean and simple, as follows:
it "should allow SELECTs" do
sql = "SELECT * FROM sometable"
subject.execute(sql)
subject.last_super_call.should == [:execute, [sql, {}]]
end
You can see a full implemenation of this on GitHub.
This is a clean solution, and one that I’d have been satisfied with if I’d come up with it first. However, the extra time on this got me thinking… it really shouldn’t be that hard to stub out irrelevant and troublemaking parts of the full class.
Test the full class and stub problematic parts
Testing the full class really shouldn’t be hard and RSpec should point out any code that gets in the way. I gave it another go.
First, I reverted to the original implementation of the subclass:
module Sequel
module MySQLPreview
class Database < Sequel::MySQL::Database
def execute(sql, opts={})
if sql.match(/^SELECT/) || sql.match(/^DESCRIBE/)
puts "RUNNING: #{sql}"
super(sql, opts)
else
puts "PREVIEW: #{sql}"
end
end
end
end
end
And started with a simple spec:
describe Sequel::MySQLPreview::Database do
it "should allow SELECTs" do
sql = "SELECT * FROM sometable"
subject.should_receive(:puts).with(/^RUNNING:/)
subject.execute(sql)
end
end
The assertion here is on #puts
rather than the superclass’s #execute
method, but we’ll add more comprehensive assertions later.
That example raised an error with a rather brief backtrace:
1) Sequel::MySQLPreview::Database should allow SELECTs
Failure/Error: super(sql, opts)
Sequel::DatabaseConnectionError:
Mysql::Error: Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)
# <internal:prelude>:10:in `synchronize'
# ./spec/whole_spec.rb:11:in `execute'
# ./spec/whole_spec.rb:30:in `block (2 levels) in <top (required)>'
RSpec tries to show you just the parts of the full backtrace that are relevant to the code you’re testing, but you can tell it to be more verbose by running it with the --backtrace
option. That got me this:
1) Sequel::MySQLPreview::Database should allow SELECTs
Failure/Error: super(sql, opts)
Sequel::DatabaseConnectionError:
Mysql::Error: Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/adapters/mysql.rb:110:in `real_connect'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/adapters/mysql.rb:110:in `connect'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/database/misc.rb:48:in `block in initialize'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool.rb:100:in `call'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool.rb:100:in `make_new'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:144:in `make_new'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:130:in `available'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:120:in `block in acquire'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:162:in `block in sync'
# <internal:prelude>:10:in `synchronize'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:162:in `sync'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:119:in `acquire'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/connection_pool/threaded.rb:91:in `hold'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/database/connecting.rb:229:in `synchronize'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/sequel-3.35.0/lib/sequel/adapters/shared/mysql_prepared_statements.rb:23:in `execute'
# ./spec/whole_spec.rb:11:in `execute'
# ./spec/whole_spec.rb:30:in `block (2 levels) in <top (required)>'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example.rb:87:in `instance_eval'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example.rb:87:in `block in run'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example.rb:195:in `with_around_each_hooks'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example.rb:84:in `run'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example_group.rb:353:in `block in run_examples'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example_group.rb:349:in `map'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example_group.rb:349:in `run_examples'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/example_group.rb:335:in `run'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/command_line.rb:28:in `block (2 levels) in run'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/command_line.rb:28:in `map'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/command_line.rb:28:in `block in run'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/reporter.rb:34:in `report'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/command_line.rb:25:in `run'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/runner.rb:69:in `run'
# /Users/chrisberkhout/.rvm/gems/ruby-1.9.2-p290-patched/gems/rspec-core-2.10.1/lib/rspec/core/runner.rb:10:in `block in autorun'
I could see that a #synchronize
method was responsible for acquiring a connection to the database. This was the method I needed to stub to to isolate my test from an actual database instance.
Following through the execution path showed that the superclass’s #execute
would eventually call #_execute
to finish its work. Setting an expectation on this method would allow me to test whether or not the SQL query was in fact being passed up to the superclass for execution.
Incorporating these two points brought me to the final form of my specs:
describe Sequel::MySQLPreview::Database do
let(:connection) { stub("connection") }
before { subject.stub(:synchronize).and_yield(connection) }
it "should allow SELECTs" do
sql = "SELECT * FROM sometable"
subject.should_receive(:puts).with(/^RUNNING:/)
subject.should_receive(:_execute).with(connection, sql, anything)
subject.execute(sql)
end
end
The two assertions here cover both the direct actions of the subclass, as well as its correct interaction with the superclass.
So, it turns out that (with --backtrace
) this was quite straightforward. You can see the full code on GitHub
In conclusion
If I’d gotten this full class spec working first, I probably would have stopped there. However, even though this tests the class as a whole - the normal way - it is not necessarily the best approach.
The downside here is that it requires stubbing the class under test (even though it’s a part of the class we’re not specifically interested in testing). The other thing is that it couples itself more tightly to the internal implementation of the superclass. It requires the #execute
, #synchronize
and #_execute
methods to behave in certain ways. It also places on the reader the burden of understanding the significance of the #_execute
expectation.
The assumptions made by the isolated module spec are simpler: that all SQL goes via the #execute
method and that it can be stopped there without any problems. It’s a smaller, cleaner set of assumptions, and there’s no stubbing of the subject. I’d say that extracting and testing a module in isolation is the best approach here.