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.