Check out my books on Amazon at https://www.amazon.com/John-Au-Yeung/e/B08FT5NT62
Subscribe to my email list now at http://jauyeung.net/subscribe/
Automated tests are essential to the apps we write since modern apps have so many moving parts.
In this piece, we’ll look at how to write apps to test an Express app that interacts with a database with Jest and SuperTest.
Creating the App We’ll Test
We create a project folder by creating an empty folder and running the following to create a package.json
file with the default answers:
npm init -y
Then we run the following to install the packages for our apps:
npm i express sqlite3 body-parser
Then, we create the app.js
file for our app and write:
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const bodyParser = require('body-parser');
const app = express();
const port = process.env.NODE_ENV === 'test' ? 3001 : 3000;
let db;
if (process.env.NODE_ENV === 'test') {
db = new sqlite3.Database(':memory:');
}
else {
db = new sqlite3.Database('db.sqlite');
}
db.serialize(() => {
db.run('CREATE TABLE IF NOT EXISTS persons (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)');
});
app.use(bodyParser.json());
app.get('/', (req, res) => {
db.serialize(() => {
db.all('SELECT * FROM persons', [], (err, rows) => {
res.json(rows);
});
})
})
app.post('/', (req, res) => {
const { name, age } = req.body;
db.serialize(() => {
const stmt = db.prepare('INSERT INTO persons (name, age) VALUES (?, ?)');
stmt.run(name, age);
stmt.finalize();
res.json(req.body);
})
})
app.put('/:id', (req, res) => {
const { name, age } = req.body;
const { id } = req.params;
db.serialize(() => {
const stmt = db.prepare('UPDATE persons SET name = ?, age = ? WHERE id = ?');
stmt.run(name, age, id);
stmt.finalize();
res.json(req.body);
})
})
app.delete('/:id', (req, res) => {
const { id } = req.params;
db.serialize(() => {
const stmt = db.prepare('DELETE FROM persons WHERE id = ?');
stmt.run(id);
stmt.finalize();
res.json(req.body);
})
})
const server = app.listen(port);
module.exports = { app, server };
The code above has the app we’ll test.
To make our app easier to test, we have:
const port = process.env.NODE_ENV === 'test' ? 3001 : 3000;
let db;
if (process.env.NODE_ENV === 'test') {
db = new sqlite3.Database(':memory:');
}
else {
db = new sqlite3.Database('db.sqlite');
}
So we can set the process.env.NODE_ENV
to 'test'
to make our app listen to a different port than it does when the app is running in a nontest environment.
We’ll use the 'test'
environment to run our tests.
Likewise, we want our app to use a different database when running unit tests than when we aren’t running them.
This is why we have:
let db;
if (process.env.NODE_ENV === 'test') {
db = new sqlite3.Database(':memory:');
}
else {
db = new sqlite3.Database('db.sqlite');
}
We specified that when the app is running in a 'test'
environment we want to use SQLite’s in-memory database rather than a database file.
Writing the Tests
Initialization the code
With the app made to be testable, we can add tests to it.
We’ll use the Jest test runner and SuperTest to make requests to our routes in our tests. To add Jest and SuperTest, we run:
npm i jest supertest
Then, we add app.test.js
to the same folder as the app.js
file we had above.
In app.test.js
, we start by writing the following:
const { app } = require('./app');
const sqlite3 = require('sqlite3').verbose();
const request = require('supertest');
const db = new sqlite3.Database(':memory:');
beforeAll(() => {
process.env.NODE_ENV = 'test';
})
In the code above, we included our Express app from our app.js
. Then we also included the SQLite3 and SuperTest packages.,
Then, we connected to our in-memory database with:
const db = new sqlite3.Database(':memory:');
Next, we set all the tests to run in the 'test'
environment by running:
beforeAll(() => {
process.env.NODE_ENV = 'test';
})
This will make sure we use port 3001
and the in-memory database we specified in app.js
for each test.
To make our tests run independently and with consistent results, we have to clean our database and insert fresh data every time.
To do this, we create a function we call on each test:
const seedDb = db => {
db.run('CREATE TABLE IF NOT EXISTS persons (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)');
db.run('DELETE FROM persons');
const stmt = db.prepare('INSERT INTO persons (name, age) VALUES (?, ?)');
stmt.run('Jane', 1);
stmt.finalize();
}
The code above creates the persons
table if it doesn’t exist and deletes everything from there afterward.
Then we insert a new value in there to have some starting data.
Adding Tests
With the initialization code complete, we can write the tests.
GET request test
First, we write a test to get the existing seed data from the database with a GET
request.
We do this by writing:
test('get persons', () => {
db.serialize(async () => {
seedDb(db);
const res = await request(app).get('/');
const response = [
{ name: 'Jane', id: 1, age: 1 }
]
expect(res.status).toBe(200);
expect(res.body).toEqual(response);
})
});
We put everything inside the callback of db.serialize
so the queries will be run sequentially.
First, we call seedDb
, which we created above to create the table if it doesn’t exist, to clear out the database, and to add new data.
Then, we call the GET
request by writing:
await request(app).get('/');
This gets us the res
object with the response resolved from the promise.
request(app)
will start the Express app so we can make the request.
Next, we have the response
for us to check against for correctness:
const response = [
{ name: 'Jane', id: 1, age: 1 }
]
Then, we check the responses to see if we get what we expect:
expect(res.status).toBe(200);
expect(res.body).toEqual(response);
The toBe
method checks for shallow equality, and toEqual
checks for deep equality. So we use toEqual
to check if the whole object structure is the same.
res.status
checks the status code returned from the server, and res.body
has the response body.
POST request test
Next, we add a test for the POST
request. It’s similar to the GET
request test.
We write the following code:
test('add person', () => {
db.serialize(async () => {
seedDb(db);
await request(app)
.post('/')
.send({ name: 'Joe', age: 2 });
const res = await request(app).get('/');
const response = [
{ name: 'Jane', id: 1, age: 1 },
{ name: 'Joe', id: 2, age: 2 }
]
expect(res.status).toBe(200);
expect(res.body).toEqual(response);
})
});
First, we reset the database with:
seedDb(db);
We made our POST
request with:
await request(app)
.post('/')
.send({ name: 'Joe', age: 2 });
This will insert a new entry into the in-memory database.
Finally, to check for correctness, we make the GET
request — like in our first test — and check if both entries are returned:
const res = await request(app).get('/');
const response = [
{ name: 'Jane', id: 1, age: 1 },
{ name: 'Joe', id: 2, age: 2 }
]
expect(res.status).toBe(200);
expect(res.body).toEqual(response);
PUT and DELETE tests
The test for the PUT
request is similar to the POST
request. We reset the database, make the PUT
request with our payload, and then make the GET
request to get the returned data, as follows:
test('update person', () => {
db.serialize(async () => {
seedDb(db);
await request(app)
.put('/1')
.send({ name: 'Joe', age: 2 });
const res = await request(app).get('/');
const response = [
{ name: 'Jane', id: 1, age: 1 }
]
expect(res.status).toBe(200);
expect(res.body).toEqual(response);
})
});
Then we can replace the PUT
request with the DELETE
request and test the DELETE
request:
test('delete person', () => {
db.serialize(async () => {
seedDb(db);
const res = await request(app).delete('/1');
const response = [];
expect(res.status).toBe(200);
expect(res.body).toEqual(response);
})
});
Running the Tests
To run the tests, we add the following to the scripts
section:
"test": "jest --forceExit"
We have to add the --forceExit
option so Jest will exist after the tests are run. There’s no fix for the issue where Jest tests using SuperTest don’t exit properly yet.
Then we run the following to run the tests:
npm test
And we should get:
PASS ./app.test.js
√ get persons (11ms)
√ add person (2ms)
√ update person (2ms)
√ delete person (6ms)Test Suites: 1 passed, 1 total
Tests: 4 passed, 4 total
Snapshots: 0 total
Time: 2.559s
Ran all test suites.
Force exiting Jest: Have you considered using `--detectOpenHandles` to detect async operations that kept running after all tests finished?
We should get the same thing no matter how many times we run the tests since we reset the database and made all database queries run sequentially.
Also, we used a different database and port for our tests than other environments, so the data should be clean.
Conclusion
We can add tests run with the Jest test runner. To do this, we have to have a different port and database for running the tests. Then we create the tables if they don’t already exist, clear all the data, and add seed data so we have the same database structure and content for every test.
With SuperTest, we can run the Express app automatically and make the request we want. Then, we can check the output.