Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add strict_mode to Test::Stubs #1298

Merged
merged 5 commits into from Aug 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/adapters/testing.md
Expand Up @@ -64,6 +64,33 @@ initialized. This is useful for testing.
stubs.get('/uni') { |env| [ 200, {}, 'urchin' ]}
```

If you want to stub requests that exactly match a path, parameters, and headers,
`strict_mode` would be useful.

```ruby
stubs = Faraday::Adapter::Test::Stubs.new(strict_mode: true) do |stub|
stub.get('/ikura?nori=true', 'X-Soy-Sauce' => '5ml' ) { |env| [200, {}, 'ikura gunkan maki'] }
end
```

This stub expects the connection will be called like this:

```ruby
conn.get('/ikura', { nori: 'true' }, { 'X-Soy-Sauce' => '5ml' } )
```

If there are other parameters or headers included, the Faraday Test adapter
will raise `Faraday::Test::Stubs::NotFound`. It also raises the error
if the specified parameters (`nori`) or headers (`X-Soy-Sauce`) are omitted.

You can also enable `strict_mode` after initializing the connection.
In this case, all requests, including ones that have been already stubbed,
will be handled in a strict way.

```ruby
stubs.strict_mode = true
```

Finally, you can treat your stubs as mocks by verifying that all of the stubbed
calls were made. NOTE: this feature is still fairly experimental. It will not
verify the order or count of any stub.
Expand Down
23 changes: 21 additions & 2 deletions examples/client_spec.rb
Expand Up @@ -12,8 +12,8 @@ def initialize(conn)
@conn = conn
end

def sushi(jname)
res = @conn.get("/#{jname}")
def sushi(jname, params: {})
res = @conn.get("/#{jname}", params)
data = JSON.parse(res.body)
data['name']
end
Expand Down Expand Up @@ -62,4 +62,23 @@ def sushi(jname)
expect { client.sushi('ebi') }.to raise_error(Faraday::ConnectionFailed)
stubs.verify_stubbed_calls
end

context 'When the test stub is run in strict_mode' do
let(:stubs) { Faraday::Adapter::Test::Stubs.new(strict_mode: true) }

it 'verifies the all parameter values are identical' do
stubs.get('/ebi?abc=123') do
[
200,
{ 'Content-Type': 'application/javascript' },
'{"name": "shrimp"}'
]
end

# uncomment to raise Stubs::NotFound
# expect(client.sushi('ebi', params: { abc: 123, foo: 'Kappa' })).to eq('shrimp')
expect(client.sushi('ebi', params: { abc: 123 })).to eq('shrimp')
stubs.verify_stubbed_calls
end
end
end
39 changes: 34 additions & 5 deletions lib/faraday/adapter/test.rb
Expand Up @@ -25,6 +25,9 @@ class Adapter
# "showing item: #{meta[:match_data][1]}"
# ]
# end
#
# # You can set strict_mode to exactly match the stubbed requests.
# stub.strict_mode = true
# end
# end
#
Expand All @@ -47,10 +50,11 @@ class Stubs
class NotFound < StandardError
end

def initialize
def initialize(strict_mode: false)
# { get: [Stub, Stub] }
@stack = {}
@consumed = {}
@strict_mode = strict_mode
yield(self) if block_given?
end

Expand Down Expand Up @@ -115,6 +119,17 @@ def verify_stubbed_calls
raise failed_stubs.join(' ') unless failed_stubs.empty?
end

# Set strict_mode. If the value is true, this adapter tries to find matched requests strictly,
# which means that all of a path, parameters, and headers must be the same as an actual request.
def strict_mode=(value)
@strict_mode = value
@stack.each do |_method, stubs|
stubs.each do |stub|
stub.strict_mode = value
end
end
end

protected

def new_stub(request_method, path, headers = {}, body = nil, &block)
Expand All @@ -128,7 +143,8 @@ def new_stub(request_method, path, headers = {}, body = nil, &block)
]
end

stub = Stub.new(host, normalized_path, headers, body, block)
headers = Utils::Headers.new(headers)
stub = Stub.new(host, normalized_path, headers, body, @strict_mode, block)
(@stack[request_method] ||= []) << stub
end

Expand All @@ -143,9 +159,9 @@ def matches?(stack, host, path, headers, body)

# Stub request
# rubocop:disable Style/StructInheritance
class Stub < Struct.new(:host, :path, :params, :headers, :body, :block)
class Stub < Struct.new(:host, :path, :params, :headers, :body, :strict_mode, :block)
# rubocop:enable Style/StructInheritance
def initialize(host, full, headers, body, block)
def initialize(host, full, headers, body, strict_mode, block) # rubocop:disable Metrics/ParameterLists
path, query = full.respond_to?(:split) ? full.split('?') : full
params =
if query
Expand All @@ -154,7 +170,7 @@ def initialize(host, full, headers, body, block)
{}
end

super(host, path, params, headers, body, block)
super(host, path, params, headers, body, strict_mode, block)
end

def matches?(request_host, request_uri, request_headers, request_body)
Expand Down Expand Up @@ -184,12 +200,25 @@ def path_match?(request_path, meta)
end

def params_match?(request_params)
if strict_mode
return Set.new(params) == Set.new(request_params)
end

params.keys.all? do |key|
request_params[key] == params[key]
end
end

def headers_match?(request_headers)
if strict_mode
headers_with_user_agent = headers.dup.tap do |hs|
# NOTE: Set User-Agent in case it's not set when creating Stubs.
# Users would not want to set Faraday's User-Agent explicitly.
hs[:user_agent] ||= Connection::USER_AGENT
end
return Set.new(headers_with_user_agent) == Set.new(request_headers)
end

headers.keys.all? do |key|
request_headers[key] == headers[key]
end
Expand Down
3 changes: 2 additions & 1 deletion lib/faraday/connection.rb
Expand Up @@ -15,6 +15,7 @@ module Faraday
class Connection
# A Set of allowed HTTP verbs.
METHODS = Set.new %i[get post put delete head patch options trace]
USER_AGENT = "Faraday v#{VERSION}"

# @return [Hash] URI query unencoded key/value pairs.
attr_reader :params
Expand Down Expand Up @@ -89,7 +90,7 @@ def initialize(url = nil, options = nil)

yield(self) if block_given?

@headers[:user_agent] ||= "Faraday v#{VERSION}"
@headers[:user_agent] ||= USER_AGENT
end

def initialize_proxy(url, options)
Expand Down
85 changes: 85 additions & 0 deletions spec/faraday/adapter/test_spec.rb
Expand Up @@ -257,4 +257,89 @@ def make_request
it { expect { request }.to raise_error described_class::Stubs::NotFound }
end
end

describe 'strict_mode' do
let(:stubs) do
described_class::Stubs.new(strict_mode: true) do |stubs|
stubs.get('/strict?a=12&b=xy', 'Authorization' => 'Bearer m_ck', 'X-C' => 'hello') { [200, {}, 'a'] }
stubs.get('/with_user_agent?a=12&b=xy', authorization: 'Bearer m_ck', 'User-Agent' => 'My Agent') { [200, {}, 'a'] }
end
end

context 'when params and headers are exactly set' do
subject(:request) { connection.get('/strict', { a: '12', b: 'xy' }, { authorization: 'Bearer m_ck', x_c: 'hello' }) }

it { expect(request.status).to eq 200 }
end

context 'when params and headers are exactly set with a custom user agent' do
subject(:request) { connection.get('/with_user_agent', { a: '12', b: 'xy' }, { authorization: 'Bearer m_ck', 'User-Agent' => 'My Agent' }) }

it { expect(request.status).to eq 200 }
end

shared_examples 'raise NotFound when params do not satisfy the strict check' do |params|
subject(:request) { connection.get('/strict', params, { 'Authorization' => 'Bearer m_ck', 'X-C' => 'hello' }) }

context "with #{params.inspect}" do
it { expect { request }.to raise_error described_class::Stubs::NotFound }
end
end

it_behaves_like 'raise NotFound when params do not satisfy the strict check', { a: '12' }
it_behaves_like 'raise NotFound when params do not satisfy the strict check', { b: 'xy' }
it_behaves_like 'raise NotFound when params do not satisfy the strict check', { a: '123', b: 'xy' }
it_behaves_like 'raise NotFound when params do not satisfy the strict check', { a: '12', b: 'xyz' }
it_behaves_like 'raise NotFound when params do not satisfy the strict check', { a: '12', b: 'xy', c: 'hello' }
it_behaves_like 'raise NotFound when params do not satisfy the strict check', { additional: 'special', a: '12', b: 'xy', c: 'hello' }

shared_examples 'raise NotFound when headers do not satisfy the strict check' do |path, headers|
subject(:request) { connection.get(path, { a: 12, b: 'xy' }, headers) }

context "with #{headers.inspect}" do
it { expect { request }.to raise_error described_class::Stubs::NotFound }
end
end

it_behaves_like 'raise NotFound when headers do not satisfy the strict check', '/strict', { authorization: 'Bearer m_ck' }
it_behaves_like 'raise NotFound when headers do not satisfy the strict check', '/strict', { 'X-C' => 'hello' }
it_behaves_like 'raise NotFound when headers do not satisfy the strict check', '/strict', { authorization: 'Bearer m_ck', 'x-c': 'Hi' }
it_behaves_like 'raise NotFound when headers do not satisfy the strict check', '/strict', { authorization: 'Basic m_ck', 'x-c': 'hello' }
it_behaves_like 'raise NotFound when headers do not satisfy the strict check', '/strict', { authorization: 'Bearer m_ck', 'x-c': 'hello', x_special: 'special' }
it_behaves_like 'raise NotFound when headers do not satisfy the strict check', '/with_user_agent', { authorization: 'Bearer m_ck' }
it_behaves_like 'raise NotFound when headers do not satisfy the strict check', '/with_user_agent', { authorization: 'Bearer m_ck', user_agent: 'Unknown' }
it_behaves_like 'raise NotFound when headers do not satisfy the strict check', '/with_user_agent', { authorization: 'Bearer m_ck', user_agent: 'My Agent', x_special: 'special' }

context 'when strict_mode is disabled' do
before do
stubs.strict_mode = false
end

shared_examples 'does not raise NotFound even when params do not satisfy the strict check' do |params|
subject(:request) { connection.get('/strict', params, { 'Authorization' => 'Bearer m_ck', 'X-C' => 'hello' }) }

context "with #{params.inspect}" do
it { expect(request.status).to eq 200 }
end
end

it_behaves_like 'does not raise NotFound even when params do not satisfy the strict check', { a: '12', b: 'xy' }
it_behaves_like 'does not raise NotFound even when params do not satisfy the strict check', { a: '12', b: 'xy', c: 'hello' }
it_behaves_like 'does not raise NotFound even when params do not satisfy the strict check', { additional: 'special', a: '12', b: 'xy', c: 'hello' }

shared_examples 'does not raise NotFound even when headers do not satisfy the strict check' do |path, headers|
subject(:request) { connection.get(path, { a: 12, b: 'xy' }, headers) }

context "with #{headers.inspect}" do
it { expect(request.status).to eq 200 }
end
end

it_behaves_like 'does not raise NotFound even when headers do not satisfy the strict check', '/strict', { authorization: 'Bearer m_ck', 'x-c': 'hello' }
it_behaves_like 'does not raise NotFound even when headers do not satisfy the strict check', '/strict', { authorization: 'Bearer m_ck', 'x-c': 'hello', x_special: 'special' }
it_behaves_like 'does not raise NotFound even when headers do not satisfy the strict check', '/strict', { authorization: 'Bearer m_ck', 'x-c': 'hello', user_agent: 'Special Agent' }
it_behaves_like 'does not raise NotFound even when headers do not satisfy the strict check', '/with_user_agent', { authorization: 'Bearer m_ck', user_agent: 'My Agent' }
it_behaves_like 'does not raise NotFound even when headers do not satisfy the strict check', '/with_user_agent', { authorization: 'Bearer m_ck', user_agent: 'My Agent', x_special: 'special' }
end
end
end