Testing the Content of JSX Data in Cypress

Hero image for Testing the Content of JSX Data in Cypress. Image by John Kavanagh.
Hero image for 'Testing the Content of JSX Data in Cypress.' Image by John Kavanagh.

I've recently been working on a new feature for my airline client, which involves extending the new search interface my team and I recently launched. With the previous search experience, the marketing team had been using Adobe Target to target specific search queries to display seasonal and locationspecific messages. Naturally, this was a feature the client was keen to include in the new search experience. Equally as naturally, I was not keen for them to use Target (which is after all only intended as an A/B testing tool and not a featureadder) to do this.

So, whilst we waited for the new headless CMS to be ready, we were presented with a set of markup which had been extracted from Target and was to be displayed strictly as provided. We ended up with a data structure where each object was an airport code (or series in the case of targeting specific journeys), with an accompanying title (a string) and description (as JSX directly from the HTML markup provided).

Here's a brief excerpt demonstrating the message for Antigua (whether the arrival or departure airport) and for the journey from Barbados to Edinburgh:

export const messages = {  ANU: {    title: 'Looking for flights to Antigua?',    description: (      <>        <p>          Fly direct to Antigua with Virgin Atlantic during our seasonal service{' '}          <strong>three times a week</strong>.        </p>      </>    ),  },  'BGI-EDI': {    title: 'We can see you searched for Edinburgh to Barbados flights.',    description: (      <>        <p>          Unfortunately we recently made the tough choice to suspend our plans to          operate these services. We're still serving this Caribbean favourite          from Manchester and London Heathrow, if you'd like to fly from there          instead.        </p>      </>    ),  },};

I should mention here that some of the messages also include lists and other markup artefacts, which is why each description has been wrapped in a fragment (<></>), although it would make no difference whether some were multiple elements within fragments whilst others were singleelement items,

Development for this was relatively straightforward, and before too long, we had everything working:


Test Coverage

In this project environment, we use Cypress for test coverage. Testing that the message was displayed for specific searches was relatively straightforward; the complication with Cypress comes when you need as we did to test JSX, to ensure that the right message is being displayed for the right search.

In Cypress (or any endtoend testing framework), testing for JSX matches presents a challenge because these frameworks only operate and test against the final, rendered HTML that your component or application produces, not the React component tree of the JSX itself. React describes with the UI of a component should look like; when rendered, JSX is transformed into React.createElement calls, which in turn produce JavaScript objects that represent the desired DOM structure (the "virtual DOM").

Whilst in our case, we are essentially only using JSX as an HTML wrapper anyway, this can become much more difficult if you're dealing with dynamic content or further component abstraction.

Suffice it to say: it isn't as simple as writing a test that says something like: cy.get(selectors.content).should('contain', JSX).

Whilst there's no right or wrong way of approaching this (and believe me I've tried a few in my time), the simplest method I've come up with to test JSX data against the rendered result in Cypress is to:

  1. Extract text from the JSX object;
  2. Loop over that text and check if each word within it appears in the rendered result.

Extracting Text from JSX

For this, I wrote a utility function which we can now use across our test suite. Here it is:

const extractTextFromJSX = (node: ReactNode): string => {  if (typeof node === 'string') {    return node;  } else if (Array.isArray(node)) {    return node.map(extractTextFromJSX).join(' ');  } else if (isValidElement(node) && node.props.children) {    return extractTextFromJSX(node.props.children);  }  return '';};

Here, we're recursively extracting and concatenating text content from a JSX object, and returning the text as a string. This is done by looping through the object:

  1. If the node is a string then we can simply return it.
  2. If the node is an array (which indicates that the component has multiple children), then it recursively called itself on each element within the array, joining the results together with spaces.
  3. If the node is a valid React element and has children, then we recursively apply the function to node.props.children. This way we can handle the descent into nested component structures and still extract text from those deeper levels of the tree.
  4. Finally, we have a fallback: if our function gets to this point then we simply return an empty string to ensure that we always return something, even if no text content is found.

So, you can simply pass your JSX into this function and expect a string of spaceseparated text to come out the other end. Bear in mind that you'll also need to import isValidElement and ReactNode from React in order to use this.

Testing JSX Against Rendered Content

Once you have the utility to convert your JSX into a string, comparing that against the rendered content becomes much more straightforward. You still can't do an equity check, but what you can do is loop over each word in the JSX output and check to see if the rendered version contains it.

Here's one I prepared earlier:

const expectedTextContent = extractTextFromJSX(description as JSX.Element);expectedTextContent.split(' ').forEach((word) => {  cy.get(selectors.content).should('contain', word);});

What this does is:

  1. Creates expectedTextContent, which is the extracted text from the JSX (in this instance: description);
  2. Uses split() to separate this into an array of words;
  3. Uses a forEach to loop over each word, checking if selectors.content contains it.

And that's about it! In this way we can take a JSX object into our Cypress tests, convert it to a string of text content, and then check that the rendered content contains the JSX content.


Caveats

As you might have already noticed, there are of course a couple of caveats with this approach (although I would argue this is still the most straightforward approach)...

Ordering

The way this test works, it will check each individual word that it has extracted from the JSX to see if it appears anywhere within the expected rendered element. This means that ordering is not at all catered for. the content 'lorem ipsum dolor sit amet' would still return as a pass if the rendered 'ipsum dolor lorem amet sit'.

There's no easy way to counter this one. If ordering is important (or likely to differ), then you could consider breaking the JSX down into smaller chunks (i.e., by paragraph) and check each of those individually. However, it would generally be fair to assume that the ordering of your JSX and that of the rendered output would match.

What this does do is test that every single word exists in both, so the coverage itself is very thorough.

Performance

On the topic of thoroughness, this is not a quick process and if you're dealing with very large pieces of JSX, then this could lead to slow tests. In our case, we found that each individual test took between around 31ms and around 110ms. Not grievously long periods of time by any means, but if you had a lot of tests to run then this can easily add up.

Screenshot of Terminal displaying part of the test results with time taken when checking JSX content against rendered content.

Equally, due to its recursive nature, the amount of time that the extractTextFromJSX function takes to actually extract the text will increase exponentially as the size and complexity of the JSX object increases.


Wrapping up

In our case (and in the cases I've come across), where it is necessary to add Cypress tests that ensure the content of our JSX matches the rendered output, the most straightforward approach I've come up with is:

  1. Convert the JSX content to a string of words by recursively extracting the text;
  2. Loop over each word in the extracted text and ensure that the word exists in the rendered output.

There are any number of other ways to approach this, but for us, this is how we approached it.


Categories:

  1. Cypress
  2. Development
  3. Front‑End Development
  4. Testing