RSpec: Example groups define constants that can shadow your models

Posted . Visible to the public.

Every example group becomes a real Ruby class, and RSpec assigns it a constant under RSpec::ExampleGroups. A describe Project do ... end in spec/models/project_spec.rb defines RSpec::ExampleGroups::Project.

That constant is harmless for the Ruby code in your specs. But it can shadow your Project model when Rails resolves an association class by name — and it only does so when the two spec files happen to load into the same process. On a sharded CI you get a test that fails on some runs and not others.

How affected tests look like

Take a look at this spec that embeds a Thing class:

# spec/models/shadowing_spec.rb

describe Project do
end

describe 'some unrelated group' do
  class self::Thing < ActiveRecord::Base
    self.table_name = 'projects'
    belongs_to :project
  end

  it 'resolves the association' do
    self.class::Thing.new.project
  end
end

Running this test will raise an error:

ArgumentError:
  The Project model class for the RSpec::ExampleGroups::SomeUnrelatedGroup::Thing#project
  association is not an ActiveRecord::Base subclass.

Delete the empty describe Project block and it passes.

Note

The two groups are siblings, not nested — and in real life they are usually in different files. >RSpec::ExampleGroups::Project is defined when the describe Project block is loaded, and it stays defined for the rest of the process. It is not scoped to the block, and it does not go away when the block ends or when its examples have run.

Fix

Anchor the class name:

belongs_to :project, class_name: '::Project'

compute_type short-circuits on a leading :: and skips the candidate walk entirely.

Alternatively, move the throwaway class out of the example group to the top level of the file, where it is no longer named under RSpec::ExampleGroups.

What is NOT affected

Normal constant references in specs are fine:

describe Project do
  it 'works' do
    puts Project              # => Project (your model)
    create(:project)          # fine
    Project.where(...)        # fine
  end
end

Ruby resolves those lexically. The block is written at the top level of the file, so Module.nesting is empty and Project is ::Project. A block does not open a namespace, and RSpec::ExampleGroups::Project is simply not reachable from there. Ruby's constant lookup never sees it.

Also unaffected:

  • Models and other classes defined at the top level of a spec file. Only classes defined inside an example group get a name under RSpec::ExampleGroups::…, which is what feeds compute_type.
  • Your application code. Nothing here changes how app/models/** resolves constants.

So you need all three at once: a Rails-managed class defined inside an example group, a group whose description camelizes to a colliding model name, and both files loaded into the same process.

Why this happens

RSpec names every group (rspec-core, ExampleGroups.assign_const). The name comes from the description string, camelized — so describe 'Project' shadows just as well as describe Project.

Rails' compute_type then resolves belongs_to :project by building candidate constant paths from the name string of the owning class, from the inside out:

RSpec::ExampleGroups::SomeUnrelatedGroup::Thing::Project
RSpec::ExampleGroups::SomeUnrelatedGroup::Project
RSpec::ExampleGroups::Project      # <- found here, search stops
RSpec::Project
Project                            # <- never reached

The bare Project is appended last, so any RSpec::ExampleGroups::…::Project wins. Rails then checks that what it found is an ActiveRecord::Base subclass, and raises.

Both groups hang off RSpec::ExampleGroups, and the walk passes through it on the way out. That is why a sibling group is enough: any top-level group named Project anywhere in the loaded suite shadows the model for every class defined inside any example group.

Profile picture of Henning Koch
Henning Koch
Last edit
Henning Koch
License
Source code in this card is licensed under the MIT License.
Posted by Henning Koch to makandra dev (2026-08-24 07:15)