Accessing JavaScript objects from Capybara/Selenium

When testing JavaScript functionality in Selenium (E2E), you may need to access a class or function inside of a evaluate_script block in one of your steps. Capybara may only access definitions that are attached to the browser (over the window object that acts as the base). That means that once you are exporting your definition(s) in Webpacker, these won't be available in your tests (and neither in the dev console). The following principles/concepts also apply to Sprockets.

Say we have a StreetMap class:

// street_map.js
class StreetMap {
  function getLatitude() {
    // ...
  }

  function renderOn(mapElement) {
    // ...
  }
}

export default StreetMap

Your HTML structure may look like this:

<div class="map" target="Hollywood"></div>

The target attribute may be fetched from the database/your record.
This results in the following compiler (when using Unpoly):

import StreetMap from 'street_map'

up.compiler('.map', function(map) {
  let targetLocation = map.getAttr('target')
  let streetMap = new StreetMap(targetLocation)
  streetMap.renderOn(map)
})

Note: For simplification purposes we import the StreetMap directly. In a real application, you probably don't want to do this.

Now we want to have a Capybara step that checks if the latitude on the map's rendering corresponds to the given location. For this we can search for our map component once the page is loaded and build a StreetMap instance with its data to see, if it applies correctly. Our code would roughly look like this:

Then(/^I should see a map with the current latitude "([^"]*)"$/) do |latitude|
  returned_latitude = page.evaluate_async_script(<<~JS)
    let [done] = arguments
    let mapElement = document.querySelector('.map')
    new StreetMap(mapElement).getLatitude().then((latitude) => {
      done(latitude)
    })
  JS
  expect(returned_latitude).to eq latitude
end

When we include this step into a scenario and run it, it will fail because StreetMap is not defined. It's just a module that is handled by Webpacker internally and the browser doesn't know anything about it.
The easiest solution is to just attach StreetMap to window:

// street_map.js
class StreetMap {
  function getLatitude() {
    // ...
  }

  function renderOn(mapElement) {
    // ...
  }
}

window.StreetMap = StreetMap
// ...

This works and our test will run. Unfortunately, there are caveats:

  • We are again polluting the global scope which is something we'd actually like to avoid by using Webpacker and why we used modules in the first place.
  • We "copy" (parts of) the compiler function by creating a new StreetMap instead of using the existing instance of our map element. This shadows a part of our actual integration - what happens if you accidentally break your compiler implementation at the point of building the visual street map? Also this needs to be adapted to the implementation every time we change something. DRY!

We should rather register the instance we want to access on our specific component. You can achieve that by attaching the StreetMap as a property to the specific element you get in your compiler function:

up.compiler('.map', function(map) {
  let targetLocation = map.getAttr('target')
  let streetMap = new StreetMap(targetLocation)
  streetMap.renderOn(map)
  map.streetMap = streetMap
})

Rewrite your step like this:

Then(/^I should see a map with the current latitude "([^"]*)"$/) do |latitude|
  map = page.find('.map')
  returned_latitude = map.evaluate_async_script(<<~JS)
    let [done] = arguments
    this.streetMap.getLatitude().then((latitude) => {
      done(latitude)
    })
  JS
  expect(returned_latitude).to eq latitude
end

Note that we no longer call evaluate_async_script (or evaluate_script, if you're running synchronously) on page, but the map element itself. Inside of the Heredoc we can then refer to map by calling this.
By doing this, we no longer reimplement any logic in our test, gain control over the actual generated object and keep the global scope clean.

Dominic Beger Almost 3 years ago