Testing Mongo with Cypress

How to access the MongoDB during Cypress API tests locally and on CircleCI

Imagine your web application is using MongoDB to store its data. How would you take advantage of it during Cypress tests? This blog post shows how to clear the data from a local Mongo database before each test by connecting to the DB directly from the Cypress plugin file.

The setup

My "application" is very simply - it is a single API endpoint that collects pizza information. You can post new pizza names plus ingredients and get the full list.

server.js
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const { connect } = require('./db')
const express = require('express')
const bodyParser = require('body-parser')

const app = express()
app.use(bodyParser.json()) // for parsing application/json

let db
let pizzas

app.get('/pizza', async (req, res) => {
console.log('getting pizza list')

if (!pizzas) {
pizzas = db.collection('pizzas')
}

const cursor = pizzas.find()
const count = await cursor.count()
if (count === 0) {
console.log('No pizzas found!')
return res.json([])
}

console.log('Found %d', count)
const list = await cursor.toArray()
res.json(list)
})

app.post('/pizza', async (req, res) => {
console.log('POST pizza', req.body)

if (!pizzas) {
pizzas = db.collection('pizzas')
}

const result = await pizzas.insertOne(req.body)
console.log('inserted %s', result.insertedId)
res.json({ _id: result.insertedId })
})

async function initServer() {
db = await connect()

const PORT = process.env.PORT || 8080
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}...`)
})

// TODO implement disconnect on quit signal
}

initServer().catch(console.dir)

The Mongo connection is made in the file db.js shown below:

db.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { MongoClient } = require('mongodb')
const uri = process.env.MONGO_URI
if (!uri) {
throw new Error('Missing MONGO_URI')
}

const client = new MongoClient(uri)
async function connect() {
// Connect the client to the server
await client.connect()

return client.db('foods')
}

async function disconnect() {
// Ensures that the client will close when you finish/error
await client.close()
}

module.exports = { connect, disconnect }

🎁 You can find the source code at bahmutov/cypress-example-mongodb.

Notice that we connect to the Mongo instance using the single connection string from the MONGO_URI environment variable.

Run Mongo locally

The simplest way to run Mongo instance locally is by using Docker container, read this post.

1
2
3
4
5
6
docker pull mongo
docker run -d --name mongo-on-docker \
-p 27017:27017 \
-e MONGO_INITDB_ROOT_USERNAME=mongoadmin \
-e MONGO_INITDB_ROOT_PASSWORD=secret \
mongo

The above command starts a new container called mongo-on-docker using the pulled image mongo. The container will expose the port 27017 (Mongo's default port for connection). We also pass the admin user name and password we want to use.

The user name and the password then can be used to create the connection string environment variable:

1
MONGO_URI=mongodb://mongoadmin:secret@localhost:27017/?authSource=admin

Tip: to stop and remove the container later use the following commands:

1
2
docker stop mongo-on-docker
docker rm mongo-on-docker

Start the server

After starting the Mongo DB instance, we can start the server. We can pass the MONGO_URI inline

1
2
$ MONGO_URI=mongodb://mongoadmin:secret@localhost:27017/?authSource=admin \
node ./server

A better idea is to use as-a to keep the secret environment variable outside the repository and easily inject them when running the commands. Thus I place the MONGO_URI into ~/.as-a/.as-a.ini file

1
2
[mongo-example]
MONGO_URI=mongodb://mongoadmin:secret@localhost:27017/?authSource=admin

and start the server like this after installing as-a globally

1
2
3
4
$ npm i -g as-a
$ as-a mongo-example node ./server

Server listening on port 8080...

Super, we can hit the server endpoint /pizza from the command line to check:

1
2
$ curl http://localhost:8080/pizza
[]

The first API test

Let's write Cypress API test using cy.request command.

We will always hit the local URL, so let's place it into the baseUrl setting in the configuration file cypress.json:

cypress.json
1
2
3
4
5
{
"fixturesFolder": false,
"supportFile": false,
"baseUrl": "http://localhost:8080"
}
cypress/integration/spec.js
1
2
3
4
5
6
7
8
/// <reference types="cypress" />

describe('Pizzas', () => {
it('shows an empty list initially', () => {
// https://on.cypress.io/request
cy.request('/pizza').its('body').should('deep.equal', [])
})
})

The first passing API test

Let's write a test that adds a pizza and verifies it is returned.

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// <reference types="cypress" />

describe('Pizzas', () => {
it('shows an empty list initially', () => {
// https://on.cypress.io/request
cy.request('/pizza').its('body').should('deep.equal', [])
})

it('adds pizzas', () => {
cy.request('POST', '/pizza', {
name: 'Margherita',
ingredients: ['tomatoes', 'mozzarella'],
})
cy.request('/pizza')
.its('body')
.should('have.length', 1)
.its(0)
.should('have.property', 'name', 'Margherita')
})
})

The tests pass. One advantage of using Cypress to work with API tests is that you can inspect every request to see what was returned. For example, by clicking on the cy.request in the second test we can see the full list of returned objects.

Inspecting the objects returned from the backend

Tip: did you notice that during the API tests the application's iframe stays empty? You can pipe the request information there, see the cy-api project.

Clear the collection

If we re-run the tests again, our tests fail.

Failing tests

Of course, we have never cleared the collection of objects before the tests, so the database keeps them around. How should we clear the collection, if the API does not expose the clear method?

By connecting to the Mongo database directly from the Cypress test runner and clearing it using a cy.task command. Let's add the following code to the plugin file:

cypress/plugins/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <reference types="cypress" />

const { connect } = require('../../db')

module.exports = async (on, config) => {
const db = await connect()
const pizzas = db.collection('pizzas')

on('task', {
async clearPizzas() {
console.log('clear pizzas')
await pizzas.remove({})

return null
},
})
}

Tip: Cypress v6+ comes with Node v12+ built-in, thus we can use all the modern ES6 syntax like async / await sugar to write asynchronous code.

From the spec file, we should all the clearPizzas task before the tests start.

cypress/integration/spec.js
1
2
3
4
5
6
describe('Pizzas', () => {
before(() => {
cy.task('clearPizzas')
})
...
})

Because Cypress needs to connect to the Mongo instance (just like the server.js), we start it by passing the MONGO_URI environment variable. The plugin file runs in Node, requires the db.js file, which uses that environment variable to connect.

1
$ as-a mongo-example npx cypress open

We can now write more tests, if we want to

cypress/integration/spec.js
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
35
36
37
38
39
40
41
42
/// <reference types="cypress" />

describe('Pizzas', () => {
before(() => {
cy.task('clearPizzas')
})

it('shows an empty list initially', () => {
// https://on.cypress.io/request
cy.request('/pizza').its('body').should('deep.equal', [])
})

it('adds pizzas', () => {
cy.request('POST', '/pizza', {
name: 'Margherita',
ingredients: ['tomatoes', 'mozzarella'],
})
cy.request('/pizza')
.its('body')
.should('have.length', 1)
.its(0)
.should('have.property', 'name', 'Margherita')
})

// bad practice: assume this test runs after the previous test
it('adds vegan pizza', () => {
cy.request('POST', '/pizza', {
name: 'Vegan',
ingredients: ['Roma tomatoes', 'bell peppers'],
})
cy.request('/pizza')
.its('body')
.should('have.length', 2)
.its(1)
// ignore "_id" property
.should('include.keys', ['name', 'ingredients'])
.and('deep.include', {
name: 'Vegan',
ingredients: ['Roma tomatoes', 'bell peppers'],
})
})
})

And they all pass

Passing tests

Testing on CI

I will run the same tests on CircleCI using the Cypress Orb. To run MongoDB we can use a service container that spins the second Docker container linked to the first one (where our server and tests execute) automatically. See the Cypress recipes for more examples.

circle.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
version: 2.1
orbs:
cypress: cypress-io/cypress@1
# using service containers on CircleCI
# https://circleci.com/docs/2.0/databases/
executors:
with-mongo:
docker:
# image used to install source code,
# run our server and run Cypress tests
- image: cypress/base:14.16.0
environment:
MONGO_URI: mongodb://$MONGO_USERNAME:$MONGO_PASSWORD@localhost:27017/?authSource=admin

# image used to run Mongo in a separate container
- image: mongo:4.4.5
environment:
MONGO_INITDB_ROOT_USERNAME: $MONGO_USERNAME
MONGO_INITDB_ROOT_PASSWORD: $MONGO_PASSWORD
workflows:
build:
jobs:
- cypress/run:
executor: with-mongo
start: npm start
# no need to save the workspace after this job
no-workspace: true

In the CircleCI project's page set the environment variables MONGO_USERNAME and MONGO_PASSWORD to use during testing. The Circle YML file forms the MONGO_URI environment variable to use in the server and Cypress to connect from those user name and password values.

Set the picked Mongo username and password as environment variables

Push a new commit to the repository to trigger the build. The build should pass.

Successful Cypress test run

You can drill down to see the individual test steps performed by the orb. For example, you can see the log output from the Mongo container running during the entire test job.

Log output from the Mongo service container

And you can see the output from the server and from Cypress tests. Clearing the collection, adding new pizzas, all that delicious jazz.

Log output from the server and Cypress

Happy testing!