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.

Sacrificial Test Classes - November 10, 2016 - Ken Mayer