How to Test Monorepo Apps Using Cypress GitHub Action

How to run different GitHub workflows depending on the changed files

🧭 You can find the source code for this blog post in the repository bahmutov/cypress-gh-action-changed-files.

The problem

Imagine we have a large monorepo (ughh, don't get me started!) with all its dependencies declared in the root package.json file, and every application in its own subfolder: apps/app-a, apps/app-b, and apps/app-c. Every application has its own end-to-end tests. We run those tests using GitHub Actions CI by using cypress-io/github-action.

The repo has the following structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.github/
workflows/
...
apps/
app-a/
cypress/
integration/
spec.js
cypress.json
index.html
app-b/
cypress/
integration/
...
public/
cypress.json
package.json
app-c/
cypress/
integration/
...
pages/
index.js
...
cypress.json
package.json
package.json
package-lock.json

How do we run only the tests for app-a when its files have changed and skip the tests for app-b and app-c? We do not want to blindly run all tests - that would waste our CI minutes testing the apps that have not changed.

This blog post shows how to run different workflows depending on the changed files.

We can create separate workflow files, each for its own application.

1
2
3
4
5
.github/
workflows/
app-a.yml
app-b.yml
app-c.yml

You can find these workflows in the repo. Let's look at the tricks we play to make it work.

App-a

Our first application is the simplest one. It is a basic static site that can work right from the index.html file. Thus our spec file is simple:

apps/app-a/cypress/integration/spec.js
1
2
3
4
5
6
7
/// <reference types="cypress" />
describe('App A', () => {
it('loads', () => {
cy.visit('index.html')
cy.contains('App A').should('be.visible')
})
})

The workflow file uses the GitHub event syntax to trigger the workflow on commit but only if it has changed files inside the apps/app-a folder or the workflow file itself.

.github/workflows/app-a.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
name: app-a
on:
push:
# only run this workflow when something in the app-a changes
# or in this workflow file
paths:
- 'apps/app-a/**'
- .github/workflows/app-a.yml
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2

- name: Cypress tests 🧪
uses: cypress-io/github-action@v2
with:
project: apps/app-a

We use the Cypress GH action with project: apps/app-a setting to install all repo's dependencies, including Cypress using the package lock file in the root of the repo. Then we run Cypress using --project apps/app-a parameter, testing just the app-a.

Let's test it out. Let's open a pull request from another branch after changing the apps/app-a/index.html file.

apps/app-a/index.html
1
2
3
4
 <body>
<h1>App A</h1>
+ <p>Static file page</p>
</body>

The pull request #2 shows only the first workflow running.

Only the affected application's workflow runs

App-b

Our second application is slightly more complex static site that requires a web server to work. We declared the serve dependency in the root package file, but in the apps/app-b/package.json we simply add the start command for convenience.

apps/app-b/package.json
1
2
3
4
5
6
7
8
9
{
"name": "app-b",
"version": "1.0.0",
"description": "App b",
"private": true,
"scripts": {
"start": "../../node_modules/.bin/serve public"
}
}

Our second workflow should run when the files in the folder apps/app-b change, or when the workflow file itself changes. It should also run when the root package.json changes - because that's where we upgrade the dependencies.

.github/workflows/app-b.yml
1
2
3
4
5
6
7
8
9
10
name: app-b
on:
push:
# run this workflow when files inside app-b change
# or this workflow changes
# or the root package.json changes
paths:
- 'apps/app-b/**'
- package.json
- .github/workflows/app-b.yml

The installation and testing steps are a little bit tricker than before. We want to install all dependencies using the root package.json file, but then start the server in the apps/app-b working directory. Thus we cannot simply use the project: apps/app-b parameter - it would try to execute npm start in the root folder before calling Cypress with --project apps/app-b argument.

Instead we need to split the install and test steps into two. First we will install the dependencies in the root folder, but won't run any tests. Second, we will execute the tests using the working-directory: apps/app-b parameter. Thus our workflow uses the following steps:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2

# install dependencies using the root folder
- name: Install 📦
uses: cypress-io/github-action@v2
with:
runTests: false

# run tests in apps/app-b folder
# where its package.json file is located
# with "npm start" command
- name: Cypress tests 🧪
uses: cypress-io/github-action@v2
with:
install: false
working-directory: apps/app-b
start: npm start

Let's change a file and observe. You can find the result in the pull request #3. Only the single workflow runs and it starts the server before running Cypress tests.

Second application workflow

Tip: Cypress GH Action automatically caches dependencies using the hash of the root package lock file. Thus all these workflows share the same cached dependencies, which allows them to start and run really quickly. The entire CI run takes 35 seconds here, for example.

App-c

The last example uses the Next.js framework to create the full single-page application. It mimics the app-b but might need a little bit of time to start. Thus we use wait-on parameter to the test run.

.github/workflows/app-c.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
name: app-c
on:
push:
# run this workflow when files inside app-c change
# or this workflow file changes
# or the root package.json file changes
paths:
- 'apps/app-c/**'
- package.json
- .github/workflows/app-c.yml
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2

# install dependencies using the root folder
- name: Install 📦
uses: cypress-io/github-action@v2
with:
runTests: false

# run tests in apps/app-c folder
# where its package.json file is located
# with "npm run dev" command
- name: Cypress tests 🧪
uses: cypress-io/github-action@v2
with:
install: false
working-directory: apps/app-c
start: npm run dev
# wait for the Next.js app to respond
# before starting Cypress tests
wait-on: 'http://localhost:3000'

Let's test it, let's change the root package file. Both app-b and app-c workflows should run. You can find the result in pull/4.

Both app-b and app-c workflows ran

The output from the Cypress GH Action shows the Next dev server starting and responding before the tests start.

App-c starts and the tests execute

I hope this example and explanation help you target your CI workflows better.

Bonus: handling inter-app dependencies

If the apps in the monorepo share code, if they depend on each other, then the clean split between the changed files and the tests breaks down. In that case, you want to run all tests on every commit and every pull request. Just remove every "paths" filter from the workflows and they all will run in parallel to quickly finish the tests. You can even parallelize each Cypress job to finish faster.