Running Cypress tests in a monorepo

cypress Jul 2, 2020

Cypress is a fantastic testing tool for running your applications in a browser like environment. It's reminiscent of Selenium, but runs quicker, and has a much nicer developer experience. It can be used for full on acceptance or integration testing, or even at a feature test level with mocking in place. If used correctly, it can even replace the component testing you might would see with a Jest + Enzyme setup as, rather than having to do a lot of mocking setup, mounting, etc in your unit tests, you can simply just run tests against the features you have and directly interact with the components themselves in a runtime environment.

There are many great examples of how to use Cypress out in the wild already including this https://github.com/cypress-io/cypress-realworld-app example, but there's not many that cover the monorepo use case.

Monorepo

Go check this post on https://www.jonathancreamer.com/how-to-successfully-manage-a-monorepo-aka-megarepo/, but the tldr; there is...

A monomegarepo is a collection of many units of code or "packages" all in the same repository, managed by tooling which enables sharing those packages easily.

Cypress in a monorepo

Cypress is built around a few conventions, for one, when you first run Cypress it'll create a nice neat structure for you in the root of whatever project you're working in.

That however doesn't necessarily jive with the structure of a megarepo which has lots of packages all over the place. Let's take the following as our megarepo structure. We're going to use yarn workspaces in this example since it's a fairly easy to understand setup. Note that the same principles should apply to a lerna, bolt, or insert your monorepo of choice.

./cypress
./cypress/fixtures
./cypress/integration
./cypress/support
./cypress/plugins
./packages/
./packages/login
./packages/account
./packages/ui-shared
./packages/web
./cypress.json
./package.json

So, in this case our Cypress tests are setup in the ./cypress folder along with the plugins, fixtures, and support folders.

When cypress run is executed, it will by default look into the ./cypress/integration folder for your tests. This in the case of a monorepo is a little less than ideal as it'd be much better if the tests for each package could live in the actual ./packages themselves.

Let's go ahead then and create some cypress folders in each of our packages too.

./packages/login/cypress/integration, etc etc. Then our tests can live next to the code that they're actually testing.

Let's create a packages/tooling package as well and add some scripts in here. First, let's create ./packages/tooling/src/cypress-runner.js. Let's assume there's also a package called @demo/web which is an express.js app that has routes for loading the other various packages like @demo/login, @demo/account, etc.

import cypress from 'cypress';
import { app } from '@demo/web';
import glob from 'glob';

const tests = glob.sync('src/cypress/integration/**/*.{js,ts}', {
	cwd: app.directory,
	absolute: true,
});

export const main = async () => {
	await app.listen(3000);
	const result = await cypress.run({
        spec: tests,
        reporter: 'junit'
    });
    
	return result;
};

main().then((result) => {
	if (result.failures) {
    	console.log('Tests failed');
        process.exit(1);
    }
    
    process.exit(0);
});

Running this script by either first compiling it with babel or using babel-node, you will effectively start your web app, ideally with a promise that resolves when the listener is listening. Then programmatically run cypress with a glob of the test files from each package.

We can expand on this in a number of ways. As well.

Parallelizing the tests in CI

Ideally your CI will parallelize the tests runners because even as fast as Cypress is, it can still be expensive when running it against many tests.

One way of accomplishing this is to utilize a package called, chunkd.

export const main = async () => {
    const index = process.env.CI_NODE_INDEX;
    const total = process.env.CI_NODE_TOTAL;

	await app.listen(3000);
	const result = await cypress.run({
        spec: chunkd(tests, index, total).join(','),
        reporter: 'junit'
    });
    
	return result;
};

What chunkd allows you to do is, pass in an array of something, give it a number of machines available with an environment variable or something like process.env.CI_NODE_TOTAL, and the index of the machine you're running on, with process.env.CI_NODE_INDEX.

Let's add an alias for running the script we created.

{
	"scripts": {
    	"cypress:ci", "babel-node ./packages/tooling/src/cypress-runner.js"
    }
}

So, if you were using Jenkins for your CI as an example...

stage('Cypress Tests') {
	failFast true
	parallel {
	stage('Test group 1') {
    	steps {
            sh 'CI_NODE_TOTAL=2 CI_NODE_INDEX=0 yarn cypress:ci`
        }
    }
	stage('Test group 2') {
    	steps {
            sh 'CI_NODE_TOTAL=2 CI_NODE_INDEX=1 yarn cypress:ci`     
        }
    }
    // ...
}

In this case we're leveraging a declarative Jenkins syntax for running tests in parallel.

In travis ci, you could accomplish something similar with a "build matrix" configuration...

script: "yarn cypress:ci`"
env:
  global:
    - CI_NODE_TOTAL=2
  jobs:
    - CI_NODE_INDEX=0
    - CI_NODE_INDEX=1

Only run tests against changed packages

Another nice optimization especially as a monorepo gets larger is to only run tests against packages that have actually changed.

With a tool like lerna, you could leverage the [lerna changed](https://github.com/lerna/lerna/tree/master/commands/changed) command to get a list of the changed packges, and then just iterate through each package, and glob for those same test files.

// https://github.com/lerna/lerna/issues/2013
const changed = somehowGetChangedPackages();

const tests = [];
for (const [location] of changed) {
  tests.push(...glob.sync(`${location}/src/cypress/integration/**`))
}

You can also utilize something like [changesets](https://github.com/atlassian/changesets) from atlassian as well, which keeps track of all the changes to each package in your monorepo as well.

import getReleasePlan from '@changesets/get-release-plan';
import getWorkspaces from '@changesets/get-release-plan';

const workspaces = await getWorkspaces();
const releasePlan = await getReleasePlan({ cwd });
const changed = [];

for (const release of releasePlan.releases) {
    const workspace = workspaces.find((w) => w.name === release.name);

    if (workspace) {
        changed.push(workspace.directory);
    }
}

const tests = [];
for (const directory of changed) {
  tests.push(...glob.sync(`${directory}/src/cypress/integration/**`))
}

Now you're not only parallelizing the cypress tests you're running, but also only parallelizing the tests against packages which have changes!

Conclusion

Cypress is a very powerful tool that can be used in many ways. Using it in a monorepo gives you a chance to see how a change in one part of the code base can affect other downstream dependent packages. It also gives you a chance to try a different way to test your code. Rather than mounting your React apps in Jest where you can't actually see them running, Cypress affords you the opportunity to test a whole bunch of cases at once, all the while being able to actually physically see how your components are rendering. It even provides screenshots and videos of your tests! Go give Cypress a try.

Tags