Re: [rspec] request macros?

46 views
Skip to first unread message

David Chelimsky

unread,
Dec 3, 2012, 3:33:42 PM12/3/12
to rs...@googlegroups.com
On Mon, Dec 3, 2012 at 2:10 PM, Chris Bloom <chris...@gmail.com> wrote:
> I'm trying to refactor some common code used in a bunch of requests specs
> into a macro, but every way I've tried so far ends in an error saying it
> can't find the macro method, or if it can then it can't find the `get`
> method. Can someone point me to an example of how to do this?
>
> # spec/requests/api/api_v1.rb
> describe MyApp::API_v1 do
> context "originator" do
> describe "GET /api/v1/originator/hello" do
> it_should_check_minimum_protected_api_params
> "/api/v1/originator/hello"
> end
> end
> end
>
> # spec/support/api_macros.rb
> module ApiMacros
> def self.included(base)
> base.extend(GroupMethods)
> end
>
> module GroupMethods
> def it_should_check_minimum_protected_api_params(url)
> get url
> ...
> end
> end
> end
>
> # spec/spec_helper.rb
> RSpec.configure do |config|
> config.include ApiMacros, :type => :request
> end
>
> This ends in:
>
> $ rspec spec/requests/api
> /spec/support/api_macros.rb:8:in
> `it_should_check_minimum_protected_api_params': undefined method `get' for
> #<Class:0x000001036708f0> (NoMethodError)

That's not saying it can't find the macro method. It says it can't find `get`.

The macro is being evaluated at the class level, whereas "get" is an
instance method. The macro needs to define examples that use the get
method, e.g:

def it_should_check_minimum_protected_api_params(url)
it "should check minimum protected api params" do
get url
# ...
end
end

HTH,
David

David Chelimsky

unread,
Dec 3, 2012, 3:37:37 PM12/3/12
to rs...@googlegroups.com
BTW - another approach is to write a matcher and use the one-liner syntax:

RSpec::Matchers.define :check_minimum_protected_api_params do |url|
match do |_|
get url
# .... return true/false for pass/fail
end
end

Now you can say:

it { should check_minimum_protected_api_params("/api/v1/originator/hello") }

Cheers,
David

Chris Bloom

unread,
Dec 3, 2012, 5:42:08 PM12/3/12
to rs...@googlegroups.com
Ah, OK. I see the difference now. Thanks for the clarification.

Chris Bloom

unread,
Dec 4, 2012, 3:17:29 PM12/4/12
to rs...@googlegroups.com
I've run into another set of problems with the two solutions you suggested.

If I go the first way, having the macro method define the example inside of it, and call that from within a describes block, it appears that any instance variables declared in the before block of the spec aren't available. Is this correct behavior?

Alternately, if I instead turn it into a matcher and call it from inside an it{} block, I'm not able to get proper result messages.

Granted the latter problem is easily ignorable given that the test itself works, but I'd like to understand both problems anyway.

Here's the code both ways, first, as a matcher:

# spec/support/api_macros.rb
RSpec::Matchers.define :require_minimum_request_params do |url, params|
  match do |_|
    get url
    response.status.should == 400
    response.body.include?("missing parameter:")

    (params.length - 1).times do |i|
      params.to_a.combination(i+1).each do |c|
        get url, Hash[*c.flatten]
        response.status.should == 400
        response.body.include?("missing parameter:")
      end
    end

    get url, params
    response.status.should == 200
    !response.body.include?("missing parameter:")
  end
  
  failure_message_for_should do
    "expected URL #{url} to require #{params.keys.join(', ')} as the minimum parameters"
  end

  failure_message_for_should_not do
    "expected URL #{url} to not require #{params.keys.join(', ')} as the minimum parameters"
  end

  description do
    "require minimum parameters #{params.keys.join(', ')} for requests to URL #{url}"
  end
end

# spec/requests/api/api_v1.rb
describe MyApp::API_v1 do
  before do
    @minimum_params = {
      api_key:     "",
      nonce:       "",
      timestamp:   "",
      hmac_digest: ""
    }
  end
  
  context "originator" do
    describe "GET /api/v1/originator/hello" do
      it { should require_minimum_request_params("/api/v1/originator/hello", @minimum_params) }
    end
  end
end

# $ rspec spec/requests/api/api_v1_spec.rb
MyApp::API_v1
  originator
    GET /api/v1/originator/hello
      should == 200

And instead as a macro:
# spec/support/api_macros.rb
module ApiMacros
  def self.included(base)
    base.extend(ClassMethods)
  end
  
  module ClassMethods
    def it_should_require_minimum_request_params(url, params)
      it "should require minimum request params" do
        get url
        response.status.should == 400
        response.body.should include("missing parameter")
    
        (params.length - 1).times do |i|
          params.to_a.combination(i + 1).each do |c|
            get url, Hash[*c.flatten]
            response.status.should == 400
            response.body.should include("missing parameter")
          end
        end
    
        get url, params
        response.status.should == 200
        response.body.should_not include("missing parameter")
      end
    end
  end
end

# spec/requests/api/api_v1.rb
describe MyApp::API_v1 do
  before do
    @minimum_params = {
      api_key:     "",
      nonce:       "",
      timestamp:   "",
      hmac_digest: ""
    }
  end
  
  context "originator" do
    describe "GET /api/v1/originator/hello" do
      it_should_require_minimum_request_params("/api/v1/originator/hello", @minimum_params)
    end
  end
end

# $ rspec spec/requests/api/api_v1_spec.rb
Failure/Error: (params.length - 1).times do |i|
     NoMethodError:
       undefined method `length' for nil:NilClass
     # ./spec/support/api_macros.rb:13:in `block in it_should_require_minimum_request_params

David Chelimsky

unread,
Dec 4, 2012, 4:21:13 PM12/4/12
to rs...@googlegroups.com
Didn't realize you were trying to do so much in one statement.

The idea of a matcher, custom or built-in, is that the match block has
one expectation expressed as a boolean - it should return true or
false to indicate pass or fail. This one matcher is wrapping 10
different expectations in logical pairs. I'd probably start with 5
examples with two expectations in each:

it "is valid with the minimum params" do
get "/api/v1/originator/hello", @minimum_params
expect(response.status).to eq(200)
expect(response.body).not_to include("missing parameter:")
end

it "requires api_key in params" do
get "/api/v1/originator/hello", @minimum_params.except("api_key")
expect(response.status).to eq(400)
expect(response.body).to include("missing parameter: api_key")
end

# 3 more failure cases

Each example has two expectations, but they work together to specify
different parts of the same outcome, so I'm comfortable bypassing the
one-expectation-per-example guideline.

You could, conceivably, reduce some of the duplication with a custom
matcher that just deals with one parameter - something like:

it { should require_param("api_key") }

Either that or wrap the failure examples in an an iterator:

describe "minimum params" do
MIN_PARAMS = {
api_key: "",
nonce: "",
timestamp: "",
hmac_digest: "
}

MIN_PARAMS.each_pair do |k, v|
it "requires api_key in params" do
get "/api/v1/originator/hello", MIN_PARAMS.except(k)
expect(response.status).to eq(400)
expect(response.body).to include("missing parameter: #{k}")
end
end
end

WDYT?
> --
> You received this message because you are subscribed to the Google Groups
> "rspec" group.
> To post to this group, send email to rs...@googlegroups.com.
> To unsubscribe from this group, send email to
> rspec+un...@googlegroups.com.
> To view this discussion on the web visit
> https://groups.google.com/d/msg/rspec/-/R3BPlOkxEZIJ.
> For more options, visit https://groups.google.com/groups/opt_out.
>
>

Chris Bloom

unread,
Dec 5, 2012, 9:04:43 PM12/5/12
to rs...@googlegroups.com
I like the idea of breaking it out into small sub-tests, but I think both of the issues I mentioned previously would still be present. That is, it doesn't appear that the before block is executed for macros, so instance variables from my setup code aren't available, and if I write it as a matcher I still won't get the correct test result messages. I need to test this behavior for an indeterminate number of API endpoints, which is why I went with a macro in the first place.

Chris Bloom

unread,
Dec 5, 2012, 9:05:23 PM12/5/12
to rs...@googlegroups.com
BTW: Thank you for your feedback so far!

David Chelimsky

unread,
Dec 5, 2012, 10:11:12 PM12/5/12
to rs...@googlegroups.com
On Wed, Dec 5, 2012 at 8:05 PM, Chris Bloom <chris...@gmail.com> wrote:
> BTW: Thank you for your feedback so far!
>
>
> On Wed, Dec 5, 2012 at 9:04 PM, Chris Bloom <chris...@gmail.com> wrote:
>>
>> I like the idea of breaking it out into small sub-tests, but I think both
>> of the issues I mentioned previously would still be present. That is, it
>> doesn't appear that the before block is executed for macros,

If your macro generates examples (unlike your first email this thread)
the before hooks will be run before the examples are run.

>> so instance
>> variables from my setup code aren't available, and if I write it as a
>> matcher I still won't get the correct test result messages. I need to test
>> this behavior for an indeterminate number of API endpoints, which is why I
>> went with a macro in the first place.

You can wrap my previous suggestion in a shared example group and get
something closer to what you're looking for:

shared_examples "minimum params" do |*args|
url = description

it "is valid with min params" do
get url, args.inject({}) {|h, k| h.merge(k => "")}
expect(response.status).to eq(200)
expect(response.body).not_to include("missing parameter:")
end

args.each do |p|
it "requires #{p} in params" do
get url, (args - [p]).inject({}) {|h, k| h.merge(k => "")}
expect(response.status).to eq(400)
expect(response.body).to include("missing parameter: #{p}")
end
end
end

describe "API" do
describe "/api/v1/foo" do
include_examples "minimum params", "k1", "k2"
end

describe "/api/v1/bar" do
include_examples "minimum params", "k3", "k4", "k5", "k6"
end
end

This outputs as follows:

$ rspec example_spec.rb -cfd

API
/api/v1/foo
is valid with min params
requires k1 in params
requires k2 in params
/api/v1/bar
is valid with min params
requires k3 in params
requires k4 in params
requires k5 in params
requires k6 in params

David Chelimsky

unread,
Dec 5, 2012, 10:13:36 PM12/5/12
to rs...@googlegroups.com
Here with better variable names:

shared_examples "minimum params" do |*params|
url = description

it "is valid with min params" do
get url, params.inject({}) {|h, p| h.merge(p => "")}
expect(response.status).to eq(200)
expect(response.body).not_to include("missing parameter:")
end

params.each do |param|
it "requires #{param} in params" do
get url, (params - [param]).inject({}) {|h, p| h.merge(p => "")}
expect(response.status).to eq(400)
expect(response.body).to include("missing parameter: #{param}")

Chris Bloom

unread,
Dec 5, 2012, 10:40:31 PM12/5/12
to rs...@googlegroups.com
Ah, OK. I'm beginning to understand the problem I was having with the macro implementation then. i was trying to pass in instance variables from my before block as parameters to the macro, but they wouldn't be available until the example inside the macro is run. That makes sense then.

I think I'll try out the shared_examples implementation. That seems like it is a nice balance of being reusable, and being descriptive as a whole as well as descriptive in the sense of using smaller chunks to describe specific behavior.

Thanks for helping me understand this, David.
Reply all
Reply to author
Forward
0 new messages