Ever wonder how to conduct API testing in an efficient and effective manner using Mocha and Chai for Node.js applications? If so, keep reading to find out!
Figure 1: Here are the technologies we'll be using for this tutorial
Setup
First, here is the example Node.js application we're going to use for this tutorial: https://github.com/ShehanAT/nodejs-api-testing-mocha-chai
Start by cloning the project and opening it up in VSCode or a similar coding editor.
Note: If you want to follow along with a different Node.js app feel free to do so.
Introduction
Before diving into development, let's have an overview of the sample application above:
- This is a Node.js application with a Library Management System theme
- The Node.js server application that we're testing in this tutorial is contained in the
server
directory of our GitHub repository. Accordingly, our tests are contained in theserver/test
folder - For the sake of simplicity, we won't be connecting the app to an external database. We'll be using seeder files and
.txt
files to cover our database functionality - There are three main APIs for this application:
- User API: handles all requests related to users, authentication and registration
- Books API: handles all requests related to creating books, borrowing books, listing borrowed books, listing all books
- Category API: handles all requests related to listing categories of books
- The main testing technologies used for this tutorial will be the Mocha JavaScript test framework and the Chai BDD/TDD JavaScript assertion library
- We won't go over the routes and middleware for this application but rather will cover the tests written for this app's routes and middleware
Testing
First, let's start by going over the API tests in the homepageTests.test.js
file:
describe('Server should: ', () => {
it('return success message after GET / request', (done) => {
server
.get('/')
.set('Connection', 'keep alive')
.set('Content-Type', 'application/json')
.expect(200)
.end((err, res) => {
if(err){
console.log(err);
}
res.status.should.equal(200);
done();
});
});
});
The above test, encompassed in a it()
method, is testing for whether a 200
status code is received after making a GET
request to the URL: /
Pretty simple right?
Let's move on to testing the User API...
In the new_server/test/userAuthTest.test.js
file, we have our first test:
// validRegisterDetails: {
// fullName: 'Cleanthes Stoic',
// username: 'testuser1',
// password: 'password1',
// email: 'cleanthes123@gmail.com',
// passwordConfirm: 'password1'
// }
describe('User Api: ', () => {
it('should return valid HTML and 200 Response Code', (done) => {
server
.post('/api/v1/users/signup')
.set('Connection', 'keep alive')
.set('Content-Type', 'application/json')
.type('form')
.send(validRegisterDetails)
.expect(201)
.end((err, res) => {
if(err){
console.log(err);
}
res.status.should.equal(201);
done();
});
});
...
Now for an explanation of the above test:
- We are sending a
POST
request to the URL:/api/v1/users/signup
- We are sending the
validRegisterDetails
object as the request body for this request. This object contains the following fields:username
,password
,email
,passwordConfirm
andfullName
- The
.type('form')
call will set theapplication/x-www-form-urlencoded
request header for the request - The validation of the request takes place in the
end()
call, where we assert that the response code should equal201
- Finally, the
done()
call ensures that Mocha will wait for the current test to finish before moving onto the next test. Thisdone()
call is important in coordinating the test execution order in the asynchronous environment that we're running tests in
The next test in the new_server/test/userAuthTest.test.js
file is the following:
// invalidUsernameMin5: {
// fullName: 'Cleanthes Stoic',
// username: 'test',
// password: 'password2',
// email: 'cleanthes456@gmail.com',
// passwordConfirm: 'password2'
// }
it('should throw error if username is less than 5 characters', (done) => {
server
.post('/api/v1/users/signup')
.set('Connection', 'keep alive')
.set('Content-Type', 'application/json')
.type('form')
.send(invalidUsernameMin5)
.expect(400)
.end((err, res) => {
if(err){
console.log(err);
}
res.status.should.equal(400);
res
.body[0]
.error
.should.equal('Please provide a username with at least 5 characters.');
done();
});
});
Ok, let's go over the above test now:
- This test sends a request to the same URL as the previous one
- The only difference between this test and the previous one is the request body
- The request body contains a purposeful error: The username value is less than 5 characters in length. This is done intentionally to test the username validation feature of the corresponding server route
- Once the request is sent, we expect a
400
error status code. This assertion is done via theres.status.should.equal(400)
statement - Finally, we also assert that the
res.body[0].error
field should contain the username length validation error that we are expecting
On to the next test in userAuthTest.test.js
file:
// noFullName: {
// username: 'cato123',
// password: '123456',
// email: 'cato123@gmail.com',
// passwordConfirm: '123456'
// },
it('Should throw error if fullname is empty', (done) => {
server
.post('/api/v1/users/signup')
.set('Connection', 'keep alive')
.set('Content-Type', 'application/json')
.type('form')
.send(noFullName)
.expect(400)
.end((err, res) => {
res.status.should.equal(400);
res.body[0].error.should.equal('Your Fullname is required');
done();
});
});
Now for an explanation of the above test:
- This test is very similar to the previously added test with the only notable difference being that we're testing whether a validation error is returned in response to excluding the
fullName
field from the request body - The assertion for the presence of the
fullName
validation error is done via the statement:res.body[0].error.should.equal('Your Fullname is required');
On to the fourth test in the userAuthTest.test.js
file:
// signUp: {
// fullName: 'Zeno of Citium',
// username: 'zeno123',
// password: '123456',
// email: 'zeno123@gmail.com',
// isAdmin: true,
// passwordConfirm: '123456'
// },
it('Should register a new user when provided request body is valid', (done) => {
server
.post('/api/v1/users/signup')
.set('Connection', 'keep alive')
.set('Content-Type', 'application/json')
.type('form')
.send(signUp)
.expect(201)
.end((err, res) => {
if(err){
console.log(err);
}
res.status.should.equal(201);
res.body.message.should.equal('Signed up successfully');
const currentUser = jwt.decode(res.body.token);
// const currentUser = res.body.token;
expect(currentUser.currentUser.email).toEqual('zeno123@gmail.com');
expect(currentUser.currentUser.username).toEqual('zeno123');
expect(currentUser.currentUser.fullName).toEqual('Zeno of Citium');
done();
});
});
Now for an explanation for the above test:
- This test is unlike the previous tests we've added because we're testing the happy path scenario for the
POST /api/v1/users/signup
route: Successful user registration - As the
signup
request body object contains valid data, we use theexpect()
method to assert theemail
,username
andfullName
fields
And now for the last test in the userAuthTest.test.js
file:
it('Should Check for existing username', (done) => {
server
.post('/api/v1/users/validate')
.set('Connection', 'keep alive')
.set('Content-Type', 'application/json')
.type('form')
.send({ username: 'rufus' })
.expect(409)
.end((err, res) => {
res.status.should.equal(409);
res.body.message.should.equal('Username already exist');
done();
});
});
Here is its explanation:
- This test is checking whether the route can detect duplicate usernames. The expected response we're looking for is an error message indicating the user of duplicate usernames
- As see in the
send()
call, we only need to pass an object with a singleusername
field containing the duplicate username - On top of asserting for the
'Username already exist'
message we assert that the response status code is409
Now that we're done with the tests for the Users API, we can now cover the tests for the Books API.
These tests are contained in the /new_server/test/bookRouteTest.test.js
file.
Here is one such test:
it('If user is logged in then request: GET /users/:userId/books should return a list of books held by the user :userId', (done) => {
server
.get('/api/v1/users/3/books')
.set('Connection', 'keep alive')
.set('Content-Type', 'application/json')
.set('x-access-token', 'Bearer ' + xAccessToken)
.type('form')
.expect(200)
.end((err, res) => {
if(err){
console.log(err);
}
res.status.should.equal(200);
res.body.message.length.should.equal(3);
done();
});
});
Here's an explanation for the above test:
- This test sends a
GET
request to the/api/v1/users/{userId}/books
route - The expected response is a list of books that are currently held by the user. For this test, we're using the
userId
of 3 and are expecting the list to contain 3 objects. Hence, our assertion checks the length of theres.body.message
object for a value of 3
Here is a second test for the bookRouteTest.test.js
file:
const expect = chai.expect;
it('Should allow the user to create a new book and return it if the user is logged, via the request: POST /books', (done) => {
server
.post('/api/v1/books')
.set('Connection', 'keep alive')
.set('Content-Type', 'application/json')
.set('x-access-token', 'Bearer ' + xAccessToken)
.send([ addBook, adminUser ])
.type('form')
.expect(201)
.end((err, res) => {
if(err){
console.log(err);
}
expect(res.body.book.bookId).to.not.be.null;
expect(res.body.book.name).to.not.be.null;
expect(res.body.book.isbn).to.not.be.null;
expect(res.body.book.description).to.not.be.null;
expect(res.body.book.productionYear).to.not.be.null;
expect(res.body.book.categoryId).to.not.be.null;
expect(res.body.book.author).to.not.be.null;
expect(res.body.book.total).to.not.be.null;
done();
});
});
Now for an explanation of the above test:
- This test sends a
POST
request to the/api/v1/books
route. This route is supposed to create a new book based on the data provided in the request body and return that book object in the response body. Therefore, all of our assertions check of the exsitence of all the fields in the response body object - If you're wondering why there is an array with two objects in the
send()
method for this test, its because both the new book details(contained inaddBook
) and the logged in user's details(contained inadminUser
) are needed by the API to create the book. Therefore, the most convienient way to send both objects was to add them to an array and send the entire array as the request body. I will admit that this is not the cleanest, most modular and maintainable way of sending the request body, but for the purposes of this small example application we can make an exception - One final note on the
expect()
method: This is not theexpect()
method from the Jest testing framework and instead from the Chai JavaScript testing library. As shown on top of the test, we make sure to defineexpect
withchai.expect
in order to use Chai'sexpect()
method. We use Chai'sexpect()
over the one provided by Jest because it makes it much easier to check for the existence of a value via its chainable getters i.e..to.not.be.null
. More on Chai's chainable getters in their official documentation
Now for the third test in the bookRouteTest.test.js
file:
it('Should allow the user to borrow a book if the user is logged in, via the request: POST /users/{userId}/books', (done) => {
server
.post('/api/v1/users/4/books')
.set('Connection', 'keep alive')
.set('Content-Type', 'application/json')
.set('x-access-token', 'Bearer ' + xAccessToken)
.send([ addBook, nonAdminUser ])
.type('form')
.expect(200)
.end((err, res) => {
if(err){
console.log(err);
}
expect(res.body.rentedBook.bookId).to.not.be.null;
expect(res.body.rentedBook.userId).to.not.be.null;
expect(res.body.rentedBook.returned).to.be.false;
done();
});
});
Now for an explanation for the above test:
- This test will make a
POST
request to the URL/user/{userId}/books
. This route's main purpose is to let the logged in user borrow a book. The borrowed book will be returned in the response body - The request body will contain an array of the same format as the previous test, as both the details of the book to be borrowed(
addBook
) and the user details(nonAdminUser
) are needed by the route's middleware - The response body should contain the
rentedBook
object, which represents the book rented by the user. The Chai testing library'sexpect()
method is used to conduct the field existence check validation via the chainable getter:.to.not.be.null
Lastly, let's quickly cover the Category API by going over the last test covered in this tutorial, contained in the /new_server/test/categoryRouteTest.test.js
file:
it('If user is logged in then request: GET /books should return a list of 3 books', (done) => {
server
.get('/api/v1/category')
.set('Connection', 'keep alive')
.set('Content-Type', 'application/json')
.set('x-access-token', 'Bearer ' + xAccessToken)
.type('form')
.expect(200)
.end((err, res) => {
if(err){
console.log(err);
}
res.status.should.equal(200);
expect(res.body.categories.length).to.be.greaterThan(5);
done();
});
});
Now for an explanation for the above test:
- This test will make a
GET
request to the URL/api/v1/category
. This route's main purpose is to provide a list of book categories currently present in the library management system as its response - All we're checking for here is the length of the
res.body.categories
object. As we know that there are 5 book categories in the system, we setup the assertion with the appropriate chainable getter of:to.be.greaterThan(5)
If you made it this far, congrats! You now have some idea of how to write effective API tests for JavaScript applications.
Conclusion
Well that's it for this post! Thanks for following along in this article and if you have any questions or concerns please feel free to post a comment in this post and I will get back to you when I find the time.
If you found this article helpful please share it and make sure to follow me on Twitter and GitHub, connect with me on LinkedIn and subscribe to my YouTube channel.
Top comments (0)