Guest Post: How UR used Cypress to convert a legacy app

April 2, 2020

By Guest

André Elmoznino Laufer is software developer at Valtech, currently consulting at the national Swedish Educational Broadcasting Company and also volunteering as a coding teacher for kids at CoderDojo.


For the past year I’ve been working at UR (the national Swedish Educational Broadcasting Company), helping rewrite a legacy Ruby on Rails app into React. With tons of untested code, a slow old test runner and no prior Ruby knowledge whatsoever, I figured Cypress could come in handy to make sure all core functionality would still work. In this blog post I’ll share my tips on how to use Cypress as a tool to convert legacy apps into a modern web framework.

Background

UR Play is a free streaming service for educational programs and podcasts, mainly used by schools and individuals curious to learn new things. A year ago, it was decided that the legacy Ruby on Rails (“RoR”) monolith would be rewritten in React. Reasons for it included the general rise of JavaScript frontend frameworks, that there had been a high staff turnover over the years, as well as difficulties in recruiting people with the right skills and willingness to work with RoR. Other issues with the old code base included so-so test coverage, no type checking, and a ton of id:s used for styling, analytics, testing and functionality - we had no idea of what we broke if we changed the DOM structure or a CSS class name.

At the same time we were rebranding and also merging our two streaming sites, one aimed at schools (UR Skola) and one aimed at the general public (UR Play), which required us to add some new features. This meant that it wasn’t a 1-to-1 rewrite of the very same RoR app/components into React, although much of the core functionality would remain.

Strategy

We decided to use react-rails, a tool that lets you use React components within a RoR app (with TypeScript if you want - highly recommended). The React community, which maintains it, also provides a version for .NET projects in case you’re rewriting such an app. In essence, in your rails views or partials (sort of a component if you think in React terms), you can include React components and pass them props like so:

<%= react_component('HelloMessage' , { name: 'André' }, { prerender: true }) %>
The first code snippet shows how to include our HelloMessage React component within a RoR partial, passing it the prop “name” and server-side rendering it (by passing in prerender).
import React from 'react'

interface HelloMessageProps {
  name: string
}

const HelloMessage = ({ name }: HelloMessageProps) => <h1>Hello {name}</h1>
The second code snippet shows how we then use our HelloMessage as any React component (with TypeScript in our case)‌‌.

Before writing any React code, we started writing Cypress end-to-end tests to verify that all the core functionality still worked, for example searching for programs, playing them, making sure our jQuery-magic-powered infinity scroll still worked on the search page, and so on. We did this by attaching unique data-cy attributes on relevant DOM elements, and writing some custom commands. One of them includes the following one that we use to assert that more search results load when we scroll to the bottom of the page, a.k.a. infinity scrolling:

Cypress.Commands.add(
  'assertLoadsOnScrollAwaitRequest',
  (element, itemsBefore, itemsAfter, request) => {
    cy.get(element).should('have.length.gte', itemsBefore)

    cy.scrollTo('bottom')

    cy.wait(request, { requestTimeout: 15000, responseTimeout: 45000 })

    cy.get(element).should('have.length.gte', itemsAfter)
  },
)
Adding this in cypress/support/commands.js lets us use cy.assertLoadsOnScrollAwaitRequest in any test. We pass in data-cy*=”product-card” as “element” to target all search results on the page (notice the *-wildcard since we suffix each card with its unique ID); as second and third arguments we pass the number of cards expected before and after scrolling; and finally we pass the API request to wait for.


Apart from adding data-cy attributes, we also went through our analytics to make sure we used data-trk attributes instead of selecting on id’s or some nested CSS selectors. That way, we could know precisely what was used and where by separating our data-attributes by concerns.

Once we knew that all core functionality was tested, we started working our way through each component. Among the first we rewrote were the filters on our search page. We started with the dropdown for filtering on education levels, and made sure everything worked as before, and that the search results were exactly the same. Once that dropdown was in React, we could reuse the component for the other dropdowns, and voilà - all dropdowns were in React. We then wrapped them all in a container to control its state and all logic. And from there on, we grew our components up-/down- or sideways, making our React components grow until they had one wrapping container for the whole page. This picture gives you an idea of how:

We started converting the education level filter (marked 1), then reusing the same component and wrapping them all in a container (marked 2). Thereafter we converted the tabs for toggling between episodes and series results (marked 3), and from thereon grew to include the header (4) and then the results in our search page container.

Once a component was rewritten in React and we had written our unit tests for it (we chose to go with Jest and Enzyme), we attached the very same data-cy attributes that we had on our RoR DOM-elements onto our React elements, and made sure the Cypress tests were still passing. That way we felt confident that all core functionality still worked. Having such a great tool as Cypress, where you can see the tests in real time but also step through them manually if you need to, made it easy as pie to troubleshoot and write tests. Once all tests were passing, we had a manual check of our Rails partials and their unit tests to make sure we didn’t miss any implicit functionality or logic. When that was done, we made sure to attach any aforementioned data-trk attributes (used for tracking/analytics) to the equivalent React components. And then - poof - we could safely delete our old RoR partials and their tests. To make sure that we don’t introduce any breaking changes, we run our Cypress test as part of our Continuous Integration (“CI”) build chain on each pull request and after merging to master.

Our general E2E testing strategy has been to have a “happy path” for each page, where we don’t stub any network requests (but occasionally alias requests so we can wait for them), since that’s a true end-to-end test. A happy path in our case could be that a user should be able to land on our start page, search for a program by name, click on it, and play the show.

We supplement those tests with “sort-of-unit-tests” for the things that are hard to test in isolation, that require some clicking or user input, and also some “sad paths” where we assert that the user gets the right feedback when things go wrong.

Another huge win for us was cypress-axe, a plugin that makes your tests fail if there are any severe accessibility (“a11y”) violations on your page at any given moment. Since we’re a public service company, it is of great importance to us that our site is accessible for everyone. For detailed setup, you can read their great documentation, but in short, you just inject it on the page right after your cy.visit() and then run cy.checkA11y() any time you want to check for violations. You might also want to trigger it several times in a test, for example after toggling some menu open or other kinds of state changes, like showing a modal etc. Automating your a11y checks makes you detect issues you may not be aware of, and at the same time helps you learn about best practices that you might otherwise not know about. So, shout-out to cypress-axe for making the web accessible for everyone!

Cypress as a tool to TDD our backend

Some of our business logic still remains in our Rails app, making it act as a backend-for-frontend for our React app. For example, when a user searches on our site, the React component makes a GET request to our Rails code, which in turn queries our API. We realized that Cypress is a great tool to test the contract between the frontend and backend, letting us assert that the response-format remains the same, and that given certain flags we can get a limited or extended response depending on our needs. And these tests are super fast! For me who had never worked with RoR before, this let me TDD our Rails API routes and feel confident that they work as intended.

it('should get correct data fields for a search query', () => {
  cy.request('/api/bff/v1/search?query=pino').then(({ body }) => {
    expect(body).to.have.property('filters')
    expect(body).to.have.property('nextPageInfo')
    expect(body).to.have.property('results')
    expect(body).to.have.property('count')
    expect(body).to.have.property('productType')

    const { count, filters, nextPageInfo, results } = body

    expect(filters).to.have.all.keys(
      'languages',
      'educationLevels',
      'formats',
      'hasWorkingMaterial',
    )

    expect(count).to.have.all.keys('programs', 'series')

    expect(nextPageInfo).to.have.all.keys('start', 'rows')

    expect(results).to.have.length.gt(1)
  })
})
Querying our API to make sure the JSON response has all the required data and that we actually get results.

Conclusion

With about 80-90% of our app rewritten in React today, the team and I feel that we’ve taken the right approach when converting component by component. By not introducing new features behind a beta-site (while maintaining the old site and fixing any new-found bugs), we’ve been able to continuously improve the site and release multiple times a day. The downside of our approach is when you wrap a few components into a larger container, you must adjust props, types and tests accordingly, which isn’t hard but just takes a little time.

A piece of advice is to have reuse in mind early on, since it makes scaling much easier. Whereas it took us weeks to convert a page into React in the beginning, nowadays we can just scaffold and reuse existing components and within days or even hours have a full page in React.

We’ve also mob-programmed most of the larger features, making sure everyone has some knowledge of how everything works, limiting the reliance on a single person. Pro tip: use our free and open-source mob programming tool UR Mobster to keep track of whose turn it is when mobbing.

Thanks to cypress-axe, our startpage now scores 100% on Google Lighthouse’s accessibility tests, a score we hope to maintain by automatically failing our CI builds if we start introducing violations.

Even though Cypress increases our build times, it saves us a ton of time from doing all that work manually. Lastly, Cypress has been a huge help for us in making sure that features, user flows and API contracts work as expected. For me especially, who had never even seen any Ruby on Rails code before this assignment, Cypress makes me feel confident that things don’t break, but rather work as intended.