Replacing Cypress with uvu, linkedom and cssom
I recently replaced Cypress with a few smaller tools, and the test runtime for an open source monorepo I work on went from 35 seconds to 4 seconds. I'll expand a bit here on the thought process and how the migration went.
Reevaluating Cypress
If you pop open the package.json
of any prominent project in the JavaScript ecosystem today, it would be no surprise to see Cypress listed as a dev dependency.
There's a reason for that. You get a solid developer experience and a huge API surface that very likely includes whatever use case you require.
But, you have to accept these drawbacks:
- Large dependency tree with 42 immediate dependencies (to date)
- Frequent npm and GitHub vulnerability notices from these dependencies
- Relatively slow to download
- Relatively slow to run
So for PRPL, a monorepo with a half dozen modules (including a local development server), a couple dozen test cases, and (probably) no users other than me, I asked myself:
Do the tradeoffs Cypress present still make sense given my goal of fuss-free long term maintenance?
Defining current behavior
The first step was to figure out exactly what Cypress was solving for me. This is what I came up with:
- Ability to test DOM representations of output HTML files
- Ability to test file watching and DOM mutation behavior (planned but wasn't tested yet)
Breaking it down like this made it clear that I may not need the (admittedly convenient) kitchen sink that Cypress offers.
Well scoped replacements
I had two requirements for any new tools that could replace Cypress:
- Zero or near-zero dependencies
- Well-scoped, simple functionality a la unix philosophy
First and foremost, I needed a new test runner. Then, in order to assert against output files reliably, I needed to be able to parse HTML and CSS strings into DOM and CSSOM documents. These are the modules I settled on:
- uvu, a test runner by lukeed
- linkedom, a DOM implementation and parser by WebReflection
- cssom, a CSS implementation and parser by NV
The authors are diligent about what dependencies (if any) they adopt, and opinionated about what is and isn't a concern of the module. I'm a fan!
Migration
See this PR for the feature parity replacement.
Let's look at a few tests before and after replacement.
Testing page interpolation
This is what the Cypress test looked like for PRPL page interpolation:
;
;
'PRPL pages',;
There are no imports or setup/teardown hooks whatsoever. The tests use Cypress' utilities to visit a page, grab a DOM node and run an assertion against it.
Now, the equivalent using uvu and linkedom:
;
;
;
;
;
;
'should interpolate templates',;
;
The interesting piece here is the import of the constructDOM
utility, which uses linkedom and looks like this (it's changed since the PR but the concept is the same):
;
;
;
;
We use linkedom to create a DOM from the built HTML and assert against it, pretty neat!
Testing the CSS imports plugin
PRPL has a plugin that resolves CSS imports at build time. The Cypress version of the test for it looked like this:
;
;
;
'CSS imports plugin',;
Again no imports or setup/teardown, but it does make clever use of Cypress' ability to wait for particular network requests and assert against them. It required a bit of a hack to disable browser caching as well.
Here's the version using uvu and cssom:
;
;
;
;
;
'should interpolate CSS imports',;
;
Given the stretch I had to make to test this sort of thing with Cypress, this implementation is much more straightforward by comparison.
Instead of trying to make sure the browser doesn't make extra requests for CSS imports at runtime, we check instead that the output CSS file has all CSS imports resolved into the respective file.
For completeness, here's what the constructCSSOM
utility looks like:
;
;
;
;
Testing the server
The Cypress test suite didn't yet have tests written for PRPL's server module, so there's nothing to compare against. In a later PR I did add these tests using the new tools. I thought I'd include it here because the implementation is somewhat interesting.
Let's look at just the test case modified HTML source files:
;
;
;
;
;
;
;
;
;
;
;
;
;
;
;
'should update if a source HTML file is changed', ;
;
On a high level, what happens in the setup/teardown hooks is:
- Before each test, we make a copy of source files under test and save it in memory
- After each test, we restore the original source files to the file system
And during each test (while the server is running), we:
- Construct the DOM (or CSSOM, or JS file) from the file system
- Make a change to the DOM (or CSSOM, or JS file) in memory
- Write the change to the file system
- Listen for when the server responds to those changes by polling with network requests
- Assert the change
Arguably the most interesting part of this is the polling approach, listenForChange
, which looks like:
;
;
;
In a nutshell, the function makes a request every 100 milliseconds and checks the last-modified
header to see if the server has modified the file. If it did, send the data back so it can be asserted against.
Brief retrospective
So, we've walked through the approach to this replacement project and also taken a tour of some of the more interesting parts of the test migration.
Looking back on it, you could argue that the amount of time spent re-solving problems that Cypress solves out of the box isn't worth the gain of a faster test runtime.
In a corporate environment, I'd say you're probably right. But for PRPL, a small open source project that I maintain, I had fun putting in the hours. Whether a solution like this is right for your project, well, as always, it depends.
Thank you so much for joining me for the ride if you made it this far, hopefully I'll see you in the next note!
Thanks for reading! Go home for more notes.