Skip to content

Instantly share code, notes, and snippets.

@erik-megarad
Created November 11, 2014 18:55
Show Gist options
  • Save erik-megarad/5df43818f1058f380631 to your computer and use it in GitHub Desktop.
Save erik-megarad/5df43818f1058f380631 to your computer and use it in GitHub Desktop.
describe "A long-running process" do
before(:context) do # Have to use before(:context) instead of subject() to make the long-running task only execute once
@input_one = 1
@input_two = 2 # Can't use let() in before(:context)
@my_test_object = MyTestClass.new
@my_test_object.thing_that_takes_thirty_minutes(@input_one, @input_two)
end
# Multiple assertions
it 'calculates the output correctly' do
@my_test_object.value.should eq 3
end
it 'does not set the failed flag' do
@my_test_object.failed.should be_nil
end
end
@myronmarston
Copy link

First off, what you've done here is completely fine. It's not incorrect in any way. So I don't really understand what you mean by:

With rspec 3.0, testing the output of a long-running process with multiple assertions is impossible to do "correctly"

Are you basing that statement on the http://betterspecs.org/ guidelines? Bear in mind the guidelines there do not reflect the opinions of RSpec's core team at all. There are many things there I disagree with.

Anyhow, while what you have here is totally fine, bear in mind that before(:context) hooks come with a lot of caveats. (See the "Warning: before(:context)" section of our docs. Many testing "resources" (for lack of a better term) like test doubles and DB transactions have a per-example life cycle, and when you use before(:context) hook, you are operating outside of that life cycle. As long as you don't use those constructs, you'll be fine, but others who work on the code base may not be aware of those caveats--so I generally recommend avoiding before(:context) hooks for things like this. (You certainly can

It looks like you chose to use a before(:context) hook to follow the "one expectation/assertion per example/test" guideline? I'd say that that guideline was really only ever meant to be applied to unit tests, where the setup/teardown is extremely minimal, and making separate examples with separate expectations or assertions is very cheap. I would never follow that guideline for anything that has long-running setup. In your case, it looks like this is more of an integration or acceptance test, and I'd just put it all in one example:

describe "A long-running process" do
  it "calculates the correct values" do
    input_one = 1
    input_two = 2

    my_test_object = MyTestClass.new

    my_test_object.thing_that_takes_thirty_minutes(input_one, input_two)
    my_test_object.value.should eq 3
    my_test_object.failed.should be_nil
  end
end

In this particular case, if you are using RSpec 3.1+, you could replace the two expectations with one:

my_test_object.should have_attributes(value: 3, failed: nil)

As for not being able to use let from before(:context) blocks: they were never intended to be used from before(:context) blocks. The docs for let have always said that they are cleared between each example, which clearly doesn't make fit with before(:context) hooks. In theory, we could make them have a lifecycle relative to whatever construct they are accessed in, but then we'd have to start putting conditionals in our docs, e.g. "if they are accessed from a before(:context) hook, they last for the whole example group, otherwise they are cleared between examples" -- which seemed to use to be a huge smell.

In RSpec 2, we didn't originally prevent you from calling lets from before(:all) hooks, but users reported some nasty bugs that occurred because we didn't explicitly prevent, so we decided to make it explicit.

If the bare words syntax is what you're after with using a let, you can still get that trivially in your before(:context) hook...just use `attr_reader:

describe "Long running thing" do
  attr_reader :result
  before(:context) { @result = MyClass.perforom_long_running_calculation }

  it "does one thing" do
    result.should do_one_thing
  end

  it "does another thing" do
    result.should do_another_ting
  end
end

Let me know if you have any more questions.

@erik-megarad
Copy link
Author

Thanks for the response. I wasn't referring to betterspecs.org, specifically, but the general feeling that using instance variables instead of let seems like a dirty hack. You also lose some convenient things like memoization, which aren't particularly important in this situation.

I was indeed attempting to follow the "one expectation/assertion per example/test" guideline, mostly because if multiple assertions fail, I want to see each one that failed, instead of just the first. If I want that, I'm forced to use before(:context), which also seems like a dirty hack.

Writing tests this way means you leave behind most of the benefits of rspec, in my opinion. Things still "work," but it's no longer pleasant to write. In an ideal world, this would be how this test would look:

describe "A long-running process" do
  let(:input_one) { 1 }
  let(:input_two) { 2 }
  let(:my_test_object) { MyTestClass.new }

  subject(:thing_that_takes_thirty_minutes, context: true) do
    my_test_object.thing_that_takes_thirty_minutes(input_one, input_two)
  end

  it 'calculates the output correctly' do
    expect { thing_that_takes_thirty_minutes }.to change {
      my_test_object.value
    }.to(3)
  end

  it 'does not set the failed flag' do
    expect { thing_that_takes_thirty_minutes }.to_not change {
      my_test_object.failed
    }
  end
end

Note how it looks the same as any other test, with the only addition being a flag on subject (called context here, but there's probably a better name for it) that instructs rspec to only evaluate the subject once per context. That seems like an elegant solution for everybody.

@myronmarston
Copy link

Here's a little extension that provides something very similar to what you're asking for:

module RSpecContextMemoization
  def let(name, &block)
    attr_reader name
    before(:context) { instance_variable_set(:"@#{name}", block.call) }
  end
end

RSpec.configure do |config|
  config.extend RSpecContextMemoization, memoization_scope: :context
end

Put that in spec/spec_helper.rb (or somewhere that is always loaded) and then tag example groups in which you want let/subject memoization to have context rather than example scope with memoization_scope: :context. Your example would become:

describe "A long-running process", memoization_scope: :context do
  let(:input_one) { 1 }
  let(:input_two) { 2 }
  let(:my_test_object) { MyTestClass.new }

  subject(:thing_that_takes_thirty_minutes) do
    my_test_object.thing_that_takes_thirty_minutes(input_one, input_two)
  end

  it 'calculates the output correctly' do
    expect { thing_that_takes_thirty_minutes }.to change {
      my_test_object.value
    }.to(3)
  end

  it 'does not set the failed flag' do
    expect { thing_that_takes_thirty_minutes }.to_not change {
      my_test_object.failed
    }
  end
end

...and it should just work.

It's trivial to add this functionality on top of what RSpec already provides. We field enough questions where users get confused when misusing before(:context) hooks that I don't want to add more features to RSpec that would encourage their (mis)use, particularly because it's so easy to add this kind of thing on top of the APIs already provided. It could make a great extension gem, though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment