Multiple assertions for single setup in RSpec
Asked Answered
H

3

7

I have a few slower specs that I would like to optimise. The example of such spec looks like:

require 'rspec'


class HeavyComputation
  def compute_result
    sleep 1 # something compute heavy here
    "very big string"
  end

end



describe HeavyComputation, 'preferred style, but slow' do

  subject { described_class.new.compute_result }

  it { should include 'big' }
  it { should match 'string' }
  it { should match /very/ }
  # +50 others
end

This is very readable and I'm happy with it generally, except that every additional spec will add at least 1 second to the total run-time. That is not very acceptable.

(Please let's not discuss the optimisation on the HeavyComputation class as it is outside of the scope of this question).

So what I have to resort to is spec like this:

describe HeavyComputation, 'faster, but ugly' do
  subject { described_class.new.compute_result }

  it 'should have expected result overall' do
    should include 'big'
    should match 'string'
    should match /very/
    # +50 others
  end
end

This is obviously much better performance wise because the time to run it will always be nearly constant. The problem is that failures are very hard to track down and it is not very intuitive to read.

So ideally, I would like to have a mix of both. Something along these lines:

describe HeavyComputation, 'what I want ideally' do
  with_shared_setup_or_subject_or_something_similar_with do
    shared(:result) { described_class.new.compute_result  }
    subject         { result }

    it { should include 'big' }
    it { should match 'string' }
    it { should match /very/ }
    # +50 others
  end
end

But unfortunately I cannot see where to even start implementing it. There are multiple potential issues with it (should the hooks be called on shared result is among those).

What I want to know if there is an existing solution to this problem. If no, what would be the best way to tackle it?

Hi answered 3/9, 2014 at 1:6 Comment(1)
Unless you expect your string to change between tests, why not just setup once in a before(:all) block?Birdella
H
1

@Myron Marston gave some inspiration, so my first attempt to implement it in a more or less reusable way ended up with the following usage (note the shared_subject):

describe HeavyComputation do
  shared_subject { described_class.new.compute_result }

  it { should include 'big' }
  it { should match 'string' }
  it { should match /very/ }
  # +50 others
end

The idea is to only render subject once, on the very first spec instead of in the shared blocks. It makes it pretty much unnecessary to change anything (since all the hooks will be executed).

Of course shared_subject assumes the shared state with all its quirks.

But every new nested context will create a new shared subject and to some extent eliminates a possibility of a state leak.

More importantly, all we need to do in order to deal the state leaks s(should those sneak in) is to replace shared_subject back to subject. Then you're running normal RSpec examples.

I'm sure the implementation has some quirks but should be a pretty good start.

Hi answered 23/9, 2014 at 2:43 Comment(0)
C
4

You can use a before(:context) hook to achieve this:

describe HeavyComputation, 'what I want ideally' do
  before(:context) { @result = described_class.new.compute_result }
  subject          { @result }

  it { should include 'big' }
  it { should match 'string' }
  it { should match /very/ }
  # +50 others
end

Be aware that before(:context) comes with a number of caveats, however:

Warning: before(:context)

It is very tempting to use before(:context) to speed things up, but we recommend that you avoid this as there are a number of gotchas, as well as things that simply don't work.

context

before(:context) is run in an example that is generated to provide group context for the block.

instance variables

Instance variables declared in before(:context) are shared across all the examples in the group. This means that each example can change the state of a shared object, resulting in an ordering dependency that can make it difficult to reason about failures.

unsupported rspec constructs

RSpec has several constructs that reset state between each example automatically. These are not intended for use from within before(:context):

  • let declarations
  • subject declarations
  • Any mocking, stubbing or test double declaration

other frameworks

Mock object frameworks and database transaction managers (like ActiveRecord) are typically designed around the idea of setting up before an example, running that one example, and then tearing down. This means that mocks and stubs can (sometimes) be declared in before(:context), but get torn down before the first real example is ever run.

You can create database-backed model objects in a before(:context) in rspec-rails, but it will not be wrapped in a transaction for you, so you are on your own to clean up in an after(:context) block.

(from http://rubydoc.info/gems/rspec-core/RSpec/Core/Hooks:before)

As long as you understand that your before(:context) hook is outside the normal per-example lifecycle of things like test doubles and DB transactions, and manage the necessary setup and teardown yourself explicitly, you'll be fine -- but others who work on your code base may not be aware of these gotchas.

Cobwebby answered 3/9, 2014 at 2:46 Comment(3)
Thanks. Good answer. The compromise needs to be found between slow runs and 'un-safety' of the faster setup.Hi
could you please explain how the variable @result travels from the before(:context) scope to the subject? The self is a different class there. So there must be some RSpec magic going on that copies over the instance variables.Hi
It's a documented feature of RSpec (see the note about instance variables from the docs copied above). The code is here: github.com/rspec/rspec-core/blob/v3.1.2/lib/rspec/core/… github.com/rspec/rspec-core/blob/v3.1.2/lib/rspec/core/…Cobwebby
H
1

@Myron Marston gave some inspiration, so my first attempt to implement it in a more or less reusable way ended up with the following usage (note the shared_subject):

describe HeavyComputation do
  shared_subject { described_class.new.compute_result }

  it { should include 'big' }
  it { should match 'string' }
  it { should match /very/ }
  # +50 others
end

The idea is to only render subject once, on the very first spec instead of in the shared blocks. It makes it pretty much unnecessary to change anything (since all the hooks will be executed).

Of course shared_subject assumes the shared state with all its quirks.

But every new nested context will create a new shared subject and to some extent eliminates a possibility of a state leak.

More importantly, all we need to do in order to deal the state leaks s(should those sneak in) is to replace shared_subject back to subject. Then you're running normal RSpec examples.

I'm sure the implementation has some quirks but should be a pretty good start.

Hi answered 23/9, 2014 at 2:43 Comment(0)
R
1

aggregate_failures, added in version 3.3, will do some of what you're asking about. It allows you to have multiple expectations inside of a spec, and RSpec will run each and report all failures instead of stopping at the first one.

The catch is that since you have to put it inside of a single spec, you don't get to name each expectation.

There's a block form:

it 'succeeds' do
  aggregate_failures "testing response" do
    expect(response.status).to eq(200)
    expect(response.body).to eq('{"msg":"success"}')
  end
end

And a metadata form, which applies to the whole spec:

it 'succeeds', :aggregate_failures do
  expect(response.status).to eq(200)
  expect(response.body).to eq('{"msg":"success"}')
end

See: https://www.relishapp.com/rspec/rspec-core/docs/expectation-framework-integration/aggregating-failures

Rhabdomancy answered 20/5, 2016 at 0:16 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.