Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

VCR-like cache #7

Merged
merged 12 commits into from

3 participants

@Swizec
Collaborator

Hey,

I prefer VCR to manually mocking every API under the sun and I wanted to use it when testing javascript that makes API requests.

Since that's impossible I improved puffing-billy to do that.

Things added:

  • ability to persist cache
  • ability to ignore parameters on some requests
  • caching different types of requests
  • added all of the above to README

In essence this means you can take frontend tests completely offline without mocking out every analytics and social button thing on your website and with full support for repeatable tests when Javascript talks to 3rd parties (stripe, balanced, and so on).

The ignoring parameters part is important because it makes things like google analytics and social buttons cacheable since they don't really affect tests, but do make a 3rd party request and always with a different URL.

I've also added tests for all of this to the best of my abilities.

Cheers,
~Swizec

@capitalist

Yes, please.

@oesmith oesmith merged commit 358ea75 into from
@oesmith
Owner

Great stuff, and it all worked straight away on some big rails projects I tried it on -- very impressive :+1:

A couple of small comments:

  • Prefer do .. end on multiline blocks instead of curlies.
  • Having a config flag and a method to call to set up the persistent cache is one step too many, imho. I'm sure we can find a cleaner way to do it :smile:

Since this has been a great addition (and a very clean PR), I've added you as a collaborator on the repo. Feel free to push changes you're confident with. Otherwise, open a pull request for discussion as usual.

THANKS!

@Swizec
Collaborator

Yay \o/

Any particular reason why do ... end is better than curly brackets?

As for the setup, in theory setting the flag should be enough. But for some reason the proxy is instantiated before spec_helper runs your config, which means that when it's spinning up the flag isn't set yet and it doesn't read the cache from YAML files. So unless you reload the cache manually, it will not be there first time requests are made.

I couldn't figure out how to fix this one ...

@oesmith
Owner
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Feb 27, 2013
  1. @Swizec
Commits on Mar 5, 2013
  1. @Swizec
  2. @Swizec

    multiple request types

    Swizec authored
Commits on Mar 6, 2013
  1. @Swizec
Commits on Mar 8, 2013
  1. @Swizec

    current tests passing

    Swizec authored
  2. @Swizec

    test for ignoring params

    Swizec authored
  3. @Swizec
Commits on Mar 11, 2013
  1. @Swizec
  2. @Swizec
Commits on Mar 12, 2013
  1. @Swizec

    debugging leftovers

    Swizec authored
  2. @Swizec
Commits on Mar 14, 2013
  1. @Swizec
This page is out of date. Refresh to see the latest.
View
88 Gemfile.lock
@@ -13,22 +13,23 @@ PATH
GEM
remote: https://rubygems.org/
specs:
- addressable (2.3.2)
- capybara (1.1.2)
+ addressable (2.3.3)
+ capybara (2.0.2)
mime-types (>= 1.16)
nokogiri (>= 1.3.3)
rack (>= 1.0.0)
rack-test (>= 0.5.4)
selenium-webdriver (~> 2.0)
- xpath (~> 0.1.4)
- capybara-webkit (0.12.1)
- capybara (>= 1.0.0, < 1.2)
+ xpath (~> 1.0.0)
+ capybara-webkit (0.14.2)
+ capybara (~> 2.0, >= 2.0.2)
json
- childprocess (0.3.5)
- ffi (~> 1.0, >= 1.0.6)
+ childprocess (0.3.8)
+ ffi (~> 1.0, >= 1.0.11)
+ coderay (1.0.9)
cookiejar (0.3.0)
daemons (1.1.9)
- diff-lcs (1.1.3)
+ diff-lcs (1.2.1)
em-http-request (1.0.3)
addressable (>= 2.2.3)
cookiejar
@@ -39,47 +40,62 @@ GEM
eventmachine (>= 1.0.0.beta.4)
eventmachine (1.0.0)
eventmachine_httpserver (0.2.1)
- faraday (0.8.4)
+ faraday (0.8.6)
multipart-post (~> 1.1)
- faye-websocket (0.4.6)
+ faye-websocket (0.4.7)
eventmachine (>= 0.12.0)
- ffi (1.1.5)
+ ffi (1.4.0)
+ guard (1.6.2)
+ listen (>= 0.6.0)
+ lumberjack (>= 1.0.2)
+ pry (>= 0.9.10)
+ terminal-table (>= 1.4.3)
+ thor (>= 0.14.6)
http_parser.rb (0.5.3)
- json (1.7.5)
- libwebsocket (0.1.5)
- addressable
- mime-types (1.19)
- multi_json (1.3.6)
- multipart-post (1.1.5)
- nokogiri (1.5.5)
- poltergeist (0.7.0)
- capybara (~> 1.1)
- childprocess (~> 0.3)
+ json (1.7.7)
+ listen (0.7.3)
+ lumberjack (1.0.2)
+ method_source (0.8.1)
+ mime-types (1.21)
+ multi_json (1.6.1)
+ multipart-post (1.2.0)
+ nokogiri (1.5.6)
+ poltergeist (1.1.0)
+ capybara (~> 2.0, >= 2.0.1)
faye-websocket (~> 0.4, >= 0.4.4)
http_parser.rb (~> 0.5.3)
- multi_json (~> 1.0)
- rack (1.4.1)
+ pry (0.9.12)
+ coderay (~> 1.0.5)
+ method_source (~> 0.8)
+ slop (~> 3.4)
+ rack (1.5.2)
rack-test (0.6.2)
rack (>= 1.0)
- rspec (2.11.0)
- rspec-core (~> 2.11.0)
- rspec-expectations (~> 2.11.0)
- rspec-mocks (~> 2.11.0)
- rspec-core (2.11.1)
- rspec-expectations (2.11.3)
- diff-lcs (~> 1.1.3)
- rspec-mocks (2.11.3)
+ rb-inotify (0.9.0)
+ ffi (>= 0.5.0)
+ rspec (2.13.0)
+ rspec-core (~> 2.13.0)
+ rspec-expectations (~> 2.13.0)
+ rspec-mocks (~> 2.13.0)
+ rspec-core (2.13.0)
+ rspec-expectations (2.13.0)
+ diff-lcs (>= 1.1.3, < 2.0)
+ rspec-mocks (2.13.0)
rubyzip (0.9.9)
- selenium-webdriver (2.25.0)
+ selenium-webdriver (2.30.0)
childprocess (>= 0.2.5)
- libwebsocket (~> 0.1.3)
multi_json (~> 1.0)
rubyzip
- thin (1.4.1)
+ websocket (~> 1.0.4)
+ slop (3.4.3)
+ terminal-table (1.4.5)
+ thin (1.5.0)
daemons (>= 1.0.9)
eventmachine (>= 0.12.6)
rack (>= 1.0.0)
- xpath (0.1.4)
+ thor (0.17.0)
+ websocket (1.0.7)
+ xpath (1.0.0)
nokogiri (~> 1.3)
yajl-ruby (1.1.0)
@@ -89,9 +105,11 @@ PLATFORMS
DEPENDENCIES
capybara-webkit
faraday
+ guard
poltergeist
puffing-billy!
rack
+ rb-inotify
rspec
selenium-webdriver
thin
View
23 Guardfile
@@ -0,0 +1,23 @@
+# A sample Guardfile
+# More info at https://github.com/guard/guard#readme
+
+guard 'rspec', :version => 2 do
+ watch(%r{^spec/.+_spec\.rb$})
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
+ watch('spec/spec_helper.rb') { "spec" }
+
+ # Rails example
+ watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
+ watch(%r{^app/(.*)(\.erb|\.haml)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
+ watch(%r{^app/controllers/(.+)_(controller)\.rb$}) { |m| ["spec/routing/#{m[1]}_routing_spec.rb", "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", "spec/acceptance/#{m[1]}_spec.rb"] }
+ watch(%r{^spec/support/(.+)\.rb$}) { "spec" }
+ watch('config/routes.rb') { "spec/routing" }
+ watch('app/controllers/application_controller.rb') { "spec/controllers" }
+
+ # Capybara request specs
+ watch(%r{^app/views/(.+)/.*\.(erb|haml)$}) { |m| "spec/requests/#{m[1]}_spec.rb" }
+
+ # Turnip features and steps
+ watch(%r{^spec/acceptance/(.+)\.feature$})
+ watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) { |m| Dir[File.join("**/#{m[1]}.feature")][0] || 'spec/acceptance' }
+end
View
36 README.md
@@ -85,6 +85,42 @@ proxy.stub('https://example.com/proc/').and_return(Proc.new { |params, headers,
Stubs are reset between tests. Any requests that are not stubbed will be
proxied to the remote server.
+## Caching
+
+Requests routed through the external proxy are cached.
+
+If you want to use puffing-billy like you would [VCR](https://github.com/vcr/vcr) you can turn on cache persistence.
+This way you don't have to manually mock out everything as requests are automatically recorded and played back.
+With cache persistence you can take tests completely offline.
+
+In your `spec_helper.rb`:
+
+```ruby
+Billy.configure do |c|
+ c.cache = true
+ c.ignore_params = ["http://www.google-analytics.com/__utm.gif",
+ "https://r.twimg.com/jot",
+ "http://p.twitter.com/t.gif",
+ "http://p.twitter.com/f.gif",
+ "http://www.facebook.com/plugins/like.php",
+ "https://www.facebook.com/dialog/oauth",
+ "http://cdn.api.twitter.com/1/urls/count.json"]
+ c.persist_cache = true
+ c.cache_path = 'spec/req_cache/'
+end
+
+# need to call this because of a race condition between persist_cache
+# being set and the proxy being loaded for the first time
+Billy.proxy.restore_cache
+```
+
+`c.ignore_params` is used to ignore parameters of certain requests when caching. You should mostly use this for analytics
+and various social buttons as they use cache avoidance techniques, but return practically the same response that most often
+does not affect your test results.
+
+The cache works with all types of requests and will distinguish between differend POST requests to the same URL.
+
+
## Customising the javascript driver
If you use a customised Capybara driver, remember to set the proxy address
View
61 lib/billy/cache.rb
@@ -1,10 +1,12 @@
require 'resolv'
require 'uri'
+require 'yaml'
module Billy
class Cache
def initialize
reset
+ load_dir
end
def cacheable?(url, headers)
@@ -16,24 +18,71 @@ def cacheable?(url, headers)
end
end
- def cached?(url)
- !@cache[url].nil?
+ def cached?(method, url, body)
+ !@cache[key(method, url, body)].nil?
end
- def fetch(url)
- @cache[url]
+ def fetch(method, url, body)
+ @cache[key(method, url, body)]
end
- def store(url, status, headers, content)
- @cache[url] = {
+ def store(method, url, body, status, headers, content)
+ cached = {
+ :url => url,
+ :body => body,
:status => status,
+ :method => method,
:headers => headers,
:content => content
}
+
+ @cache[key(method, url, body)] = cached
+
+ if Billy.config.persist_cache
+ Dir.mkdir(Billy.config.cache_path) unless File.exists?(Billy.config.cache_path)
+
+ begin
+ File.open(Billy.config.cache_path+key(method, url, body)+".yml", 'w') {
+ |f| f.write(cached.to_yaml(:Encoding => :Utf8))
+ }
+ rescue StandardError => e
+ end
+ end
end
def reset
@cache = {}
end
+
+ def load_dir
+ if Billy.config.persist_cache
+ Dir.glob(Billy.config.cache_path+"*.yml") { |filename|
+ data = begin
+ YAML.load(File.open(filename))
+ rescue ArgumentError => e
+ puts "Could not parse YAML: #{e.message}"
+ end
+
+ @cache[key(data[:method], data[:url], data[:body])] = data
+ }
+ end
+ end
+
+ def key(method, url, body)
+ url = URI(url)
+ no_params = url.scheme+'://'+url.host+url.path
+
+ if Billy.config.ignore_params.include?(no_params)
+ url = URI(no_params)
+ end
+
+ key = method+'_'+url.host+'_'+Digest::SHA1.hexdigest(url.to_s)
+
+ if method == 'post' and !Billy.config.ignore_params.include?(no_params)
+ key += '_'+Digest::SHA1.hexdigest(body.to_s)
+ end
+
+ key
+ end
end
end
View
5 lib/billy/config.rb
@@ -4,12 +4,15 @@ module Billy
class Config
DEFAULT_WHITELIST = ['127.0.0.1', 'localhost']
- attr_accessor :logger, :cache, :whitelist
+ attr_accessor :logger, :cache, :whitelist, :ignore_params, :persist_cache, :cache_path
def initialize
@logger = defined?(Rails) ? Rails.logger : Logger.new(STDOUT)
@cache = true
@whitelist = DEFAULT_WHITELIST
+ @ignore_params = []
+ @persist_cache = false
+ @cache_path = '/tmp'
end
end
View
5 lib/billy/proxy.rb
@@ -53,6 +53,11 @@ def reset_cache
@cache.reset
end
+ def restore_cache
+ @cache.reset
+ @cache.load_dir
+ end
+
protected
def find_stub(method, url)
View
10 lib/billy/proxy_connection.rb
@@ -72,6 +72,7 @@ def handle_request
if handler && handler.respond_to?(:call)
result = handler.call(@parser.http_method, @url, @headers, @body)
end
+
if result
Billy.log(:info, "STUB #{@parser.http_method} #{@url}")
response = EM::DelegatedHttpResponse.new(self)
@@ -79,7 +80,7 @@ def handle_request
response.headers = result[1].merge('Connection' => 'close')
response.content = result[2]
response.send_response
- elsif @parser.http_method == 'GET' && cache.cached?(@url)
+ elsif cache.cached?(@parser.http_method.downcase, @url, @body)
Billy.log(:info, "CACHE #{@parser.http_method} #{@url}")
respond_from_cache
else
@@ -114,9 +115,10 @@ def proxy_request
res_headers = res_headers.merge('Connection' => 'close')
res_headers.delete('Transfer-Encoding')
res_content = req.response.force_encoding('BINARY')
- if @parser.http_method == 'GET' && cache.cacheable?(@url, res_headers)
- cache.store(@url, res_status, res_headers, res_content)
+ if cache.cacheable?(@url, res_headers)
+ cache.store(@parser.http_method.downcase, @url, @body, res_status, res_headers, res_content)
end
+
res = EM::DelegatedHttpResponse.new(self)
res.status = res_status
res.headers = res_headers
@@ -126,7 +128,7 @@ def proxy_request
end
def respond_from_cache
- cached_res = cache.fetch(@url)
+ cached_res = cache.fetch(@parser.http_method.downcase, @url, @body)
res = EM::DelegatedHttpResponse.new(self)
res.status = cached_res[:status]
res.headers = cached_res[:headers]
View
2  puffing-billy.gemspec
@@ -22,6 +22,8 @@ Gem::Specification.new do |gem|
gem.add_development_dependency "selenium-webdriver"
gem.add_development_dependency "capybara-webkit"
gem.add_development_dependency "rack"
+ gem.add_development_dependency "guard"
+ gem.add_development_dependency "rb-inotify"
gem.add_runtime_dependency "eventmachine"
gem.add_runtime_dependency "em-http-request"
gem.add_runtime_dependency "eventmachine_httpserver"
View
3  spec/requests/examples/facebook_api_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require 'base64'
-describe 'Facebook API example', :type => :request, :js => true do
+describe 'Facebook API example', :type => :feature, :js => true do
before do
proxy.stub('https://www.facebook.com:443/dialog/oauth').and_return(Proc.new { |params,_,_|
# mock a signed request from facebook. the JS api never verifies the
@@ -20,4 +20,3 @@
page.should have_content "Hi, Tester 1"
end
end
-
View
3  spec/requests/examples/tumblr_api_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Tumblr API example', :type => :request, :js => true do
+describe 'Tumblr API example', :type => :feature, :js => true do
before do
proxy.stub('http://blog.howmanyleft.co.uk/api/read/json').and_return(
:jsonp => {
@@ -27,4 +27,3 @@
page.should have_content('News item 2 content here')
end
end
-
View
79 spec/requests/proxy_spec.rb
@@ -87,6 +87,85 @@
}.to_not change { r.body }
end
end
+
+ context 'ignore_params GET requests' do
+ around do |example|
+ Billy.configure { |c| c.ignore_params = ['/analytics'] }
+ example.run
+ Billy.configure { |c| c.ignore_params = [] }
+ end
+
+ it 'should be cached' do
+ r = http.get('/analytics?some_param=5')
+ r.body.should == 'GET /analytics'
+ expect {
+ expect {
+ r = http.get('/analytics?some_param=20')
+ }.to change { r.headers['HTTP-X-EchoCount'].to_i }.by(1)
+ }.to_not change { r.body }
+ end
+ end
+
+ context "cache persistence" do
+ def key(method, url)
+ url = proxy.url+url
+
+ url = URI(url)
+ no_params = url.scheme+'://'+url.host+url.path
+
+ if Billy.config.ignore_params.include?(no_params)
+ url = URI(no_params)
+ end
+
+ method+'_'+url.host+'_'+Digest::SHA1.hexdigest(url.to_s)
+ end
+
+ context "enabled" do
+ around do |example|
+ # for some reason this isn't getting through to the functions underneath
+ Billy.configure { |c|
+ c.persist_cache = true
+ c.cache_path = '/tmp/cache'
+ c.ignore_params = []
+ }
+ example.run
+ Billy.configure { |c|
+ c.persist_cache = false
+ c.cache_path = ''
+ }
+ end
+
+ it 'should persist' do
+ fudge = rand(100)
+ r = http.get('/foo'+fudge.to_s)
+ r.body.should == 'GET /foo'+fudge.to_s
+
+ File.exists?('/tmp/cache'+key('GET', '/foo'+fudge.to_s)).should be_true
+ end
+ end
+
+ context "disabled" do
+ around do |example|
+ Billy.configure { |c|
+ c.persist_cache = false
+ c.cache_path = '/tmp/cache'
+ c.ignore_params = []
+ }
+ example.run
+ Billy.configure { |c|
+ c.persist_cache = false
+ c.cache_path = ''
+ }
+ end
+
+ it 'shouldnt persist' do
+ r = http.get('/foo')
+ r.body.should == 'GET /foo'
+
+ File.exists?('/tmp/cache'+key('GET', '/foo')).should be_false
+ end
+ end
+ end
end
describe Billy::Proxy do
Something went wrong with that request. Please try again.