Mocking HTML5 API's Using PhantomJS Extensions

Geolocation API

https://pixabay.com/static/uploads/photo/2015/09/19/17/02/map-947471_960_720.jpg

Recently one of our projects called for using the browser’s Geolocation API. We were excited about this project. However, we had an immediate concern about how to test a feature that interacts with one of the browser’s built in APIs. Luckily, PhantomJS has extension support which, along with Poltergeist’s options, makes mocking these built in APIs even easier.

Let’s assume we have a simple registration form with an acceptance spec as follows.

feature "Registration" do
  scenario "Normal Registration" do
    visit new_user_path

    fill_in :name, with: "John Doe"
    fill_in :email, with: "john@fake.com"

    click_button "Create"

    expect(page).to have_content "Welcome, John Doe!"

    user = User.last
    expect(user.name).to eq("John Doe")
    expect(user.email).to eq("john@fake.com")
    expect(user.location).to eq("")
  end
end

Now our project manager asks if we can make the location fill in automatically while they are registering.

(As responsible engineers we would tell him it is possible, but we do not see the cost to benefit ratio, right?)

The code part is pretty straightforward, but how do we test it?  If Developer A in Holland, MI hardcodes his lat/long coordinates into the spec, the test will fail on Developer B’s machine in Washington, DC, not to mention Travis-CI.

We would usually mock the request to the external service using something like VCR, but this is not an external service this is part of the browser.

This is where the PhantomJS extensions become so helpful.

We first write our new acceptance spec.

feature "Registration" do
  scenario "Normal Registration" do
    …
  end

  scenario "Geolocation Registration" do
    visit new_user_path

    fill_in :name, with: "Jane Doe"
    fill_in :email, with: "jane@fake.com"

    click_button "Create"

    user = User.last
    expect(user.name).to eq("Jane Doe")
    expect(user.email).to eq("jane@fake.com")
    expect(user.location).to eq("Brooklyn, NY")
  end
end

Now that we have a red test, we can begin working towards an implementation to make it pass.

In order to mock the browser’s Geolocation API we need to create the extension file to include in PhantomJS. Let’s put it at spec/support/phantomjs_ext/geolocation.js

navigator.geolocation = 
{
  getCurrentPosition: function(callback) {
    callback({ coords: { latitude: "40.714224", longitude: "-73.961452" } });
  }
}

The built in Geolocation API getCurrentPosition function prompts the user for access and then calls the callback provided. Our implementation simply returns a predetermined set of coordinates.

Now we just have to let PhantomJS know about this extension. In spec_helper.rb add

Capybara.register_driver :poltergeist do |app|
  Capybara::Poltergeist::Driver.new(
    app,
    extensions: [File.expand_path("../support/phantomjs_ext/geolocation.js", __FILE__)]
  )
end
Capybara.default_driver = :poltergeist

Then it is a matter of writing the js (or coffeescript in this case) code to make the test pass.

if navigator.geolocation
  navigator.geolocation.getCurrentPosition (geoData) ->
    $.getJSON("http://maps.googleapis.com/maps/api/geocode/json?latlng=#{geoData.coords.latitude},#{geoData.coords.longitude}").done (json) -> 
    components = json["results"][0]["address_components"]
    city = (item["short_name"] for item in components when item["types"][0] is "administrative_area_level_3")
    state = (item["short_name"] for item in components when item["types"][0] is "administrative_area_level_1")
    $("#user_location").val("#{city}, #{state}")

Our geolocation test is green, but our first test is failing. Since we do not want the geolocation to kick in on the first test, like a user denying access, we can use the same technique to disable the geolocation.

Instead of adding the extension by default we can add it based on RSpec metadata.

Change our spec_helper.rb from above to

Capybara.register_driver :poltergeist do |app|
  Capybara::Poltergeist::Driver.new(app, extensions: [])
end
Capybara.default_driver = :poltergeist

Then we will add spec/support/phantomjs_ext.rb to define when to use the extension and when not to use it.

RSpec.configure do |config|
  config.before(:each, type: :feature) do
    page.driver.browser.extensions = [File.expand_path("../phantomjs_ext/disable_geolocation.js", __FILE__)]
  end

  config.before(:each, geolocation: true) do
    page.driver.browser.extensions = [File.expand_path("../phantomjs_ext/geolocation.js", __FILE__)]
  end
end

We will add the spec/support/phantomjs_ext/disable_geolocation.js for all non-geolocation features.

navigator.geolocation = false

And finally, we add the metadata to our test that requires geolocation.

feature "Registration" do
  scenario "Normal Registration" do
    …
  end

  scenario "Geolocation Registration", geolocation: true do
    visit new_user_path

    fill_in :name, with: "Jane Doe"
    fill_in :email, with: "jane@fake.com"

    click_button "Create"

    user = User.last
    expect(user.name).to eq("Jane Doe")
    expect(user.email).to eq("jane@fake.com")
    expect(user.location).to eq("Brooklyn, NY")
  end
end

Now that we know how we can mock the browser’s default Geolocation API, we can use this knowledge to mock other things such as HTML5 FileAPI, Web Storage, etc.

Photo of Ryan Glover

Ryan is a U.S. Air Force veteran who now works as a software developer for Collective Idea. Before joining us, Ryan used to develop software for multiple government intelligence agencies.

Comments