Codementor Events

How to build REST APIs with TDD and Adonis JS

Published Feb 15, 2019Last updated Aug 13, 2019

Building REST APIs with Nodejs is extremely amazing. Using a test driven development approach to develop these APIs would greatly improve your code quality, and most importantly the confidence you have in the API.
In this tutorial, we are going to learn how to build robust REST APIs with the awesome AdonisJs framework, following test driven development practices.

For a practice project, we'll build a REST API for a JWT authentication ssytem. Users can register and login to the application to generate JWT.

Prerequisites

To follow along, you would need to have the Adonis CLI installed on your local computer. If you do not have it installed, you can run the command npm i -g @adonisjs/cli to install it.

First, we'll generate a brand new Adonis project using the CLI. Run the following command:
adonis new accounts-manager --api-onlyOnce the project is generated, the next step is to install some dependencies we need for our database connection and for testing. Run the following command to install Adonis vow, which is the in built Adonis testing package, and sqlite3 for our database connection to run our tests.

adonis install @adonisjs/vow sqlite3

‌Once Adonis Vow is installed, it will add a vowfile.js to the project root. In this file, remove the comments from the following lines of code:

const ace = require('@adonisjs/ace')

await ace.call('migration:run', {}, { silent: true })

await ace.call('migration:reset', {}, { silent: true })

These lines off code tells the test runner to run migrations before running tests, and resets them after all tests have been completed.
Running the tests now with the adonis test command should pass.

Step 2 - Writing our first test

The first test we'll be writing is for the registration endpoint. A user should be able to register with a new account. Generate a new functional test with the CLI using the following command:

adonis make:test RegisterUser

We'll add our first test in here, which outlines the registration process for a new user.

'use strict'
const Factory =  use('Factory')
const User =  use('App/Models/User')
const  { test, trait }  =  use('Test/Suite')('Register User')

trait('Test/ApiClient')

test('registers a new user and generates a jwt',  async  ({ assert, client })  =>  {
  // generate a fake user
  const  { username, email, password }  =  await Factory.model('App/Models/User').make()
  // make api request to register a new user
  const response =  await client.post('/api/register').send({
    username,
    email,
    password
  }).end()

  // expect the status code to be 200
  response.assertStatus(200)
  // assert the email and username are in the response body
  response.assertJSONSubset({
    user: {
      email,
      username
    }
  })
  // assert the token was in request
  assert.isDefined(response.body.token)
  // assert the user was actually saved in the database
  await User.query().where({ email }).firstOrFail()
})

The assertions we write are very important, to make sure the feature we are testing for is correctly implemented. In this case, we are making sure the response is a successful one, token is defined in the response, and most importantly, we run a query at the end of our test to make sure the user is actually saved to the database. We don't use an assertion, but we use the firstOrFail function, and this function will throw an error if the user with that email is not found, causing the test to fail. Running the test suite right now gives the following result:

Register User Test Fail
Our test fails, of course. What's important to us is the failure message, and this message is the guide for our next step. We received a 404 which means the endpoint we are trying to access does not exist yet. To fix this error, let's register this route in the application routes file.

// register a route for registration
Route.group(()  =>  {
  Route.post('register',  'RegisterController.store')
}).prefix('api')

While we're at it, let's generate the controller for this route and create the store method.

adonis make:controller RegisterController

Add the store method to the controller class:

'use strict'

class  RegisterController  {
  async  store  ()  {}
}

module.exports  =  RegisterController

Running our tests at this point gives the following output.

Register user test output 2

This means our server is responding with a 204 status code, which is not what we asserted for. A 204 means the server responded with no content. We need to fix this by actually implementing the functionality we desire:

'use strict'

const User = use('App/Models/User')

class RegisterController {
  async store({ auth, request, response }) {
    // get the user data from the request
    const { username, email, password } = request.all()
    const user = await User.create({ username, email, password })
    // generate the jwt for the user
    const token = await auth.generate(user)
    return response.ok({ user, token })
  }
}

module.exports = RegisterController

Running our tests now should pass.

Preventing evergreen tests

When writing tests for your application, it's always good to make sure that your tests are not evergreen, which means tests that do not fail ever. These types of tests could arise because the assertions are not running, or we are asserting against the wrong thing. To make sure your tests are not evergreen, change an assertion and see if it fails. For the current test, I'll change the following line of code:

// before
response.assertStatus(200) // after
response.assertStatus(403) // before
assert.isDefined(response.body.token) // after
assert.isUndefined(response.body.token)

I run my tests and watch them fail. If they don't, then there's a problem. After I confirm that my assertions are actually asserting, then I revert my tests back to their original state, run tests again, and get back to green.

Step 3 - Testing for duplicate emails

Let's add a test to make sure our application responds with the appropriate error message if the email has already been taken. Add the following test to the test/functional/register-user.spec.js file:

test('returns an error if user already exists', async ({ assert, client }) => {
  // create a new user
  const { username, email, password } = await Factory.model('App/Models/User').create()
  const response = await client.post('/api/register').send({ username, email, password }).end()
  // assert the status code is 422
  response.assertStatus(422)
  // get the errors from the response
  const { errors } = response.body
  // assert the error for taken email was returned
  assert.equal(errors[0].message, 'The email has already been taken.')
})

Running our tests now gives us a 500 server error.

Now this is not very helpful, because a 500 could be anything. When practicing TDD, our next step is decided most of the time by the error we get when we run our test, but how do we proceed when we do not know what the error is? To ffind out what error is coming from our server, let's modify the error handler in AdonisJs to print the error to the console for us to see. First we need to generate the error handler by running:

adonis make:ehandler

This generates a class called ExceptionHandler in the app/Exceptions/Handler.js file. The handle method in this class handles all errors coming from our application.
Modify it like this:

...
async handle (error, { request, response }) {
  console.log(error)
  response.status(error.status).send(error.message)
}
...

Now, whenever an error occurs on our server, it will be printed on the console before it is rendered as a response.

Running our test now gives us a clear error we can work with:

{ [Error: SQLITE_CONSTRAINT: UNIQUE constraint failed: users.email] errno: 19, code: 'SQLITE_CONSTRAINT', status: 500 }

Our database already sets the email as unique, and the database throws an error if the has already registered.
We'll use the @adonisjs/validator package, to easily validate data before saving to our database. First, install the validator:

adonis install @adonisjs/validator

After registering the validator provider, let's implement the code for validating the email is unique. We'll modify the store method in the RegisterController:

async store ({ auth, request, response }) {
  // get the user data from request
  const { username, email, password } = request.all()
  // create a validator with validation rules
  const validation = await validate({ email }, {
    email: 'unique:users'
  }, { unique: 'The email has already been taken.' })
  // check if validation fails
  if(validation.fails()) { return response.status(422).json({ errors: validation.messages() }) }
  // create user
  const user = await User.create({ username, email, password })
  // create token for user
  const token = await auth.generate(user)
  // return response with newly created user and token
  return response.ok({ user, token })
}
}

This method creates a new validator, adds a rule to make sure the email is unique on the users table
and also adds a custom error to match what we asserted for in our test. Running our test at this moment should pass too.

Step 4 - Testing for required username

Let's add a test to make sure the username is required for registration.

test('returns an error if username is not provided', async ({ assert, client }) => {
  // make a post request to register a user without the username
  const response = await client.post('/api/register').send({ username: null, email: 'test@email.com', password: 'password' }).end()
  // assert the status code is 422
  response.assertStatus(422)
  // get the errors from the response body
  const { errors } = response.body
  // assert the error has one for required username
  assert.equal(errors[0].message, 'The username is required.')
})

Running this test now throws the following error:

{ [Error: SQLITE_CONSTRAINT: NOT NULL constraint failed: users.username] errno: 19, code: 'SQLITE_CONSTRAINT', status: 500 }

To prevent this error, we need to validate to make sure the username is required in the controller before actually saving the user to the database. Let's modify the validator like this:

// create a new validator
const validation = await validate({ username, email }, { email: 'unique:users', username: 'required'
}, { required: 'The {{ field }} is required.', unique: 'The email has already been taken.'
})

Running our tests now should pass.

Finally, let's add for the login functionality. When a user provides their email and password, a JWT should be generated for them and sent as a JSON response. Let's generate a functional test suite for the login functionality.

adonis make:test LoginUser

Then, let's add this test to the newly generated file:

'use strict'

const Factory =  use('Factory')
const User =  use('App/Models/User')
const  { test, trait }  =  use('Test/Suite')('Register User')

trait('Test/ApiClient')

test('a JWT is generated for a logged in user',  async  ({ assert, client })  =>  {
  // generate a fake user
  const  { username, email, password }  =  await Factory.model('App/Models/User').make()
  
  // save the fake user to the database
  await User.create({
    username, email, password
  })
  
  // make api request to login the user
  const response =  await client.post('api/login').send({
    email, password
  }).end()
  // assert the status is 200
  response.assertStatus(200)
  // assert the token is in the response
  assert.isDefined(response.body.token.type)
  assert.isDefined(response.body.token.token)
})

Running this test should fail, and since we modified our error handler, we have a clear error that we can work with:

HttpException: E_ROUTE_NOT_FOUND: Route not found POST /api/login

To fix this error, let's register this route, and create it's associated controller and method.
In the routes file, let's add the login route.

Route.group(() => {
  Route.post('login', 'LoginController.generate')
  Route.post('register', 'RegisterController.store')
}).prefix('api')

Next, we'll generate the controller:

adonis make:controller LoginController
'use strict'

class LoginController {
  async generate() {}
}
module.exports = LoginController

Running the tests now fails, so we have to implement the login functionality for the assertions to pass.

async generate ({ auth, request, response }) {
  // get user data from request
  const { email, password } = request.all()
  // attempt to login the user
  const token = await auth.attempt(email, password)
  return response.ok({ token })
}

Running the tests now are successful.

Conclusion

Hopefully you now have a better understanding of how to build rest APIs following a TDD approach. Here's a link to the source code for this tutorial.

Discover and read more posts from Frantz Vallie
get started
post commentsBe the first to share your opinion
Andrew Gupta
5 years ago

Way better article for understanding to build APIs

Show more replies