Skip to content

Instantly share code, notes, and snippets.

@pbjorklund
Created January 20, 2012 10:18
Show Gist options
  • Save pbjorklund/1646567 to your computer and use it in GitHub Desktop.
Save pbjorklund/1646567 to your computer and use it in GitHub Desktop.
Transcript of avdi's vcr screencast
http://avdi.org/devblog/2011/04/11/screencast-taping-api-interactions-with-vcr/
== What is it
Testing web apps. Modern web apps are more often then not interconnected and talking with at least one RESTful api. These can be social networking sites, url shorteners, payment gateways etc.
This presents an interesting problem when it comes to high level integration testing. There are a few different approaches to testing on this level.
* Live testing - Talking directly to external sources
Slow, makes continuous integration a pain
Dependant on the services, can be in maintenance mode
Can run into API-call limits
* Put facades on all API calls
Replace calls with mocks or stubs
Fast - reliable, but we are not integration testing anymore. The service could have changed but our tests are still passing
* Build fake versions of the external services
Put together a small Sinatra app to mimic the twitter api
A lot of time is put into maintaining the fake applications to make sure it behaves like the original service
* Use libraries like fake web or web mock
Replace calls with fake calls
Another form of stubbing and mocking
Faking the HTTP responses for any external call
Same problem with maintenance as the previous example
I have done all of these but haven't been that happy with any of them
== VCR
A new approach to testing external API's
You write your cucumber tests like they are going to hit the live servers. VCR acts as a middleman and records all the interactions that you do. HTTP Request / response. Records in YAML files.
When we run the tests again it automatically replays the interactions. The application believes that it is hitting the external service. VCR is mashing the external requests against actions that is already recorded and returns the HTTPs response.
Its very fast and no longer dependent on the external services. It gives us the ability to tweak the recordings if we need to tweak them a little bit to make our tests work.
Periodically we can clean out the recordings and re-record them.
== Google spreadsheet api
First I need for the app to be able to auth against the google api.
The first step is requiring the VCR 1.8.0 (current version) gem in our Gemfile in the test/dev group.
Now I need to actually use it. But first we need to do a little bit of configuration.
Edit config/environments/test.rb
Add
VCR.config do |c|
c.casette_library_dir = File.expand_path('vcr_casette', Rails.root)
c.stubs_with :typhoeus
c.allow_http_connections_when_no_casette = false # Sets things up so that HTTP connections only can be made through VCR. With this, all HTTP requests not through VCR will result in an exception
c.default_casette_options = {
:record => :once # Check to see if the cassette exists. If it does then just play it back. Otherwise record it
}
end
VCR integrates with a bunch of HTTP stubbing libraries such as fake web and web mock.
I could use VCR directly in my cucumber steps. But I like to put a helper around it.
So I'm going to go into my features/support directory and create a new file called VCR.rb and add a few methods that I find handy in here.
module VcrHelpers
def slugify(*strings) #This is a simple name to create nice filenames from arbitrary strings
strings.map {|s| s.strip.downcase.tr_s("^a-z0-9_". "_").join("_").squeeze("_")}
end
def using_vcr_casette(*path_segments, &block) # I want to make sure that recordings go into the features directory
path_segments = path_segments.dup
path_segments.unshift("features")
casette_name = path_segments.map { |s| slugify(s).join("/")}
VCR.use_casette(casette_name, &block)
end
end
This give us some helpers but I need to make sure to make them available to our cucumber steps by adding
World(VcrHelpers)
Scenario: Sign in for the first time
When I enter "25 situps"
And I grant access to Google Sheet
Then I should see "Logged in as bench_test@inbox.avdi.org"
In the step definitions it's going to look for the sign in with google link and simulate the process of clicking that link, getting authorization from google. What google will do is that when a google authorizes it's going to redirect back to the applications provided callback url and provide a token to that callback.
Inside the callback handler it's making a request back to google to upgrade the token to a permanent token. This is what we want to record.
using_vcr_casette("grant_access") do
#vcr_casettes/features/grant_access.yml
visit(callback_url_to_s)
end
Well it didn't log us in successfully. But we got a vcr_casettes directory with a features directory and a grant_access.yml file.
This is just an ordinary yml file. It starts each file with an HTTP interaction object.
We did a get against google AuthSubSessionToken with the fake token that the test uses.
Lo and behold we get an invalid auth token request!
We have a problem, we want to record this interaction so that we can play it back.
It's not pure REST-ful calls. The user needs to be presented with the login screen and hit the grant button and that will redirect back to the application.
It's hard to automate this in a test, so what to do?
I though about this for a while and I realized that we have this way to record arbitrary HTTP interactions. There is no reason that we only have to do this in the test, we can also do this in development mode and reuse them in test.
What I'm going to do is create a very simple way to record live use of the application, all the API interactions that happens in the background as we use the application.
The first step is to configure the application to run with VCR enabled in development mode.
Im going to go back to the config/environments/test and grab the section we inputed earlier and paste it in to config/environments/development. Im going to modify it just a tad.
Im going to switch record mode over to :all, now it's always going to hit the live servers.
And then what I'm going to do is say at the end
VCR.insert_casette ENV.fetch("VCR_CASETTE") { "development"}
Since we have an insert we need a matching eject
at_exit {
VCR.eject_casette
}
This gives us something rudimentary, but I want a way to start new recordings and stopping them while the application is running.
Im going to create a very basic controller for controlling the VCR-recording.
/app/controllers/vcr_controller.rb
class VcrController < ApplicationController
def index
end
def insert
casette_name = File.join("development", params[:cassette][:name])
casette = VCR.insert_casette(casette_name)
flash[:notice] = "Inserted casette #{casette_name}"
redirect_to :action => "index"
end
def eject
cassette = VCR.eject_casette #VCR uses a stack of cassettes, so eject is pop and insert is push
flash[:notice] = "Ejected casette#{casette_name}"
redirect_to :action => "index"
end
end
Now we need a new view /app/views/vcr/index.rb. Im using the erector view system so the view file is just another ruby class.
class Views::Vcr::Index < Views::Layouts::Page
def content
render_flash
h1 "VCR Casettes"
p {
text "Current cassette: "
span VCR.current_casette.name, :class => "current_casette"
}
end
end
So lets just briefly turn on the application to make sure that we are not making any glaring mistakes.
I realize that one thing that I have to do before we use the new controller is make some routes for it. Go to the route file
We know that this should only be avil in development
if Rails.env.development?
match "vcr/insert" => "vcr#insert", :via => :post
match "vcr/eject" => "vcr#eject", :via => :post
match "vcr" => "vcr#index"
end
So I went to the vcr path and we can see the view. The default cassette name is development if you remember.
Now lets go back to the index template.
class Views::Vcr::Index < Views::Layouts::Page
def content
render_flash
h1 "VCR Casettes"
p {
text "Current cassette: "
span VCR.current_casette.name, :class => "current_casette"
}
button_to("Eject casette", vcr_eject_path)
form_for(:cassette, :url => vcr_insert_path) { |f|"
f.label :name. "Casette name: "
f.text_field :name
f.submit "Insert Casette"
}
end
end
And that's it for our view.
Im going to insert a new cassette called authorization, press insert cassette.
Now I'm going to to back to the application homepage and put some data in, sign in with google and choose the test account, grant access and get redirected back to the index page.
Im going to go back to me vcr controller and press eject cassette.
You need to remember that VCR only writes the recording when the cassette is ejected.
Now if I check the cassette directory there is a new dir with development/authorization.yml with a recording that does not contain a rejection but a working authorization.
Im just going to grab the entire recording, go into the feature we recorded before (grant_acess) and replace it with the recording we just did.
Im going to go back to the feature file and try to run it again. We are still not passing.
showing Token in the grant_acess.yml
The reason that it's not passing is that I'm not pulling the correct data from the callback. Im trying to pull the token from the response headers hash but when I check I can see that we get an expires header and that the token is in a string in the body. It's a plain-text reply. I need to update my google callback handler to reflect that.
*fixes off-recording*
Brought back the code that actually shows the logged in user. Had to make a couple more tweaks.
Now that I am showing the currently_logged in user I had to change only of my step definitions to use a cassette.
When I enter "25 situps" that enters data into the entry field and hits submit, now that I'm showing user information that can trigger an api call to google. Since we are disallowing http calls I had to surround that with a cassette.
using_vcr_casette("enter #{text}") do
When
And
And
end
This is going to use the cassette enter 25 pushups
Ok there isn't a cassette for that but the ability to enter arbitrary names like this enables us to have different recordings for different cucumber steps.
Another thing i had to do was change the using_vcr_casette("grant acess", :record => :new_episode)
In record once mode it will check if the cassette exists and then it won't record any new interactions. It will limit the HTTP interactions to the ones that have already been recorded. If you try to do something that is not recorded.
With new_episode if you make an API call that is not in the cassette it will add it to the end of that cassette and save it again.
I have done that and ran the features again and what we can see is that in my grant_acess cassette it has appended the new interaction.
The new using_vcr_casette helper changed to
def using_vcr_casette(*path_segments, &block) # I want to make sure that recordings go into the features directory
options = path_segments.last.is_a?(Hash) ? path_segments.pop : {}
path_segments = path_segments.dup
path_segments.unshift("features")
casette_name = path_segments.map { |s| slugify(s).join("/")}
VCR.use_casette(casette_name, options, &block)
end
So with all that in place I can run that scenario all the way through and it passes and I could have turned off my network connection and done that since all the interactions are recorded. If I ever want to refresh that and make sure that it's still up to date I can just delete the yml files or move them and run it again and make sure the tests pass again.
Thats about it. Check me out at http://avdi.org @avdi on twitter
@pbjorklund
Copy link
Author

It would benefit from a quick search and replace of tabs to spaces

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment