- SSR application
- Check HTML
- Removing application bundle
- Disable component method
- Confirming
createReactClass
call - Hydrated page
- Conclusions
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 | $ http localhost:3000 |
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 | it('renders 5 items on the server', () => { |
The DevTools console shows the returned HTML page
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 | it('renders 5 items on the server', () => { |
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 | it('renders 5 items on the server', () => { |
Tip: instead of cy.state('document')
use cy.document to grab the document
object from the application under test iframe:
1 | // gets the document object and calls "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.
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 | it('skips client-side bundle', () => { |
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.
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 | it('disables component methods from createReactClass', () => { |
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 | it('how to know if componentDidMount was called', () => { |
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 | cy.request('/') |
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 | cy.request('/') |
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 | it.only('how to know if componentDidMount was called', () => { |
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.
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 | let componentDidMountSet |
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 | /** |
The test runs and confirms that the hydrated page matches the static HTML exactly.
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.