Testing the Content of JSX Data in Cypress

In Brief
Cypress cannot compare JSX directly because it tests the rendered DOM, not React's JSX objects. Recursively extract the expected text from the controlled JSX data, normalise its whitespace, then compare it once with the normalised text of the rendered element. This preserves content order without coupling the assertion to incidental wrappers, indentation, or line breaks.
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 location‑specific 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 feature‑adder) 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 single‑element 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 end‑to‑end 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:
- Extract text from the JSX object;
- Normalise whitespace in that text and compare it with the normalised rendered text, preserving the full order.
Extracting Text from JSX
For this, I wrote a utility function which we can now use across our test suite. Here it is:
const normaliseText = (value: string): string => value.replace(/\s+/g, ' ').trim();const extractTextFromJSX = (node: ReactNode): string => { if (typeof node === 'string' || typeof node === 'number') { return String(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:
- If the node is a
stringthen we can simply return it. - 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. - 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. - 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 space‑separated 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, compare its normalised text with the normalised text of the rendered element. This preserves ordering and avoids brittle failures caused only by indentation or line breaks in the markup.
Here's one I prepared earlier:
const expectedTextContent = normaliseText( extractTextFromJSX(description as JSX.Element),);cy.get(selectors.content).invoke('text').then((renderedText) => { expect(normaliseText(renderedText)).to.equal(expectedTextContent);});What this does is:
- Creates
expectedTextContent, which is the extracted text from the JSX (in this instance:description); - Uses
normaliseText()to collapse whitespace in the expected value; - Uses
invoke('text')to read the rendered element once, then compares its normalised text with the expected value.
And that's about it! In this way we can take a JSX object into our Cypress tests, extract and normalise its text content, and then compare it with the rendered content in the same order.
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 normalised equality check preserves word order. For example, lorem ipsum dolor sit amet does not equal ipsum dolor lorem amet sit, even though both contain the same individual words.
If the component deliberately inserts unrelated text between sections, compare meaningful ordered chunks such as individual paragraphs instead of weakening the assertion to unordered words.
This is stricter than checking each word independently: missing, duplicated or reordered text causes the test to fail.
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.

Equally, due to its recursive nature, the amount of time that the extractTextFromJSX function takes to extract the text grows with the number of nodes it visits. The old word‑by‑word test added a separate Cypress command and DOM assertion for every word; reading the rendered text once avoids that repeated command overhead.
Wrapping Up
In our case, where it is necessary to add Cypress tests that ensure the text from our JSX matches the rendered output, the most straightforward approach I've found is:
- Convert the JSX content to a string of words by recursively extracting the text;
- Normalise the expected and rendered strings, then compare them once in the same order.
There are any number of other ways to approach this, but for us, this is how we approached it.