DRY up RSpec subject defining

Augusts Bautra - Sep 27 '23 - - Dev Community

Immediate caveat - usually you want to favour KISS over DRY in specs. I've railed against shared examples (a DRYing technique) in the past, so this may seem hypocritical of me, but hear me out.

I'll argue that there is a difference between "boilerplate" code (the describe, context and it calls) and "substance" code (lets and actual asserts), and that reducing the boilerplate will make the substance shine through better.

The example

Let's look at a naive example. We have a class that has a class method, an initializer, and an instance method:

class Triangle
  attr_reader :a, :b, :c

  def initialize(a, b, c)
   @a = a
   @b = b
   @c = c
  end

  def self.sum_of_interior_angles
    180
  end

  def perimeter
    a + b + c
  end
end
Enter fullscreen mode Exit fullscreen mode

Seeing how initializer isn't doing anything interesting, we should get by with speccing .sum_of_interior_angles and #perimeter:

RSpec.describe Triangle do
  describe ".sum_of_interior_angles" do
    subject(:sum_of_interior_angles) { described_class.sum_of_interior_angles }

    it { is_expected.to eq(180) }
  end

  describe "#perimeter" do
    subject(:perimeter) { triangle.perimeter }

    let(:triangle) { described_class.new(3, 4, 5) }

    it { is_expected.to eq(12) }
  end  
end
Enter fullscreen mode Exit fullscreen mode

The gripe

My gripe with the spec is minor, but affects nearly all specs, at least for methods without arguments - we've repeated the method name three times, across two lines where one call is plenty.

We could have this:

RSpec.describe Triangle do
  describe ".sum_of_interior_angles" do    
    it { is_expected.to eq(180) }
  end

  describe "#perimeter" do
    let(:triangle) { described_class.new(3, 4, 5) }

    it { is_expected.to eq(12) }
  end  
end
Enter fullscreen mode Exit fullscreen mode

How? Easy - convention! Use describe's . or # to tell whether a class or instance method is being specced and define the subject based on that. For instance method specs infer the name of the instance by underscoring the class name.

But I agree that this may be a bit too implicit and terse for new developers. Here's a bit more explicit version where we specify instance_name: :triangle metadata to tell where to read the instance from, and :implicit_subject metadata to signal that subject is being defined under the hood.

RSpec.describe Triangle, instance_name: :triangle do
  describe ".sum_of_interior_angles", :implicit_subject do    
    it { is_expected.to eq(180) }
  end

  describe "#perimeter", :implicit_subject do
    let(:triangle) { described_class.new(3, 4, 5) }

    it { is_expected.to eq(12) }
  end  
end
Enter fullscreen mode Exit fullscreen mode

Yummy!

The implementation

To support this behavior a shared context with metadata-based inclusion can be used.

# in some path that gets loaded for specs, usually spec/support/shared_contexts/*.rb
METHOD_DESCRIPTORS = %w[. #]

RSpec.shared_context "with implicit subject defining", :implicit_subject do |context|
  if metadata[:description].first.in?(METHOD_DESCRIPTORS)   
    method_name = metadata[:description].split("(").first[1..]

    case metadata[:description].first
    when "." # class_method
      subject(method_name) { described_class.send(method_name) }
    when "#" # instance_method
      instance_getter =
        metadata[:instance_name] ||
        :instance # you can do class-name-based inference here if you wish

      subject(method_name) { send(instance_getter).send(method_name) }
    end
  end  
end
Enter fullscreen mode Exit fullscreen mode
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .