For web developers, Cypress is a pretty well-understood testing library that everyone has at least come across or heard of. Getting it set up for an app is pretty straightforward, and you can be off and writing tests in a matter of minutes. But what if you have a monorepo with multiple apps? Do you set up a per-app test suite and manage multiple sets of code in multiple places? Or have you already set that up and noticed that there's a lot of redundancy with potentially shared code that you'd like to refactor into one place?
I was recently tasked with setting up such a Cypress testing structure in our Showcase section for our starter.dev project. The idea of the Showcases is that we utilized each of our framework packages to create a GitHub clone as an advanced example of an implementation of each. So they all have the same exact UI to the user, but underneath the hood, they all utilize different sets of technologies for the JavaScript framework, GraphQL/Rest, or CSS libraries.
I instantly figured that there had to be a way to write one set of tests that could be utilized against each app, and all I had to do was unify the data-testid
attributes across all of the apps. But how do you set it up to automate the process, and against so many different apps? Would it even be possible to start, test, and stop each app through a script? Thankfully, the answer is yes -- and this blog will document and explain a structure that I used when solving that problem.
Prerequisites
If you've already been developing in a monorepo and have everything set up for that, you likely already have all requirements necessary to install Cypress. However, if you're not and you're setting everything up from scratch, the Cypress docs list a few system requirements:
- macOS 10.9 and above (64-bit only)
- Linux Ubuntu 12.04 and above, Fedora 21 and Debian 8 (64-bit only)
- Windows 7 and above (64-bit only)
If you're on a Linux distrobution, pay special attention to the dependencies you'll be needing as well. If you're using npm
, you'll need:
- Node.js 12 or 14 and above
It's possible to download Cypress directly, but I don't recommend that approach for the purposes of this guide.
Project Structure
For this example, I will be showing the structure I used in the starter.dev GitHub Showcases repository. But hopefully it demonstrates that it's flexible enough to be used on any monorepo structure with any number of apps. The folder structure will look something like this when we're done (showing just two apps and the relevant folders/files for succinctness):
project/
βββ app1/
β βββ src/
β βββ package.json
βββ app2/
β βββ src/
β βββ package.json
βββ tests-e2e/
βββ package.json
βββ yarn.lock
βββ cypress/
βββ configs/
βββ fixtures/
βββ integration/
βββ plugins/
βββ support/
Installation
In the root of your project, you'll want to make your directory where your Cypress tests will exist (replace tests-e2e
with whatever you'd like your folder to be called):
mkdir tests-e2e
cd tests-e2e
Then, once inside this folder, install Cypress via npm
:
npm install cypress --save-dev
Or via yarn
:
yarn add cypress --dev
Next, we'll install start-server-and-test, which will be needed later on to automate starting our apps and running our test suite against them. Let's also install TypeScript:
npm install start-server-and-test --save-dev
npm install typescript
Or via yarn
:
yarn add start-server-and-test --dev
yarn add typescript
In the newly created package.json
in this folder, let's add a basic script to open Cypress:
{
"scripts": {
"cypress:open": "cypress open"
},
"dependencies": {
"cypress": "^10.0.3",
"typescript": "^4.7.3"
},
"devDependencies": {
"start-server-and-test": "^1.14.0"
}
}
Configuration
Now that we have everything installed, we can start configuring Cypress. Let's open Cypress via the newly created script in our last step, npm run cypress:open
or yarn run cypress:open
. Once Cypress opens, select the E2E Testing
configuration and click continue at the bottom of the list to create all the default files (make sure to read what each one does if this is your first time using Cypress!).
You should see in your folder structure that Cypress created a number of files and folders automatically, but let's create a few additional folders:
mkdir cypress/configs
mkdir cypress/e2e #mkdir cypress/integration if under Cypress 10
mkdir cypress/plugins
Inside cypress/configs
let's create a file called app1.config.js
, or app1.json
if you're using a Cypress version older than 10 (replace app1
with the name of one of the apps you want to test against):
module.exports = defineConfig({
startCommand: 'yarn dev',
e2e: {
setupNodeEvents(on, config) {},
baseUrl: 'http://localhost:3000',
},
})
Just regular JSON format for versions prior to Cypress 10:
{
"baseUrl": "http://localhost:3000",
"startCommand": "yarn dev",
"integrationFolder": "cypress/integration"
}
A couple notes, baseUrl
is the URL your app will deploy on when started up, startCommand
is the commmand your app uses to start (for me, it was different for a few different apps, but if yours all use the same command you may not need this), and integrationFolder
is where all the test .spec
files will be. This can be customized if you've already decided that you'd like your tests to be written separately and/or only one of them will need unique tests, etc. But we can leave it alone for now.
Additionally, Cypress has quite a few configuration options. But, the one I'd like to point out specifically is the env
option. Just like a .env
file, you can utilize this to pass in specific parameters or options into your Cypress tests. Specifically, the different apps I was getting this test suite working against handled auth differently in a few cases, so I needed to visit a specific URL to fire off a redirect/auth chain to mock its state.
It looks like this (will be the same in any version of Cypress):
"env": {
"authUrl": "/"
}
This may not be something you specifically need yourself, but you can pass in whatever you need for your specific apps, and it will only inject into the Cypress state for that app's Cypress config file. So, maybe you could use this to get the name of what app you're testing, etc.
Next, let's add a basic test file inside cypress/e2e
or cypress/integration
if under Cypress 10. Call it whatever you'd like, but I'll name it first-test.cy.ts
(or first-test.spec.ts
if you're using a Cypress version older than 10):
describe('My First Test', () => {
it('Does not do much!', () => {
expect(true).to.equal(true)
})
})
Lastly, let's set up a few run scripts to automate running against all our apps. Update the scripts
section of your package.json
as such:
"scripts": {
"cypress:open": "cypress open",
"cypress:run": "cypress run --config-file $CYPRESS_CONFIG",
"start": "START_COMMAND=`node -p \"require('$CYPRESS_CONFIG').startCommand\"` && cd ../$TARGET_APP && $START_COMMAND",
"test": "CYPRESS_CONFIG=./cypress/configs/$TARGET_APP.config.ts BASE_URL=`node -p \"require('$CYPRESS_CONFIG').baseUrl\"` && CYPRESS_CONFIG=$CYPRESS_CONFIG start-server-and-test start $BASE_URL cypress:run",
"test:watch": "yarn run cypress open --config-file cypress/configs/$TARGET_APP.config.ts"
}
For Cypress versions prior to 10:
"scripts": {
"cypress:open": "cypress open",
"cypress:run": "cypress run --config-file $CYPRESS_CONFIG",
"start": "START_COMMAND=`node -p \"require('$CYPRESS_CONFIG').startCommand\"` && cd ../$TARGET_APP && $START_COMMAND",
"test": "CYPRESS_CONFIG=./cypress/configs/$TARGET_APP.json BASE_URL=`node -p \"require('$CYPRESS_CONFIG').baseUrl\"` && CYPRESS_CONFIG=$CYPRESS_CONFIG start-server-and-test start $BASE_URL cypress:run",
"test:watch": "yarn run cypress open --config-file cypress/configs/$TARGET_APP.json"
}
Tying it all together
If your entire project structure is set up properly and the file names and configurations all match names properly as described at the start of this blog, the scripts should work as is. The usage would looks like this:
TARGET_APP=app1 yarn run test
TARGET_APP=app1 yarn run test:watch
The first command, test
, runs through all of the test specs that your config file you set up points to in the integrationFolder
option in the command line. This option is good to quickly verify passing tests in the background and/or on your CI/CD pipeline. It first fetches the config file from cypress/config/
, grabs the baseUrl
from that config file, utilizes start-server-and-test
to start your target app, and once it's running, it will run your test suite and tear everything down. This is a powerful and flexible option to then chain together running all your Cypress test suites for all your apps back to back from the same place.
The next option, test:watch
is the option you'll want to use when developing tests. All it does is open Cypress against the target app's config file you've set up. Then in another process, you will still manually need to start your app locally. The benefit of this is once you change code in either the app or your Cypress test spec files, both will update automatically while everything is still open.
Conclusion
The solution laid out here isn't one for every single monorepo. However, I believe it can eliminate redundancy for certain types of monorepo structures where each app inside is similar enough, or even more rarely, each app is the same but the target deployments or underlying technology is different. Instead of a per-app Cypress installation and test suite in each app package, this may be exactly the solution to abstract or even refactor them to one single place. There's even flexibility built into this structure to allow a unique set of tests against only one or some of the apps you will need to have integration tests for.
Of course, this is only the first step. But hopefully it eliminates potentially the most problematic one. If you'd like to read further on writing Cypress tests themselves, we also have a great guide on writing tests themselves with Cypress that you can check out.