End-to-end Testing for Server-Side Rendered Pages

How to validate SSR page using Cypress.io test runner

Note: the source code for this blog post is in bahmutov/react-server-example repository which is a fork of the excellent mhart/react-server-example.

SSR application

If you install dependencies and run this web application, it starts listening on port 3000. For each received request the server returns a rendered markup for a simple list generated using a React component. It also returns props that allow the application to hydrate client-side and continue from there.

Here is the returned HTML (I am using my favorite httpie instead of curl to fetch the page). Notice both the list items and the window.APP_PROPS in the returned page:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ http localhost:3000
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 676
Content-Type: text/html; charset=utf-8
Date: Tue, 14 May 2019 01:32:41 GMT

<body><div id="content"><div data-reactroot=""><button disabled="">
Add Item</button><ul><li>Item 0</li><li>Item 1</li><li>Item 2</li>
<li>Item 3</li></ul></div></div><script>var APP_PROPS = {"items":["Item 0",
"Item 1","Item 2","Item 3"]};</script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/create-react-class.min.js"></script>
<script src="/bundle.js"></script></body>

How do we test the server-side rendered page using an end-to-end test runner like Cypress.io? The application hydrates, thus if we simply load the page using cy.visit('http://localhost:3000') we might be testing the client-side SPA, not the server-rendered one! Here is one possible solution.

Instead of cy.visit we can request the page using cy.request just like a regular HTTP resource - forcing the server to render it. The following test shows how to request the page and pick its body property:

1
2
3
it('renders 5 items on the server', () => {
cy.request('/').its('body')
})

The DevTools console shows the returned HTML page

Page HTML is returned by the server

Check HTML

If we have static HTML we can find the rendered list items. Without bringing any extra libraries like cheerio we can use jQuery already bundled with Cypress:

1
2
3
4
5
6
7
8
9
10
it('renders 5 items on the server', () => {
cy.request('/')
.its('body')
.then(html => {
const $li = Cypress.$(html).find('li')
expect($li)
.to.have.property('length')
.equal(4)
})
})

Confirm there are 4 items

Nice, server is really rendering the expected items - but we don't see them! Hmm, we can throw the HTML into the application's iframe (the one that is empty right now)

1
2
3
4
5
6
7
8
9
10
11
it('renders 5 items on the server', () => {
cy.request('/')
.its('body')
.then(html => {
const $li = Cypress.$(html).find('li')
expect($li)
.to.have.property('length')
.equal(4)
cy.state('document').write(html)
})
})

The only problem with this approach - the JavaScript starts running immediately, which we can see by adding a few console log statements to the component life cycle methods.

Component is running

Removing application bundle

After we receive the server-side rendered page, but before we stick it into the browser, we can simply remove the application bundle (or even all script tags). Then we can use "normal" Cypress query methods to confirm the expected number of elements - and see them ourselves.

1
2
3
4
5
6
7
8
9
10
11
12
it('skips client-side bundle', () => {
cy.request('/')
.its('body')
.then(html => {
// remove the application code bundle
html = html.replace('<script src="/bundle.js"></script>', '')
cy.state('document').write(html)
})
// now we can use "normal" Cypress api to confirm
// number of list element
cy.get('li').should('have.length', 4)
})

The page shows the expected elements (highlighted) and the console does not show any messages from the component itself. The button stays disabled, which is another sign that our component has never been activated.

Skipped component bundle

Disable component method

Instead of removing the application bundle completely, we can just disable some React component lifecycle methods, for example componentDidMount. Here is how we can do it - by being ready when window.createReactClass is called.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
it('disables component methods from createReactClass', () => {
let createReactClass
cy.window().then(win => {
Object.defineProperty(win, 'createReactClass', {
get () {
return definition => {
definition.componentDidMount = () => null
return createReactClass(definition)
}
},
set (fn) {
createReactClass = fn
}
})
})
cy.request('/')
.its('body')
.then(html => {
cy.state('document').write(html)
})
cy.get('li').should('have.length', 4)
// since we disabled componentDidMount the button should
// never become enabled
cy.get('button').should('be.disabled')
})

No more componentDidMount

Confirming createReactClass call

In the above test we have confirmed that the componentDidMount was called - but only indirectly, by observing the button that has remained disabled. Let's actually confirm that our dummy no-op function was called once by the React starting up. We can create a cy.stub that will be called by the component.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
it('how to know if componentDidMount was called', () => {
cy.window().then(win => {
let createReactClass
Object.defineProperty(win, 'createReactClass', {
get () {
return definition => {
definition.componentDidMount = cy.stub().as('componentDidMount')
return createReactClass(definition)
}
},
set (fn) {
createReactClass = fn
}
})
})
cy.request('/')
.its('body')
.then(html => {
cy.state('document').write(html)
})
// and then ...
})

Hmm, we have a tiny bit of problem with the rest of the test. How do we get to the @componentDidMount alias? We cannot simply assert that it has been called once - because the alias has not been created yet when we try to cy.get it.

1
2
3
4
5
6
7
8
cy.request('/')
.its('body')
.then(html => {
cy.state('document').write(html)
})
// hmm, this throws, because alias "componentDidMount"
// has NOT been registered yet
cy.get('@componentDidMount').should('have.been.calledOnce')

Stub was called, but the test claims it was unavailable

Notice that in the test above cy.get('@componentDidMount') has failed to find the alias, yet it was later called by the app. That is why the "Spies / Stubs" table shows 1 call. Hmm, how do we wait until an alias has been created before calling cy.get on it? We could just add a 1 second wait - that should be enough, right?

1
2
3
4
5
6
7
cy.request('/')
.its('body')
.then(html => {
cy.state('document').write(html)
})
cy.wait(1000)
cy.get('@componentDidMount').should('have.been.calledOnce')

Wait 1 second - the alias should be there after the delay

Of course, this is NOT the way Cypress works - you should not hardcode waits, instead you should just declare a condition to wait for. The test runner then will only wait until the moment the condition becomes satisfied, and not a millisecond longer. To achieve this we can take advantage of cy.should(fn) that automatically retries the callback function until it passes without throwing an error (or times out).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
it.only('how to know if componentDidMount was called', () => {
let componentDidMountSet
cy.window().then(win => {
let createReactClass
Object.defineProperty(win, 'createReactClass', {
get () {
return definition => {
definition.componentDidMount = cy.stub().as('componentDidMount')
componentDidMountSet = true
return createReactClass(definition)
}
},
set (fn) {
createReactClass = fn
}
})
})
cy.request('/')
.its('body')
.then(html => {
cy.state('document').write(html)
})

// wait until custom assertion passes
cy.wrap(null).should(() => expect(componentDidMountSet).to.be.true)
// now the alias should exist
cy.get('@componentDidMount').should('have.been.calledOnce')
})

This line is the key

1
cy.wrap(null).should(() => expect(componentDidMountSet).to.be.true)

It retries until the expect(...).to.be.true passes successfully.

Auto-retry until variable is set

Notice that auto-retrying is much faster (130ms) than hard-coding 1 second wait, yet works reliably.

One other way to write a command to wait until a specific condition becomes true (without throwing) is to use cypress-wait-until plugin. Using this plugin we can write the same "wait until variable gets its value" like this

1
2
3
4
let componentDidMountSet
...
cy.waitUntil(() => cy.wrap(componentDidMountSet))
cy.get('@componentDidMount').should('have.been.calledOnce')

Hydrated page

Once the web application starts client-side, the markup should not jump or move - the newly rendered DOM should match the static HTML exactly, except the button becomes enabled in our example. Let's confirm it with the following test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
it('renders same application after hydration', () => {
// technical detail - removes any stubs from previous tests
// since our application iframe does not get reset
// (there is no "cy.visit" call to reset it)
const win = cy.state('window')
delete win.createReactClass

let pageHtml
cy.request('/')
.its('body')
.then(html => {
pageHtml = html
// remove bundle script to only have static HTML
cy.state('document').write(
html.replace('<script src="/bundle.js"></script>', '')
)
})

cy.get('li').should('have.length', 4)
cy.get('button').should('be.disabled')

let staticHTML
cy.get('#content')
.invoke('html')
// static HTML before hydration has the "disabled" button attribute
// we should remove it before comparing to hydrated HTML
.then(html => (staticHTML = html.replace(' disabled=""', '')))

// now mount the full page and let it hydrate
.then(resetDocument)
.then(() => {
cy.state('document').write(pageHtml)
})

// now the page should be live client-side
cy.get('button').should('be.enabled')

cy.get('#content')
.invoke('html')
.then(html => {
expect(html).to.equal(staticHTML)
})
})

The test runs and confirms that the hydrated page matches the static HTML exactly.

Static vs hydrated HTML test

Conclusions

Using cy.request we can request the server-side rendered page and mount it into the Test Runner's application iframe for further testing. We can disable client-side functionality to make sure we only see the static HTML before hydration. We can also spy on the client-side application to confirm that it starts correctly, and I have shown how to wait for a variable to get its value before the test continues. Finally, I have shown how to confirm that the static HTML sent by the server is hydrated correctly by the client side application from the APP_PROPS data.