Testing Microservices with Cypress, Docker & CircleCI
July 2023 Update: I just released an interactive course on web application security — check it out on educative.io!
In the past, integration/E2E tests have been hard to orchestrate and getting different applications coordinating with each-other in CI has been non-obvious. However, with the meteoric rise of microservices and containerization, it’s no longer an option to skip this layer of testing if we want to have the most confidence in our applications. You can read more about the differences between unit, integration and E2E testing here. Fortunately, as Cypress points out:
“The web has evolved. Finally testing has too.”
Cypress + Docker + CI = ❤️. Cypress and Docker allow you to completely decouple the testing framework from the actual application code. This setup can essentially test any web app regardless of your programming language or application framework of choice.
This tutorial will focus on setting up the infrastructure in order to run E2E tests across multiple repos locally and in CI — not on writing the actual tests.
This article is highly influenced by these two great articles and I recommend you read them as well:
Demo App
The example app code can be found here and the example API code can be found here. I recommend cloning the repos and following along.
The actual application we will be testing is very silly and simple:
When users land on our app, they will see a list of items pulled from an API. They can click a button to remove all those items. Afterwards, they should see something like this:
The frontend is using create react app (let’s pretend this is an app that justifies using it) and our Dockerfile is simple:
FROM node:8# Create app directory
WORKDIR /appCOPY package.json yarn.lock ./RUN yarn# Bundle app source
COPY . .EXPOSE 3000CMD [ "npm", "start" ]
In reality, this is a Dockerfile for local development because it is using webpack-dev-server. For more information on dockerizing node apps check out this great guide. If you’re interested in serving up a create-react-app instance in production, check this reference out.
The node API lives in a separate repo but its Dockerfile is equally simple:
FROM node:8# Create app directory
WORKDIR /api# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
COPY package*.json ./RUN npm install# Bundle app source
COPY . .EXPOSE 8080CMD [ "npm", "start" ]
Now we need to get these apps talking! The docker-compose.yml
file within our app directory looks like this:
version: '3.2'
services:
app:
build:
context: .
ports:
- 3000:3000
env_file:
- .env
volumes:
# share everything BUT node_modules from the host
- .:/app
- /app/node_modules
depends_on:
- api
api:
build:
context: ../cypress-demo-api
environment:
- PORT=8080
ports:
- 8080:8080
volumes:
# share everything BUT node_modules from the host
- ../cypress-demo-api:/api
- /api/node_modules
This is assuming that if we move up one directory from our app code, our API code will live in a directory called cypress-demo-api
.
Let’s abstract our docker config with a Makefile
!
.PHONY: dev cleanDEV_DC = docker-composedev:
$(DEV_DC) up --buildclean:
$(DEV_DC) stop
$(DEV_DC) rm -f
- Now we can run
make dev
and navigate tohttp://localhost:3000
and our app should be running! Sweet! - We can also run
make clean
to stop and remove all our containers which is handy if we want to reset everything.
Running E2E Tests
So where should we put the E2E tests? Where should Cypress live? If you look at the app’s package.json
file, you will notice that Cypress is no where to be found. However, there is a separate top-level directory titled e2e
which contains our Cypress config and tests.
We can create a separate docker-compose.cypress.yml
file to kick off our Cypress instance:
version: '3.2'
services:
app:
environment:
- REACT_APP_API_URL=http://api:8080
cypress:
image: 'cypress/included:3.2.0'
depends_on:
- app
entrypoint: /scripts/wait_for_it.sh app:3000 -- cypress run
environment:
- CYPRESS_baseUrl=http://app:3000
working_dir: /e2e
volumes:
- ./e2e:/e2e
- ./scripts:/scripts
Using multiple Compose files enables you to customize your config for different environments and workflows. You can read more about it here.
- We redefine a service called
app
and set an environment variable calledREACT_APP_API_URL
tohttp://api:8080
. We can use the container name (api
) as a hostname. In ourdocker-compose.yml
file, we had previously used a.env
file to setREACT_APP_API_URL
tohttp://localhost:8080
but now we want our apps to communicate within dockerland. - We define a new service called
cypress
using an official cypress docker image. - Our entrypoint instruction is
cypress run
and we use thewait_for_it
shell script to test and wait on the availability of our app host and port. - Our
CYPRESS_baseUrl
tells Cypress where it can access our app and we can again use the container name (app) as a hostname.
We then need to tweak our Makefile
so that we can use our new docker-compose file:
.PHONY: dev e2e cleanDEV_DC = docker-composedev:
$(DEV_DC) up --buildE2E_DC = docker-compose -f docker-compose.yml -f docker-compose.cypress.yml -p e2ee2e:
$(E2E_DC) build
$(E2E_DC) up --exit-code-from cypressclean:
$(DEV_DC) stop
$(DEV_DC) rm -f
$(E2E_DC) stop
$(E2E_DC) rm -f
- We use
-f
to specify and compose multiple docker-compose files together. - We use
-p
to set an alternate project name. The current directory name is used by default and we want to give some context to our containers. - We use
--exit-code-from
so that all our containers stop when our cypress service exits.
We can now run make e2e
and our cypress tests will run!
Running Interactive E2E Tests
But wait! One of the best features of Cypress is the interactive debugger! Do we lose the ability to use it when running Cypress in dockerland? Fear not. Using Gleb Bahmutov’s awesome ideas as described in “Run Cypress with a single Docker command”, let’s create another docker-compose file titled docker-compose.cypress-interactive.yml
:
version: '3.2'
services:
cypress:
entrypoint: /scripts/wait_for_it.sh app:3000 -- cypress open --project /e2e
environment:
- DISPLAY
volumes:
- /tmp/.X11-unix:/tmp/.X11-unix
- We redefine our
cypress
service but this time usecypress open
instead ofcypress run
to open the Cypress Test Runner in interactive mode. - We use
--project /e2e
to tell cypress where ourcypress.json
file is located. By default, Cypress expects yourcypress.json
file to be found where yourpackage.json
file is. - In order to see Cypress in interactive mode via dockerland, you need to forward the XVFB messages from Cypress out of the Docker container into an X11 server running on the host machine. In order for Cypress to communicate with the X11 server, we pass the socket file:
/tmp/.X11-unix:/tmp/.X11-unix
- We also need to pass an X11
DISPLAY
environment variable. Where do we set this? TheMakefile
!
IP ?= $(shell ipconfig getifaddr en0)
DISPLAY := $(IP):0export.PHONY: dev e2e e2e-interactive cleanDEV_DC = docker-composedev:
$(DEV_DC) up --buildE2E_DC = docker-compose -f docker-compose.yml -f docker-compose.cypress.yml -p e2ee2e:
$(E2E_DC) build
$(E2E_DC) up --exit-code-from cypressE2E_INTERACTIVE_DC = docker-compose -f docker-compose.yml -f docker-compose.cypress.yml -f docker-compose.cypress-interactive.yml -p e2e-interactivee2e-interactive:
open -a XQuartz
xhost + $(IP)
$(E2E_INTERACTIVE_DC) build
$(E2E_INTERACTIVE_DC) up --exit-code-from cypressclean:
$(DEV_DC) stop
$(DEV_DC) rm -f
$(E2E_DC) stop
$(E2E_DC) rm -f
$(E2E_INTERACTIVE_DC) stop
$(E2E_INTERACTIVE_DC) rm -f
- We get the IP of the host machine and set it to an environment variable called
IP
. We use it to create the X11DISPLAY
environment variable which is used by ourcypress
service in interactive mode. We export the environment variables to the subshells used by theMakefile
commands. - We define a new
e2e-interactive
command which opens XQuartz and adds the host IP to the allowed X11 hosts before running our containers with the newdocker-compose.cypress-interactive.yml
file.
Now we just run make e2e-interactive
and bam! We can debug our E2E tests!
Running in CI
Because of our work containerizing Cypress and abstracting how we run our tests, getting them to run in CI is stupid simple. Here is the .circleci/config.yml
file:
version: 2
jobs:
test:
machine:
docker_layer_caching: true
steps:
- checkout
- run:
command: |
git clone git@github.com:qaiken/cypress-demo-api.git ../cypress-demo-api
- run:
command: |
make e2eworkflows:
version: 2
test:
jobs:
- test:
filters:
branches:
only:
- master
- We use the
machine
executor type since we are building Docker images during our workflows. We also enabledocker_layer_caching
which caches the individual layers of our Docker images built during each job so that unchanged image layers are reused on subsequent CircleCI runs, rather than rebuilding the entire image every time. Read more about it here. - We then clone our api code, from
master
, into a directory next to our app code and simply runmake e2e
.
Testing never felt so good!
P.S. — I also released a course on mastering technical interviews. Check it out!