ryanfiller89@gmail.com

What?

Synthetic events are, by definition, events that do not belong natively to the browser. Instead, a Synthetic Event is a wrapper that some JavaScript frameworks use around a browser’s event specification. In modern web development, Synthetic Events are probably most well known for their use in React.

Why?

console.loging the event emitted from a native click event looks very different than an onClick event in React, especially the prototype of the two events. The native event returns an EventPrototype object while the synthetic event returns a generic Object prototype. A jquery event is also called with a generic Object, albeit with different attributes than the synthetic event.

click {
  target: button#button, 
  buttons: 0, 
  clientX: 42, 
  clientY: 22, 
  layerX: 42, 
  layerY: 22
}

The new-ish HTML5 input types, especially ones that offer custom UI such as type="range" and type="color", emit even more complicated events. These components are internally running a form of JavaScript, but are using something called the Shadow DOM to encapsulate their functionality. Understanding precisely how this works doesn’t necessarily matter for testing, but this article from Ire Aderinokun explains exactly what the Shadow DOM is and how it works.

change {
  target: input#native-input,
  isTrusted: true,
  srcElement: input#native-input,
  currentTarget: input#native-input,
  eventPhase: 2,
  bubbles: true,
  cancelable: false,
  returnValue: true,
  defaultPrevented: false,
  composed: false,
  ...
}

I put together a very basic CodePen example to explore more what each event type will output to the console.

What’s the issue with testing?

Problems arise when using testing frameworks, like Cypress and Cucumber, that rely on using jQuery to try to call DOM events. They do an okay job with older inputs using click events, but oftentimes choke on newer inputs with slightly different browser implementations.

The most common error I have personally run across is a test runner will use jQuery to invoke a change, the native DOM element will correctly update, but the event will not be properly caught by the frontend framework. This shows up visually as an input having changed, but none of the other updates it should trigger elsewhere will be reflected.

cypress.io test running showing the the range input has been changed to 500 but DOM dependents still show 100
The range input has changed to 500, but the textarea and visual styles still reflect a "wght" value of 100

How?

The most reliable way I have found to work around this issue is to use getOwnPropertyDescriptor to fish the .set() method from a browser’s HTMLInputElement object. That native method can then be called directly and attached to a DOM input element using the .dispatchEvent() method.

const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
  window.HTMLInputElement.prototype,
  'value'
).set

const changeInputValue = inputToChange => newValue => {
  nativeInputValueSetter.call(inputToChange[0], newValue)
  inputToChange[0].dispatchEvent(new Event('change', {
    newValue,
    bubbles: true
  }))
}

This function uses Cypress’s jQuery interface to create an event, but still makes sure the full event is fired from the browser in a way that it can be caught by the framework’s event listening system.

It is important to set bubbles: true in the dispatchEvent configuration object so that the event will bubble up until the framework can catch it. This is especially true of any framework using synthetic events, like React, as they sometimes use a single event listener and delegate responses to the appropriate DOM nodes.

To make this function more reusable, it can be added as a custom command within the cypress/support/commands.js file.

// cypress/support/commands.js

Cypress.Commands.add('inputChange', (input, value) => {
  const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
    window.HTMLInputElement.prototype,
    'value'
  ).set

  const changeInputValue = inputToChange => newValue => {
    nativeInputValueSetter.call(inputToChange[0], newValue)
    inputToChange[0].dispatchEvent(new Event('change', {
      newValue,
      bubbles: true
    }))
  }

  return cy.get(input).then(input => {
    changeInputValue(input)(value)
  })
})

This command can now be called anywhere the cy global object is available.

// range-input.spec.js

cy.get('#range-input').then(input => cy.inputChange(input, '15'))
A note about other End to End to Runners

This post is specifically about Cypress, which is the testing framework that this site currently uses. I’ve encountered and fixed this exact problem in Cucumber, the testing framework I use for Ruby code at work. The commonality between these two frameworks is they both use jQuery to orchestrate DOM events. There are a lot of testing frameworks out there, if the one you are using is also dependent on jQuery, this post might be helpful to you.