logosmonn
Cover photo by Aaron Burden

Example of how to test a server-side rendered React website

Published by Simon Ingeson

This blog post is a follow-up to How to test a server-side rendered React website. This time, we’ll build on top of the minimal SSR setup that we already have created. To have some interesting code to test, we’ll add a registration form, a backend endpoint to send data to, and a confirmation page. If you don’t already have it set up, clone the source code and check out the tag part-3.

Install dependencies

All we need for now is cypress:

npm install --save-dev cypress

Add cypress.json

Mostly setting this to avoid adding files we won’t use yet. Feel free to adjust according to your needs.

{
  "baseUrl": "http://localhost:3000",
  "fixturesFolder": false,
  "pluginsFile": false,
  "supportFile": false,
  "video": false,
  "viewportHeight": 600,
  "viewportWidth": 800
}

Add scripts to run cypress

  "scripts": {
+   "cy:open": "cypress open",
+   "cy:run": "cypress run",
    "build": "babel src --out-dir dist",
    "dev": "nodemon --require @babel/register src/index.js",
    "start": "NODE_ENV=production node dist/index.js"
  },

Add initial cypress tests

These are just simple initial tests for our navigation links and registration page.

// ./cypress/integration/navigation.spec.js

describe('on navigation success', () => {
  // Although these tests appear to be similar, we want to avoid getting clever here as the tests will most likely change over time, and each page will evolve separately.

  it('navigates to /', () => {
    cy.visit('/about')
    cy.contains('h1', 'Home').should('not.exist')
    cy.contains('a', 'Home').should('exist').click()
    cy.url().should('eq', Cypress.config().baseUrl + '/')
    cy.contains('h1', 'Home').should('exist')
  })

  it('navigates to /about', () => {
    cy.visit('/')
    cy.contains('h1', 'About').should('not.exist')
    cy.contains('a', 'About').should('exist').click()
    cy.url().should('eq', Cypress.config().baseUrl + '/about')
    cy.contains('h1', 'About').should('exist')
  })

  it('navigates to /register', () => {
    cy.visit('/')
    cy.contains('h1', 'Register').should('not.exist')
    cy.contains('a', 'Register').should('exist').click()
    cy.url().should('eq', Cypress.config().baseUrl + '/register')
    cy.contains('h1', 'Register').should('exist')
  })
})

describe('on navigation failure', () => {
  it('shows a 404 page', () => {
    // Must disable `failOnStatusCode` since this page will yield a 404 status code.
    cy.visit('/this-page-does-not-exist', { failOnStatusCode: false })
    cy.contains('h1', 'Page Not Found').should('exist')
    cy.contains('a', 'Return to home').should('exist').click()
    cy.url().should('eq', Cypress.config().baseUrl + '/')
  })
})
// ./cypress/integration/registration.spec.js

describe('on registration success', () => {
  it('registers a new user', () => {
    cy.visit('/register')
    cy.get(`[data-cy="registration-email-address"]`).type('user@host')
    cy.get(`[data-cy="registration-password"]`).type('password')
    cy.get(`[data-cy="registration-submit"]`).click()
    cy.url().should('eq', Cypress.config().baseUrl + '/confirmation')
  })
})

describe('on registration failure', () => {
  // We may want to show custom error messages in the future. For now, the errors shown are only the built-in browser validation messages.

  it('user enters no data', () => {
    cy.visit('/register')
    cy.get(`[data-cy="registration-submit"]`).click()
    cy.url().should('eq', Cypress.config().baseUrl + '/register')
  })

  it('user enters invalid email address', () => {
    cy.visit('/register')
    cy.get(`[data-cy="registration-email-address"]`).type('@')
    cy.get(`[data-cy="registration-password"]`).type('password')
    cy.get(`[data-cy="registration-submit"]`).click()
    cy.url().should('eq', Cypress.config().baseUrl + '/register')
  })

  it('user enters invalid password', () => {
    cy.visit('/register')
    cy.get(`[data-cy="registration-email-address"]`).type('user@host')
    cy.get(`[data-cy="registration-password"]`).type('        ')
    cy.get(`[data-cy="registration-submit"]`).click()
    cy.url().should('eq', Cypress.config().baseUrl + '/register')
  })
})

Feel free to start cypress with the initial tests setup by running npm run cy:open and trigger an initial run. Most tests will fail, but as we keep adding the files below and making changes, the tests should start passing.

If you don’t have any integration tests, cypress will create a whole set of examples to explore on your first run.

Add server-side validation and handlers

We’ll also extract the route handlers into separate files to reduce the bloat in src/index.js.

// ./src/util/validation.js

// Simple email regular expression, not for production use.
export const emailAddressPattern = /^[^@\s]+@[^@\s]+$/

// Require 8 characters, must not start or end with whitespace.
export const passwordPattern = /^\S.{6,}\S$/

export function validateEmailAddress(emailAddress = '') {
  return emailAddressPattern.test(emailAddress)
}

export function validatePassword(password = '') {
  return passwordPattern.test(password)
}

export function validateRegistration({ email_address, password } = {}) {
  return validateEmailAddress(email_address) && validatePassword(password)
}
// ./src/handlers/handle-registration.js

import { validateRegistration } from '../lib/validation'
import handleRender from './handle-render'

/**
 * Handler for registration route.
 * @param {express.Request} req Express Request object
 * @param {express.Response} res Express Response object
 */
export default function handleRegistration(req, res) {
  const isValid = validateRegistration(req.body)
  // In a real-world application, this would also create the new user.

  const json = () => res.status(isValid ? 200 : 400).json({ isValid })

  const html = () => {
    if (isValid) {
      res.redirect(302, '/confirmation')
    } else {
      handleRender(req, res)
    }
  }

  // https://expressjs.com/en/4x/api.html#res.format
  res.format({
    json,
    html
  })
}
// ./src/handlers/handle-render.js

import renderApp from '../lib/render-app'

/**
 * Renders the app to HTML and sets the response accordingly.
 * @param {express.Request} req Express Request object
 * @param {express.Response} res Express Response object
 */
export default function handleRender(req, res) {
  // This context lets us track redirects and 404:s triggered by React Router.
  const context = {}
  const html = renderApp(req.url, context)

  if (context.url) {
    // This means a redirect was triggered.
    res.redirect(context.statusCode || 301, context.url)
  } else {
    if (context.statusCode) {
      // This usually means we have a 404.
      res.status(context.statusCode)
    }

    res.send(html)
  }
}
  // ./src/index.js

  import express from "express";
- import renderApp from "./lib/render-app";
+ import handleRegistration from "./handlers/handle-registration";
+ import handleRender from "./handlers/handle-render";

  const app = express();

+ // Handle POSTs both for HTML and JSON requests.
+ app.post(
+   "/register",
+   express.urlencoded({ extended: false }),
+   express.json(),
+   handleRegistration
+ );
+
  // This wildcard route will ensure we catch all GET requests.
- app.get("/*", (req, res) => {
-   // This context lets us track redirects and 404:s triggered by React Router.
-   const context = {};
-   const html = renderApp(req.url, context);
-
-   if (context.url) {
-     // This means a redirect was triggered.
-     res.redirect(context.statusCode || 301, context.url);
-   } else {
-     if (context.statusCode) {
-       // This usually means we have a 404.
-       res.status(context.statusCode);
-     }
-
-     res.send(html);
-   }
- });
+ app.get("/*", handleRender);

  app.listen(process.env.PORT || 3000);

Add registration form and pages

// ./src/components/registration-form.js

import { passwordPattern } from '../lib/validation'

export default function RegistrationForm({ action }) {
  return (
    <form action={action} method="POST">
      <div>
        <label htmlFor="email_address">Email address</label>
        <input
          data-cy="registration-email-address"
          id="email_address"
          name="email_address"
          type="email"
          required
        />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          data-cy="registration-password"
          id="password"
          name="password"
          type="password"
          required
          minLength={8}
          pattern={passwordPattern.source}
          aria-describedby="password-help"
        />
        <small id="password-help">
          Minimum 8 characters and must not start or end with whitespace.
        </small>
      </div>
      <div>
        <button data-cy="registration-submit" type="submit">
          Register
        </button>
      </div>
    </form>
  )
}
// ./src/pages/register.js

import Layout from '../components/Layout'
import RegistrationForm from '../components/registration-form'

export default function Register() {
  return (
    <Layout>
      <h1>Register</h1>
      <RegistrationForm action="/register" />
    </Layout>
  )
}
// ./src/pages/confirmation.js

import { Link } from 'react-router-dom'

export default function Confirmation() {
  return (
    <main>
      <h1>Confirmation</h1>
      <p>You successfully signed up!</p>
      <p>
        <Link to="/">Return to home</Link>
      </p>
    </main>
  )
}

Refactor route configuration

By extracting the configuration from src/components/app.js and src/components/layout.js we can avoid some duplication, and as we add more pages, we’ll only need to change one file.

// ./src/lib/route-config.js

import Index from '../pages'
import NotFound from '../pages/404'
import About from '../pages/About'
import Confirmation from '../pages/confirmation'
import Register from '../pages/register'

// Routes without a label won't show in the navigation.
const routeConfig = [
  {
    path: '/',
    label: 'Home',
    component: Index,
    exact: true
  },
  {
    path: '/about',
    label: 'About',
    component: About
  },
  {
    path: '/register',
    label: 'Register',
    component: Register
  },
  {
    path: '/confirmation',
    component: Confirmation
  },
  {
    path: '*',
    component: NotFound
  }
]

export default routeConfig
  // ./src/components/app.js

  import { Route, Switch } from "react-router-dom";
- import Index from "../pages";
- import NotFound from "../pages/404";
- import About from "../pages/About";
+ import routeConfig from "../lib/route-config";

  export default function App() {
    return (
      <Switch>
-       <Route path="/" exact>
-         <Index />
-       </Route>
-       <Route path="/about">
-         <About />
-       </Route>
-       <Route path="*" component={NotFound} />
+       {routeConfig.map((route) => (
+         <Route
+           key={route.path}
+           path={route.path}
+           component={route.component}
+           exact={route.exact}
+         />
+       ))}
      </Switch>
    );
  }
  // ./src/components/layout.js
+ import { Fragment } from "react";
  import { NavLink } from "react-router-dom";
+ import routeConfig from "../lib/route-config";

  export default function Layout({ children }) {
    return (
      <>
        <nav>
-         <NavLink to="/" exact>
-           Home
-         </NavLink>
-         {" | "}
-         <NavLink to="/about">About</NavLink>
+         {routeConfig
+           .filter((route) => !!route.label)
+           .map((route, index) => (
+             <Fragment key={route.path}>
+               {index > 0 ? <span aria-hidden> | </span> : null}
+               <NavLink to={route.path} exact={route.exact}>
+                 {route.label}
+               </NavLink>
+             </Fragment>
+           ))}
        </nav>
        <main>{children}</main>
      </>
    );
  }

As usual, you can find the source code over in the GitHub repository (tag integration-tests).

Cover photo by Aaron Burden.