Javascript

Quick Introduction: Mocha, Chai and Chai-http Test Express API Auth Endpoints

Irrespective of the programming language or framework used, Obeying the Testing Goat is essential and leads to reduced error-prone programs or applications. Let’s put Mocha, Chai and Chai-HTTP to use and see how we can test an Express API endpoints behind authentication.

As much as testing is generally not fun to write, we all appreciate the benefits. Fortunately, I’m not here to tell you what goodness testing comes with, and how it can improve your code base if you have good test coverage.

In this article, let’s explore how to handle testing API endpoints which are protected. In our setup, we’ll be using JWTs, therefore, a way to maintain the JWT in subsequent tests without refreshing is important, in order to pass a flow.

Our stack for this article are:

  • Express (duh)
  • Mocha (npm install --save-dev mocha)
  • Chai and Chai-HTTP (npm install --save-dev chai chai-http)

Of course, this article assumes you have a bit of experience using at least, Javascript of which Express is made of.

If you’ve played with Express before, then that’s fine. In any, I think developers interested in testing know their way around.

So let’s get in now that we have our needed packages installed.

What to Test?

In short, we wanna test these:

  1. /GET login and /POST login
  2. /GET register and  /POST register
  3. /POST register, /POST login and then /GET user (a protected endpoint)

I won’t go over the steps for 1. and 2. as the third point will take us through all that, and throw in the accessing a protected route, namely, /GET user, which returns a User info.

Therefore, our /GET register and /POST register endpoints can like this (for brevity, some code part will be cut short)

// entire api endpoint url looks like this eventually:
// localhost:3000/api/v1/auth/register

router.get('/register', function(req, res) {
    res.json({
        'state': true,
        'msg': 'Register endpoint',
        'data': {
            'username': 'username',
            'email': 'email',
            'password': 'password',
            'fullName': 'full name'
        }
    });
});

router.post('/register', function(req, res) {
    // check if all required were submitted
    if (req.body.username && req.body.email && req.body.password) {

        let newUser = new User(req.body); //this is safe, because of defined schema behind the scenes
        newUser.hash_password = bcrypt.hashSync(req.body.password, 10);
        newUser.save(function(err, user) {
            if (err) {
                if (err && err.code === 11000) { // this is Duplicate key error code from MongoDB
                    return res.status(500).json({
                        'state': false,
                        'msg': 'User with this email address exist.'
                    })
                }
                return res.status(400).json({
                    'state': false,
                    'msg': err.message
                });
            } else {

                verification_token = crypto.randomBytes(20).toString('hex');

                // Do a verification_token 'distin' here.

                /**
                 * Reads an html file, and sends it as email, replacing values in it with params
                 */
                utils.readHTMLFile('./public/email/verify-email.html', function(err, html) {

                    // handlebars replaces parts in html for us
                    /* let template = handlebars.compile(html);
                    let replacements = {
                        ....
                    };

                    let mailOptions = {
                        ...
                    };
                    */

                    // ignore sending mail if running in test mode
                    if (req.app.get('env') === 'test') {
                        // Don't send mail
                        console.log('Sending no mail, in test mode');
                    } else {
                        // Send actual email
                        sendAnEmail(mailOptions);
                    }

                    // Bingo!
                    res.status(201).json({
                        'state': true,
                        'msg': 'Registration Successful. Kindly check your email for further instructions.'
                    });
                })
            }
        });
    } else {
      res.json({
        'state': false,
        'msg': 'Please include all required credentials, namely, Email, Username and Password'
      })
    }
});

Our /GET login and /POST login can look on the other hand, like this:

// localhost:3000/api/v1/auth/login
router.get('/login', function(req, res) {
    res.json({
        'state': true,
        'msg': 'Login page',
        'data': {
            'email_or_username': 'username',
            'password': 'password'
        }
    });
});

router.post('/login', function(req, res) {
    console.log(req.body);

    if (req.body.email_or_username && req.body.password) {

        let email;
        let username;
        let user_info = req.body.email_or_username.trim().toLowerCase();

        // Check if email or username
        console.log(validator.isEmail(user_info));

        if (validator.isEmail(user_info)) {
            email = user_info;
            console.log('assigned as email');
        } else {
            username = user_info;
            console.log('assigned as username');
        }

        let password = req.body.password

        // ensure these values were actually sent
        // they're compulsory
        User.findOne({
            $or: [ // login with either 'username' or 'email'
                { email: email },
                { username: username }
            ]
        }, function(err, user) {
            if (err) throw err;
            if (!user) {
                res.status(401).json({
                    'state': false,
                    'msg': 'Authentication failed. We have no user with such credentials.'
                });
            } else if (user) {
                if (!user.comparePassword(password)) {
                    res.status(401).json({
                        'state': false,
                        'msg': 'Authentication failed. Wrong email or password. Try again.'
                    });
                } else {
                    user.last_login = Date.now();
                    user.save();
                    return res.json({
                        'state': true,
                        'user': {
                            'fullName': user.fullName,
                            'email': user.email,
                            'username': user.username
                        },
                        'token': "JWT" + " " + jwt.sign({
                            'email': user.email,
                            'username': user.username,
                            '_id': user._id
                        }, config.secret, { expiresIn: 60 * 60 * 24 })
                    })
                }
            }
        })
    } else {
        res.json({
            'state': false,
            'msg': "Missing credentials. Provide 'email' or 'username' AND 'password'"
        })
    }
});

Nothing interesting happening in the above snippet though. We’re logging in!

Now let’s test!

Testing, Shall we?

First of all, add something small to your package.jsonfile indicating a command to run the test.

{
  "name": "appname",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./bin/www",
    "test": "mocha --timeout 3000 --recursive"
  },
  "dependencies": {
   ...
  },
  "devDependencies": {
   ...
  }
}

With the above in our package.json file, we can simply do, npm test to trigger the running of the test. But we even have no test file yet.

Let’s do that.

process.env.NODE_ENV = 'test';

let mongoose = require('mongoose');
let User = require('../models/userModel.js');

let chai = require('chai');
let chaiHttp = require('chai-http');
let server = require('../bin/www');
let should = chai.should();
let expect = chai.expect;

chai.use(chaiHttp);

let login_details = {
  'email_or_username': 'email@email.com',
  'password': '123@abc'
}

let register_details = {
  'fullName': 'Rexford',
  'email': 'email@email.com',
  'username': 'username',
  'password': '123@abc'
};


/**
* Test the following in on scoop:
* - Create an account, login with details, and check if token comes
*/

describe('Create Account, Login and Check Token', () => {
  beforeEach((done) => {
    // Reset user mode before each test
    User.remove({}, (err) => {
      console.log(err);
      done();
    })
  });

  describe('/POST Register', () => {
    it('it should Register, Login, and check token', (done) => {
      chai.request(server)
        .post('/api/v1/auth/register')
        .send(register_details) // this is like sending $http.post or this.http.post in Angular
        .end((err, res) => { // when we get a response from the endpoint
          // in other words,
          // the res object should have a status of 201
          res.should.have.status(201);
          // the property, res.body.state, we expect it to be true.
          expect(res.body.state).to.be.true;

          // follow up with login
          chai.request(server)
            .post('/api/v1/auth/login')
            .send(login_details)
            .end((err, res) => {
              console.log('this was run the login part');
              res.should.have.status(200);
              expect(res.body.state).to.be.true;
              res.body.should.have.property('token'); 
              
              let token = res.body.token;
              // follow up with requesting user protected page
              chai.request(server)
                .get('/api/v1/account/user')
                // we set the auth header with our token
                .set('Authorization', token)
                .end((err, res) => {
                  res.should.have.status(200);
                  expect(res.body.state).to.be.true;
                  res.body.data.should.be.an('object');

                  done(); // Don't forget the done callback to indicate we're done!
                })
            })

        })
    })
  })
})

Now, go back and read through the code, isn’t it just beautiful, how writing tests with Chai mean we get to use common English expressions.

The steps involved in the above snippet is this:

  • To make request to protected pages, we need to have an auth token
  • To have an auth token, we need to login
  • To be able to log in, we need to have an account.
  • Therefore, we create an account, then login, then extracting the auth token, we make a request to the protected page/endpoint.
  • We signal the end of our tests with the done() callback. Indeed, we’re done!

Nesting your a request, yet in the .end( .. ) chained sections means we get access to the either err or res objects from the previous request. With that, the rest is history.

We get to manipulate our res object just like we do in usual Express. Nothing special.

Checkout the Chai docs to see more available expressions: http://devdocs.io/chai/api/bdd/index

When we run the above, if all goes well, we can see something like this

 

> khophiserver@0.0.0 test /home/khophi/Developments/Express/KhophiServer
> mocha --timeout 3000 --recursive

Using testDB
Running mode: test


Listening on: http://localhost:3011
  Create Account, Login and Check Token
    /POST Register
null
Sending no mail, in test mode
POST /api/v1/auth/register 201 118.103 ms - 97
true
assigned as email
POST /api/v1/auth/login 200 91.769 ms - 343
this was run the login part
GET /api/v1/account/user 200 6.253 ms - 366
      ✓ it should Register, Login, and check token (244ms)

  User Auth Approaches
    /GET auth login
null
GET /api/v1/auth/login 200 0.633 ms - 95
      ✓ it should display login page
    /POST auth login
null
POST /api/v1/auth/login 200 0.682 ms - 89
      ✓ it should throw error with invalid credentials
    /GET auth register
null
GET /api/v1/auth/register 200 0.450 ms - 132
      ✓ it should get register endpoint
    /POST auth register successfully
null
Sending no mail, in test mode
POST /api/v1/auth/register 201 79.321 ms - 97
      ✓ it should register new user (81ms)
    /POST auth register fail
null
POST /api/v1/auth/register 200 0.406 ms - 101
      ✓ it should fail to register a new user


  6 passing (371ms)

All our tests passed. Don’t say because I say it passed, it passed for you too. Play with the examples above. Create your own tests, as I have in my test results above.

You can decide to test different scenarios of your application, thus, if something doesn’t go right in future updates and code increments you can pick them up easily.

Writing test.should.be.fun and using Chai and Chai-http makes the experience easier.

Conclusion

Lemme know in the comments if you don’t understand any parts. Will be happy to help. Otherwise

I hope to write more tests for my upcoming Progressive Web App, which will live at khophi.com. It is from the codebase of the project which the above snippets were taken from.

Hope to you see you in the next one.

 

Related Articles

Back to top button