Node TDD
Repository
If you are experienced with TDD and just want a node example feel free to just clone the repository available here
Intro
In the node ecosystem, we haven’t been able to find an article describing the way we prefer to practice TDD internally. In our opinion, with simple REST APIs we believe that people wrongly overly focus on UNIT testing, and sometimes forget to write END to END tests.
We prefer to do the oposite: we start with END to END tests - and if we decide the code is complex enough, we start to write more granular tests on algorithm heavy pieces of code. Generally for basic CRUD we find the mockist aproach that seems to be the most prevelent in the Nodejs ecosystem to be a bit overkill and often overly coupled to implementation details.
That is not to say that we never use mocks… We do! just not in the context of a simple webservice.
We want to do some TDD while creating a simple API. In this tutorial, we chose to use ExpressJS as a web framework, Sequelize as the ORM and a parsing middleware body-parse.
Lets get started!
Project initialisation
Let’s create a new folder.
mkdir node_tdd && cd node_tdd
Let’s initialize a new node project
npm init -y
npm i express sequelize body-parser --save
We will use Postgres database, but you can as well use Mysql as well.
npm i pg
If the “sequelize” command is not found, install the sequelize-cli.
npm i -g sequelize-cli
To keep the project clean, we will use dotenv package in order to get the databases credentials
npm install dotenv --save
Now, the base packages of our project are ready to be used. We can start the configuration.
Sequelize set up
We first initialize our models and migrations folders with sequelize. This will create two new folders:
- an empty migrations folder
- a models folder with a index.js file in it.
sequelize init:models
sequelize init:migrations
Database creation
We want to create two separate databases: one for our automatic testing, and one for playing around with the project. (dev and testing) We create 2 files in the project’s root:
- /.env
- /.env.test
We will keep our databases credentials there.
You can for example name your db node_tdd
and you test db node_tdd_test
Fill the below informations in each of these files.
DB_NAME=your_db_name
DB_USER=your_db_username
DB_PASSWORD=your_db_password
DB_HOST=127.0.0.1
DB_DIALECT=postgres
Create a file named /config/config.js
const env = process.env.NODE_ENV || 'development'
switch (env) {
case 'development':
require('dotenv').config({path: process.cwd() + '/.env'})
break
case 'test':
require('dotenv').config({path: process.cwd() + '/.env.test'})
}
module.exports = {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
dialect: process.env.DB_DIALECT,
}
In /model/index.js
, replace this line
const config = require(__dirname + '/../config/config.json')[env];
by this one
const config = require(__dirname + '/../config/config');
In this same file, we add a function in the db
object which allows us to close the connection with the database.
We will need this to write our tests later.
db.close = async () => {
await db.sequelize.close()
};
Let’s create our databases! It can be done manually but thanks to the infomation we provided it, Sequelize can do it for us!
NODE_ENV=development sequelize db:create
NODE_ENV=test sequelize db:create
Our databases are now created. Time to write some migrations. Migrations are used to automate the process of keeping your environments in sync. (testing, dev on your teams local computers + production and potentially staging)
We want a simple database with 2 tables. Author and Post. Author will have multiple Posts. Post will belong to one Author.
Sequelize can create the model and the associated migration with one command.
sequelize model:generate --name Author --attributes firstName:string,lastName:string
sequelize model:generate --name Post --attributes title:string,content:text
Let’s get this all migrated, in both environments.
NODE_ENV=development sequelize db:migrate
NODE_ENV=test sequelize db:migrate
In order to create a belongs to
association, we have to create a migration.
Our Post table must have a AuthorId column.
sequelize migration:create --name='add-author-id-to-posts'
Replace the migration file with:
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn('Posts', 'AuthorId', {
type: Sequelize.INTEGER,
references: {
model: 'Authors', // name of Target model
key: 'id', // key in Target model that we're referencing
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
})
},
down: (queryInterface) => {
return queryInterface.removeColumn('Posts', 'AuthorId')
}
}
NODE_ENV=development sequelize db:migrate
NODE_ENV=test sequelize db:migrate
Let’s add the associations in model files
In Author model:
Author.associate = (models) => {
Author.hasMany(models.Post)
}
In Post model:
Post.associate = (models) => {
Post.belongsTo(models.Author)
}
Create /app.js We will put our routes/controllers in this file.
const express = require('express')
const bodyParser = require('body-parser')
const db = require('./models')
const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({
extended: true
}))
app.use(express.static('app/public'))
module.exports = app
Create /server.js
const db = require('./models')
const app = require('./app')
app.listen(3000, () => console.log('App listening on port 3000!'))
The app is now ready. Let’s install all the packages in order to start practicing TDD.
npm install --save-dev jest supertest babel-cli babel-preset-env
Replace the test script in your package.json
"test": "jest --runInBand --forceExit spec"
Create a /spec folder and create a test file in it. (/spec/api.test.js)
const request = require('supertest')
const app = require('../app')
const db = require('../models');
describe('GET /', () => {
let response;
beforeEach(async () => {
await cleanDb(db)
response = await request(app).get('/');
})
test('It should respond with a 200 status code', async () => {
expect(response.statusCode).toBe(200);
});
});
We want to clean our database before each test. To do that, we create a helper function which will handle this.
(/spec/helpers/cleanDb.js
)
const cleanDb = async (db) => {
await db.Author.truncate({ cascade: true });
await db.Post.truncate({ cascade: true });
}
module.exports = cleanDb
Now the helper is created, let’s use it in our spec file.
Add this line in api.test.js
to clean the database before and after the tests.
Import the helper at the top of the file.
const cleanDb = require('./helpers/cleanDb')
We want to clean the database before and after all tests
beforeAll(async () => {
await cleanDb(db)
});
afterAll(async () => {
await cleanDb(db)
await db.close()
});
Let’s launch our tests with jest.
npm run test
The test received an 404 response code.
That means that the file was not found. Let’s create a pretty basic one in app.js
file.
app.get('/', (req, res) => {
res.status(200).send('Hello.')
})
Once the route is created, the test is green! Let’s add a new test block. We want a route to create an author in database.
describe('POST /author', () => {
let response;
let data = {};
beforeAll(async () => {
data.firstName = 'John'
data.lastName = 'Wick'
response = await request(app).post('/author').send(data);
})
test('It should respond with a 200 status code', async () => {
expect(response.statusCode).toBe(200);
});
});
For now we just want a positive response code. (200). But once we run the test, we get a 404 instead of 200. Let’s add the route.
app.js
app.post('/author', async (req, res) => {
res.status(200).send('POST author')
})
Tests OK !
But we want more functionalities with this route. We want to create a new author with a firstName and lastName. The route should return a json payload of this newly created author. Lets translate the above requirement into a test!
test('It should return a json with the new author', async () => {
expect(response.body.firstName).toBe(data.firstName);
expect(response.body.lastName).toBe(data.lastName);
});
The test fail. Let’s modify our controller to make the test pass.
app.post('/author', async (req, res) => {
await db.Author.create({
firstName: req.body.firstName,
lastName: req.body.lastName
}).then((result) => res.json(result))
})
Tests are now green !
This verifies only half the requirement! We have nothing that verifies that the data has in fact been added to the DB (all we know from the above test is that the route returns the correct json) Let’s add a test which will ensure this.
test('It should create and retrieve a post for the selected author', async () => {
const author = await db.Author.findOne({where: {
id: response.body.id
}})
expect(author.id).toBe(response.body.id)
expect(author.firstName).toBe(data.firstName)
expect(author.lastName).toBe(data.lastName)
});
It passes too ! No need to modify our controller.
Let’s do the same thing with getting all the authors. The /GET authors
route should return all the authors in our db. Lets write the test first!
describe('GET /authors', () => {
let response;
let data = {};
beforeAll(async () => await cleanDb(db))
describe('when there is no author in database', () => {
beforeAll(async () => {
response = await request(app).get('/authors').set('Accept', 'application/json');
})
test('It should not retrieve any authors in db', async () => {
const authors = await db.Author.findAll()
expect(authors.length).toBe(0);
});
test('It should respond with a 200 status code', async () => {
expect(response.statusCode).toBe(200);
});
})
});
Tests are red. Let’s create the route.
app.get('/authors', async (req, res) => {
res.status(200).send('Hello World!')
})
Tests OK ! We want the response to be a void array.
test('It should return a json with a void array', async () => {
expect(response.body).toStrictEqual([]);
});
Test red. Let’s modify our controller.
app.get('/authors', async (req, res) => {
await db.Author.findAll().then((result) => res.json(result))
})
Tests are now green !
The following tests will need to have some data already created. In order to do this we will use factories. Factories are a very useful tool to build or create objects with some automatically generated default values. Let’s install Factory Girl.
npm install factory-girl --save
Let’s create a factory file in spec/factories/author.js
const factoryGirl = require('factory-girl')
const adapter = new factoryGirl.SequelizeAdapter()
factory = factoryGirl.factory
factory.setAdapter(adapter)
const Author = require('../../models').Author
factory.define('author', Author, {
firstName: factory.sequence((n) => `firstName${n}`),
lastName: factory.sequence((n) => `lastName${n}`),
})
Thanks to factory.sequence, all the authors we generate using the factory will have different names. We can always override the default values if we need too.
Let’s import the factory in the test file
require('./factories/author').factory
const factory = require('factory-girl').factory
And add a test block. This time we write some tests in the case where we have some already created author in db.
describe('when there is one or more authors in database', () => {
beforeAll(async () => {
authors = await factory.createMany('author', 5)
response = await request(app).get('/authors').set('Accept', 'application/json')
})
test('It should not retrieve any author in db', async () => {
const authorsInDatabase = await db.Author.findAll()
expect(authorsInDatabase.length).toBe(5)
});
test('It should respond with a 200 status code', async () => {
expect(response.statusCode).toBe(200)
});
test('It should return a json with a void array', async () => {
expect(response.body.length).toBe(5)
for (i = 0; i < 5 ; i++) {
const expectedBody = {
id: authors[i].id,
firstName: authors[i].firstName,
lastName: authors[i].lastName,
}
expect(response.body).toContainEqual(expectedBody)
}
});
})
Now we have another error. In the response body, we can see the authors but with timestamps. Let’s get rid of them.
app.get('/authors', async (req, res) => {
await db.Author.findAll(
{attributes: ['id', 'firstName', 'lastName']}
).then((result) => {
return res.json(result)
})
})
And now all the tests pass !
Let’s now create a Post factory, the same way we did with Author.
const factoryGirl = require('factory-girl')
const adapter = new factoryGirl.SequelizeAdapter()
factory = factoryGirl.factory
factory.setAdapter(adapter)
const Post = require('../../models').Post
factory.define('post', Post, {
title: factory.sequence((n) => `title${n}`),
content: factory.sequence((n) => `content${n}`),
})
Require Post factory in test file
require('./factories/post').factory
Now we can write a basic test for the new route we want to create.
describe('POST /post', () => {
let response
let data = {}
let post
let author
beforeAll(async () => await cleanDb(db))
describe('The author has one or multiple posts', () => {
beforeAll(async () => {
author = await factory.create('author')
post = await factory.build('post')
data.title = post.title
data.content = post.content
data.AuthorId = author.id
response = await request(app).post('/post').send(data).set('Accept', 'application/json')
})
test('It should respond with a 200 status code', async () => {
expect(response.statusCode).toBe(200);
});
})
});
404 error! Go to the app.js file and create the new route.
app.post('/post', async (req, res) => {
await db.Post.create({
title: req.body.title,
content: req.body.content,
AuthorId: req.body.AuthorId,
}).then((result) => res.json(result))
})
It passes !
Let’s add some tests, to ensure the POST post route is working correctly.
test('It should create and retrieve a post for the selected author', async () => {
const postsInDatabase = await db.Post.findAll()
expect(postsInDatabase.length).toBe(1)
expect(postsInDatabase[0].title).toBe(post.title)
expect(postsInDatabase[0].content).toBe(post.content)
});
test('It should return a json with the author\'s posts', async () => {
expect(response.body.title).toBe(data.title);
expect(response.body.content).toBe(data.content);
});
test('The post should belong to the selected authors\' posts', async () => {
const posts = await author.getPosts()
expect(posts.length).toBe(1)
expect(posts[0].title).toBe(post.title)
expect(posts[0].content).toBe(post.content)
})
And now it’s all good ! All the functionnalities listed in this test file are working properly.
The last often overlooked step in TDD is cleanup! Let’s organize our code a bit by extracting each resource’s routes/controllers to different files.
Create a file: /app/api/post.js
module.exports = (app, db) => {
app.post('/post', async (req, res) => {
await db.Post.create({
title: req.body.title,
content: req.body.content,
AuthorId: req.body.AuthorId,
}).then((result) => res.json(result))
})
}
Delete this piece of code from app.js and require it.
const postRoutes = require('./app/api/post')
...
postRoutes(app, db)
Do the same thing with author.
Create a file: /app/api/author.js
module.exports = (app, db) => {
app.post('/author', async (req, res) => {
await db.Author.create({
firstName: req.body.firstName,
lastName: req.body.lastName,
}).then((result) => res.json(result))
})
app.get('/authors', async (req, res) => {
await db.Author.findAll(
{attributes: ['id', 'firstName', 'lastName']}
).then((result) => {
return res.json(result)
})
})
}
Now your app.js file should look like this:
const express = require('express')
const bodyParser = require('body-parser')
const db = require('./models')
const postRoutes = require('./app/api/post')
const authorRoutes = require('./app/api/author')
const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({
extended: true
}))
app.use(express.static('app/public'))
app.get('/', async (req, res) => {
res.status(200).send('Hello World!')
})
postRoutes(app, db)
authorRoutes(app, db)
module.exports = app
And your test file should look like this:
const request = require('supertest')
const app = require('../app')
const db = require('../models');
const cleanDb = require('./helpers/cleanDb')
require('./factories/author').factory
require('./factories/post').factory
const factory = require('factory-girl').factory
beforeAll(async () => {
await cleanDb(db)
});
afterAll(async () => {
await cleanDb(db)
await db.close()
});
describe('GET /', () => {
let response;
beforeAll(async () => {
await cleanDb(db)
response = await request(app).get('/');
})
test('It should respond with a 200 status code', async () => {
expect(response.statusCode).toBe(200);
});
});
describe('POST /author', () => {
let response;
let data = {};
beforeAll(async () => {
data.firstName = 'Seb'
data.lastName = 'Ceb'
console.log(`data = ${JSON.stringify(data)}`)
response = await request(app).post('/author').send(data).set('Accept', 'application/json');
})
test('It should respond with a 200 status code', async () => {
expect(response.statusCode).toBe(200);
});
test('It should return a json with the new author', async () => {
console.log(response.body)
expect(response.body.firstName).toBe(data.firstName);
expect(response.body.lastName).toBe(data.lastName);
});
test('It should create and retrieve a post for the selected author', async () => {
const author = await db.Author.findOne({where: {
id: response.body.id
}})
expect(author.id).toBe(response.body.id)
expect(author.firstName).toBe(data.firstName)
expect(author.lastName).toBe(data.lastName)
});
});
describe('GET /authors', () => {
let response
let authors
beforeAll(async () => await cleanDb(db))
describe('when there is no author in database', () => {
beforeAll(async () => {
response = await request(app).get('/authors').set('Accept', 'application/json');
})
test('It should not retrieve any author in db', async () => {
const authors = await db.Author.findAll()
expect(authors.length).toBe(0);
});
test('It should respond with a 200 status code', async () => {
expect(response.statusCode).toBe(200);
});
test('It should return a json with a void array', async () => {
expect(response.body).toStrictEqual([]);
});
})
describe('when there is one or more authors in database', () => {
beforeAll(async () => {
authors = await factory.createMany('author', 5)
response = await request(app).get('/authors').set('Accept', 'application/json')
})
test('It should not retrieve any author in db', async () => {
const authorsInDatabase = await db.Author.findAll()
expect(authorsInDatabase.length).toBe(5)
});
test('It should respond with a 200 status code', async () => {
expect(response.statusCode).toBe(200)
});
test('It should return a json with a void array', async () => {
expect(response.body.length).toBe(5)
for (i = 0; i < 5 ; i++) {
const expectedBody = {
id: authors[i].id,
firstName: authors[i].firstName,
lastName: authors[i].lastName,
}
expect(response.body).toContainEqual(expectedBody)
}
});
})
});
describe('POST /post', () => {
let response
let data = {}
let post
let author
beforeAll(async () => await cleanDb(db))
describe('The author has one or multiple posts', () => {
beforeAll(async () => {
author = await factory.create('author')
post = await factory.build('post')
data.title = post.title
data.content = post.content
data.AuthorId = author.id
response = await request(app).post('/post').send(data).set('Accept', 'application/json')
})
test('It should respond with a 200 status code', async () => {
expect(response.statusCode).toBe(200);
});
test('It should create and retrieve a post for the selected author', async () => {
const postsInDatabase = await db.Post.findAll()
expect(postsInDatabase.length).toBe(1)
expect(postsInDatabase[0].title).toBe(post.title)
expect(postsInDatabase[0].content).toBe(post.content)
});
test('It should return a json with the author\'s posts', async () => {
expect(response.body.title).toBe(data.title);
expect(response.body.content).toBe(data.content);
});
test('The post should belong to the selected authors\' posts', async () => {
const posts = await author.getPosts()
expect(posts.length).toBe(1)
expect(posts[0].title).toBe(post.title)
expect(posts[0].content).toBe(post.content)
})
})
});
Run the tests one last time to check if refactoring broke anything. They still all pass ! We did not break anything!