DEV Community

Sean Atukorala
Sean Atukorala

Posted on

How to do API Testing using Mocha and Chai for JavaScript Applications

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!

Here are the technologies we'll be using for this tutorial

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 the server/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();
            });
    });
});
Enter fullscreen mode Exit fullscreen mode

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();
      });
  });
  ...
Enter fullscreen mode Exit fullscreen mode

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 and fullName
  • The .type('form') call will set the application/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 equal 201
  • Finally, the done() call ensures that Mocha will wait for the current test to finish before moving onto the next test. This done() 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();
      });
  });
Enter fullscreen mode Exit fullscreen mode

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 the res.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();
      });
  });
Enter fullscreen mode Exit fullscreen mode

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();
      });
  });
Enter fullscreen mode Exit fullscreen mode

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 the expect() method to assert the email, username and fullName 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();
      });
  });
Enter fullscreen mode Exit fullscreen mode

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 single username field containing the duplicate username
  • On top of asserting for the 'Username already exist' message we assert that the response status code is 409

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();
        });
  });
Enter fullscreen mode Exit fullscreen mode

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 the res.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();
      });
});
Enter fullscreen mode Exit fullscreen mode

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 in addBook) and the logged in user's details(contained in adminUser) 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 the expect() 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 define expect with chai.expect in order to use Chai's expect() method. We use Chai's expect() 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();
          });
      });
Enter fullscreen mode Exit fullscreen mode

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's expect() 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();
          });
    });
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)