Skip to content

Commit

Permalink
Add strict_mode to Test::Stubs (#1298)
Browse files Browse the repository at this point in the history
  • Loading branch information
yykamei committed Aug 1, 2021
1 parent a089fb8 commit a1ee375
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 8 deletions.
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

0 comments on commit a1ee375

Please sign in to comment.