Kevin Jalbert
Kevin Jalbert

Reputation: 3245

Rspec it{} evaluated uses last variable value

I'm using Rspec to make some tests for my program. In a spec I instantiate the class once, and perform my tests on it using describes and contexts. I encountered something interesting, if the its seem to be evaluated at the end of the contexts. For example given the following classes and its associated spec:

class Tmp
  def initialize
    @values = {}
  end

  def modify(new_value1, new_value2)
    @values = {:a => new_value1, :b => new_value2}
  end

  def get_values
    return @values
  end
end

describe Tmp do
  tmp = Tmp.new()

  describe "testing something" do
    context "change value" do

      # First evaluation
      tmp.modify("hi", "bye")
      it {tmp.get_values.should == {:a => "hi", :b => "bye"}}

      # Second evaluation
      tmp.modify("bye", "hi")
      it {tmp.get_values.should == {:a => "bye", :b => "hi"}}
    end
  end
end

Using the provided class and spec the results are as follows:

F.

Failures:

  1) Tmp testing something change value 
     Failure/Error: it {tmp.get_values.should == {:a => "hi", :b => "bye"}}
       expected: {:a=>"hi", :b=>"bye"}
            got: {:a=>"bye", :b=>"hi"} (using ==)
       Diff:
       @@ -1,3 +1,3 @@
       -:a => "hi",
       -:b => "bye"
       +:a => "bye",
       +:b => "hi"
     # ./spec/tmp_spec.rb:11:in `block (4 levels) in <top (required)>'

Finished in 0.00211 seconds
2 examples, 1 failure

This is interesting as Rspec seems to evaluate the first it with the values from tmp as it is at the end of the context. Even though within the context the tmp is changing its values and should pass, Rspec evaluates the its based on the last values that variables have at the ending (I even tried this with a local primitive variable within the context and has a similar experience).

Is there a way to get around this and have the its evaluated in order? Or to at least get the following test to pass? I know I can use different variables and it will work, but there must be a way around this. I also want to know if this is an intended effect for Rspec.

UPDATE with respect to Philipe's answer

By making the change within a single it block the spec passes:

describe Tmp do
  describe "do something" do
    let(:instance) {Tmp.new}

    it 'should be modifiable' do
      instance.modify('hi', 'bye')
      instance.values.should == {a: 'hi', b: 'bye'}
      instance.modify('bye', 'hi')
      instance.values.should == {a: 'bye', b: 'hi'}
    end
  end
end

But if I use the subject it seems to revert back and fails on the first should

describe Tmp do
  describe "do something" do
    let(:instance) {Tmp.new}
    subject{instance.values}

    it 'should be modifiable' do
      instance.modify('hi', 'bye')
      should == {a: 'hi', b: 'bye'}
      instance.modify('bye', 'hi')
      should == {a: 'bye', b: 'hi'}
    end
  end
end

Not sure why this is the case. At least I see that the changes should be within a it block to better reflect the changes we're testing for.

Upvotes: 1

Views: 2462

Answers (4)

Myron Marston
Myron Marston

Reputation: 21830

RSpec uses a two-pass process when you execute the rspec command:

  1. First, it loads all of your spec files and evaluates each example group. Note that the it blocks are not evaluated at this point; they are stored for later evaluation.
  2. Once all spec files have been loaded, and all metadata filtering, etc has been applied, RSpec runs each example.

This might seem confusing at first, but really, it's a lot like almost any ruby code you write: your classes are defined at one point in the program (typically early on, when your code file is required), and the methods in those classes get called later.

Here's a pure ruby code snippet that demonstrates what's going on with your example:

# First you define your examples...
class MyExampleGroup
  tmp = {}

  define_method :example_1 do
    puts "example_1 failed (#{tmp.inspect})" unless tmp == {}
  end

  tmp[:a] = 1
  define_method :example_2 do
    puts "example_2 failed (#{tmp.inspect})" unless tmp == { :a => 1 }
  end

  tmp[:b] = 2
end

# Once all examples have been defined, RSpec runs them...
group = MyExampleGroup.new
group.example_1
group.example_2

The output:

example_1 failed ({:a=>1, :b=>2})
example_2 failed ({:a=>1, :b=>2})

It's important for the robustness of your test suite that each example can be run independently, in any order. To that end, it's best for each example to perform its actions in its own "sandbox" with its own instance of the object-under-test. let is designed specifically to help with this; see my answer about let for more details.

Upvotes: 2

Frederick Cheung
Frederick Cheung

Reputation: 84182

Your subject based example fails because of the interaction between your implementation of modify and the behaviour of let & subject

These two methods cache the result of their call - you clearly wouldn't want a new instance of your class being created every time instance was referred to. This means that your should's will use the value of subject as it was the first time it was accessed (either explicitly by you or by rspec.

Your subject is instance.values but calling your modify method will cause instance.values to be a new object (You're assigning a new hash to @values rather than mutating it in place). Your assertions are using the first used value of subject, so they're not comparing the current value of instance.values at all and hence your specs fail.

Personally I think having the subject be instance.values is a bit weird: the thing that you are interacting with is instance so that would be my choice of subject.

Upvotes: 1

jordanpg
jordanpg

Reputation: 6516

From the rspec-core docs:

Under the hood, an example group is a class in which the block passed to describe or context is evaluated. The blocks passed to it are evaluated in the context of an instance of that class.

https://www.relishapp.com/rspec/rspec-core/docs/example-groups/basic-structure-describe-it

This suggests to me that the code in the ExampleGroup (describe block) is executed when the group is instantiated, with the exception of the Examples themselves (the it blocks). Then the it blocks are executed in the context of the describe block. That's why it only sees the last value of tmp.

Upvotes: 1

fphilipe
fphilipe

Reputation: 10054

You shouldn't be creating instances and manipulating them outside of it, specify, before, let, and subject blocks. Otherwise the subject and other variables are not reset after a test.

Below I rewrote your spec using a couple of different styles. See the inline comments for an explanation.

class Tmp
  # Exposes the @values ivar through #values
  attr_reader :values

  def initialize
    @values = {}
  end

  def modify(new_value1, new_value2)
    @values = {a: new_value1, b: new_value2}
  end
end

describe Tmp do
  #`let` makes the return value of the block available as a variable named `instance` (technically it is a method named instance, but let's call it a variable).
  let(:instance) { described_class.new }

  # Inside an it block you can access the variables defined through let.
  it 'should be modifiable' do
    instance.modify('hi', 'bye')
    instance.values.should == {a: 'hi', b: 'bye'}
  end

  # or

  # `specify` is like `it` but it takes no argument:
  specify do
    instance.modify('hi', 'bye')
    instance.values.should == {a: 'hi', b: 'bye'}
  end

  # or

  # This is another common way of defining specs, one describe per method.
  describe '#modify' do
    let(:instance) { described_class.new }
    # Here we define the subject which is used implicitly when calling `#should` directly.
    subject { instance.values }
    before { instance.modify('hi', 'bye') }
    it { should == {a: 'hi', b: 'bye' } # Equivalent to calling `subject.should == ...`
  end

  # or

  # When we don't specify a subject, it will be an instance of the top level described object (Tmp).
  describe '#modify' do
    before { subject.modify('hi', 'bye') }
    its(:values) { should == {a: 'hi', b: 'bye' }
  end
end

Upvotes: 5

Related Questions