Sacrificial Test Classes
November 10, 2016
Today I learned that there’s a difference between a throw-away class and a sacrificial class. When writing rspec tests, you want the latter; otherwise you might create some very hard-to-debug test pollution.
Until today, I would create a throw-away test class in my rspec tests, especially when unit testing a module:
RSpec.describe MyModule do
class MyTestClass
include MyModule
...
end
it 'tests something' do
subject = MyTestClass.new
expect(subject).to do_something
end
end
It turns out that this is a terrible thing to do because it will pollute your global name space with MyTestClass
. In my naïve understanding of rspec, I thought that things created inside the rspec block would be scoped to that block. This is very much the case when writing tests in MiniTest
; you are just creating classes and methods, so a little private class can’t leak out of your test examples. Not so when writing rspec tests. There’s a GitHub issue about it that gives some good explanations about the hows and why: https://github.com/rspec/rspec-core/issues/2181
Here’s another, simpler, example:
irb: class Foo1
>> BAR = 5
>> end
===> 5
irb: Foo1::BAR
===> 5
irb: BAR
NameError: uninitialized constant BAR
irb: Foo2 = Class.new do
\-+ BAR = 5
>> end
===> Foo2
irb: BAR
===> 5
irb: Foo2::BAR
(irb):9: warning: toplevel constant BAR referenced by Foo2::BAR
===> 5
Yikes! Foo1
and Foo2
are roughly similar class, but BAR
is scoped to Foo1
, as in, Foo1::BAR
, but Foo2
created a top level constant named BAR
. You can see how this might make your tests break. Myron Marston explained in the issue:
While not ideal, it’s known, expected behavior. In Ruby, when you define a constant in a block, it uses the module scoping of whatever is outside the block as the constant namespace. … This is simply how Ruby works and there’s nothing RSpec can do about this. (And since it’s how ruby works, I’m not sure that we should try to do anything different.)
So, what should we do, if these throw-away classes are not so temporary? My example gives a hint: Create a “sacrificial class,” instead, using a let
variable instead, since those variables are scoped to the example group.
RSpec.describe MyModule do
let(:my_test_class) {
Class.new do
include MyModule
...
end
}
it 'tests something' do
subject = my_test_class.new
expect(subject).to do_something
end
end
There, all better. No more test pollution.