From a1ee375bcda196803d4ec8bf07e110e34e52044f Mon Sep 17 00:00:00 2001 From: Yutaka Kamei Date: Mon, 2 Aug 2021 01:45:32 +0900 Subject: [PATCH] Add strict_mode to Test::Stubs (#1298) --- docs/adapters/testing.md | 27 ++++++++++ examples/client_spec.rb | 23 ++++++++- lib/faraday/adapter/test.rb | 39 ++++++++++++-- lib/faraday/connection.rb | 3 +- spec/faraday/adapter/test_spec.rb | 85 +++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 8 deletions(-) diff --git a/docs/adapters/testing.md b/docs/adapters/testing.md index fb19f68e8..7f7c66ec1 100644 --- a/docs/adapters/testing.md +++ b/docs/adapters/testing.md @@ -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. diff --git a/examples/client_spec.rb b/examples/client_spec.rb index f13a2cafe..264c2c79b 100644 --- a/examples/client_spec.rb +++ b/examples/client_spec.rb @@ -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 @@ -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 diff --git a/lib/faraday/adapter/test.rb b/lib/faraday/adapter/test.rb index 0230b853f..41ebe06b3 100644 --- a/lib/faraday/adapter/test.rb +++ b/lib/faraday/adapter/test.rb @@ -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 # @@ -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 @@ -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) @@ -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 @@ -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 @@ -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) @@ -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 diff --git a/lib/faraday/connection.rb b/lib/faraday/connection.rb index eca09b5cd..3379ec5d2 100644 --- a/lib/faraday/connection.rb +++ b/lib/faraday/connection.rb @@ -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 @@ -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) diff --git a/spec/faraday/adapter/test_spec.rb b/spec/faraday/adapter/test_spec.rb index 53a8ebbb7..32d052426 100644 --- a/spec/faraday/adapter/test_spec.rb +++ b/spec/faraday/adapter/test_spec.rb @@ -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