DEV Community

Emanuel Quimper
Emanuel Quimper

Posted on • Edited on • Originally published at equimper.com

Build a REST API with AdonisJs and TDD Part 1

Hello dev.to :) My first post here :)

I've been playing lately with AdonisJs a NodeJS MVC framework who look a lot like Laravel a really popular PHP framework. I really started to love the Adonis approach, more convention than configuration. I also love the fact they say in the headline.

Writing micro-services or you are a fan of TDD, it all boils down to confidence. AdonisJs simplicity will make you feel confident about your code.
Enter fullscreen mode Exit fullscreen mode

In the past few month, I wrote all my backend project with the TDD pattern, and I really feel this help me getting more productive, and more confident with my code. I know TDD is not perfect, can slow you down, when you start, but I really think this can improved your code in the long term.

About this tutorial

So in this tutorial we gonna build kind of a bucket list for movies to watch. A user can create a challenge, and put movies to this one. I know, this is not the most awesome project ever, but this will help you see how Lucid, the Adonis ORM work with relationship. We gonna also see how easy this framework will make our live.

At the end of this tutorial, we gonna create a service where a user can finally enter just the name of the movie and the year. Us we will use TheMovieDB Api and find info about this movie.

Getting Started

First we need to install the Adonis cli

npm i -g @adonisjs/cli
Enter fullscreen mode Exit fullscreen mode

To make sure everything work run the command in your terminal

adonis --help
Enter fullscreen mode Exit fullscreen mode

If you see a list of command that mean this is working :)

For creating the project we will run this command in the terminal

adonis new movies_challenges --api-only
Enter fullscreen mode Exit fullscreen mode

Here this will create a new project call movies_challenges and this will be an api only boilerplate, so no ui with this.

Follow the instructions

cd movies_challenges
Enter fullscreen mode Exit fullscreen mode

For running the project the command will be

adonis serve --dev
Enter fullscreen mode Exit fullscreen mode

But for us we don't really need cause all the interaction will be done from the testing.

Open the project in your text-editor of choice. For myself I use VSCode it's free and awesome.

Setup the db

Adonis have setup lot of stuff for us. But they let us choosing some stuff like which db to use etc. If you open the file config/database.js you will see sqlite, mysql and postgresql config. For this project I will be using Posgresql

To make it work we need to follow the instruction they provide at the bottom of this file.

npm i --save pg
Enter fullscreen mode Exit fullscreen mode

After this go inside your .env file and setup the connection for your db. For me this will look like

DB_CONNECTION=pg
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_DATABASE=movies_challenges_dev
Enter fullscreen mode Exit fullscreen mode

After I make sure I create the db from my terminal

createdb movies_challenges_dev
Enter fullscreen mode Exit fullscreen mode

Setup the testing environnment

Adonis don't came with a testing framework out-of-the-box, but it's really easy to make it work.

Run the command

adonis install @adonisjs/vow
Enter fullscreen mode Exit fullscreen mode

What is that ? Adonis have a way to install dependency by using npm internally. But the beauty of this it's they can add other stuff also. Like if you look what happen after this is done, they will open a a url in your browser with other instructions.

They have create 3 new files.

.env.testing
vowfile.js
example.spec.js
Enter fullscreen mode Exit fullscreen mode

First we will setup the .env.testing file to make sure we it a test db and not the dev one.

Append that to the end of the file

DB_CONNECTION=pg
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_DATABASE=movies_challenges_test
Enter fullscreen mode Exit fullscreen mode

After I make sure I create the db from my terminal

createdb movies_challenges_test
Enter fullscreen mode Exit fullscreen mode

Writing your first test

So the way the app will work is a User can have many Challenges. Those challenge can have many movie to it. But movie can be to many challenge.

So in relationship this will look like

If you have check a bit the folder structure you will see Adonis give use User model and Auth of the box.

We will use this in the future.

So for making your first test file we will need to think about what we need to do.

The first thing I want to test is the fact a user can create a challenge. A challenge need to have a title, and a description is optionnal. I want to make sure only a authenticate user can create a challenge. When a challenge is create I need to put the current_user id to the data. So we will know who is the owner.

Adonis give us lot of tool to make our live easier. One of them is generator command thank to ace. We will use a command to make our first test. But to be able to do this we need to register the vow test framework to the provider of the project. Open start/app.js and add this to your aceProvider

const aceProviders = [
  '@adonisjs/lucid/providers/MigrationsProvider',
  '@adonisjs/vow/providers/VowProvider',
]
Enter fullscreen mode Exit fullscreen mode

Now we can run the command

adonis make:test CreateChallenge
Enter fullscreen mode Exit fullscreen mode

When you get ask unit or functionnal test use functionnal and click enter.

This will create a file

test/functional/create-challenge.spec.js
Enter fullscreen mode Exit fullscreen mode

Nice first test file create :)

We will change the title of this test to be more useful.

test('can create a challenge if valid data', async ({ assert }) => {})
Enter fullscreen mode Exit fullscreen mode

Now the way I wrote test is by creating the assertion first. After I then go backward and create the step I need to make it work.

test('can create a challenge if valid data', async ({ assert }) => {

  const response = // do api call

  response.assertStatus(201)
  response.assertJSONSubset({
    title: 'Top 5 2018 Movies to watch',
    description: 'A list of 5 movies from 2018 to absolutely watched',
    user_id: // to do
  })
})
Enter fullscreen mode Exit fullscreen mode

Here I test than I want to receive back from my api call a 201 created with a certain object who will have the title a provide, the description I provide, and my current user id.

Next we need to write the code for the response

const { test, trait } = use('Test/Suite')('Create Challenge')

trait('Test/ApiClient')

test('can create a challenge if valid data', async ({ assert, client }) => {

  const data = {
    title: 'Top 5 2018 Movies to watch',
    description: 'A list of 5 movies from 2018 to absolutely watched'
  }

  const response = await client.post('/api/challenges').send(data).end()

  response.assertStatus(201)
  response.assertJSONSubset({
    title: data.title,
    description: data.description,
    user_id: // to do
  })
})
Enter fullscreen mode Exit fullscreen mode

To make a api call we need to import first trait from the test suite. We need to told the test we want the api client. This will give us now access to client in the callback. I then put my data I want to an object and send it to a route with the verb POST.

Now I want to test with a current user jwt in the headers. How can we do this ? This is so easy with Adonis

'use strict'

const Factory = use('Factory')
const { test, trait } = use('Test/Suite')('Create Challenge')

trait('Test/ApiClient')
trait('Auth/Client')

test('can create a challenge if valid data', async ({ assert, client }) => {
  const user = await Factory.model('App/Models/User').create()

  const data = {
    title: 'Top 5 2018 Movies to watch',
    description: 'A list of 5 movies from 2018 to absolutely watched',
  }

  const response = await client
    .post('/api/challenges')
    .loginVia(user, 'jwt')
    .send(data)
    .end()

  response.assertStatus(201)
  response.assertJSONSubset({
    title: data.title,
    description: data.description,
    user_id: user.id,
  })
})
Enter fullscreen mode Exit fullscreen mode

OMG !!! Too much. DONT WORRY. We just need to break it down a bit. So first what is Factory. Factory is a way to make dummy data easier. This come with a really nice api. Here the Factory will create a user to the db. But how can the factory know the data we want ? Easy just open the database/factory.js file and add this at the bottom

const Factory = use('Factory')

Factory.blueprint('App/Models/User', faker => {
  return {
    username: faker.username(),
    email: faker.email(),
    password: 'password123',
  }
})
Enter fullscreen mode Exit fullscreen mode

Here we create a Factory for the Models user we have in the db. This use faker also who is a library who make dummy data so much easier. Here I put a fake username and email. But why I don't do this to password ? It's because when I will need to test login I want to be able to log, and because the password will become hash I need to know what is the original version.

So this line

const user = await Factory.model('App/Models/User').create()
Enter fullscreen mode Exit fullscreen mode

We create a user to the db, now we can use this same user here in the request

const response = await client
  .post('/api/challenges')
  .loginVia(user, 'jwt')
  .send(data)
  .end()
Enter fullscreen mode Exit fullscreen mode

As you can see we can now use loginVia and pass the user at first argument, the second argument is the type of auth here I say jwt. I can use .loginVia cause of this trait at the top

trait('Auth/Client')
Enter fullscreen mode Exit fullscreen mode

Now in my json response I can now check the user id is really the one of the current user

response.assertJSONSubset({
  title: data.title,
  description: data.description,
  user_id: user.id,
})
Enter fullscreen mode Exit fullscreen mode

One think we need to do before going further and run the test is we need to see the error from the response to do a real tdd.

So we will add this line before the assertion

console.log('error', response.error)
Enter fullscreen mode Exit fullscreen mode

Now we can run the test with the command adonis test

You will see the error

error: relation "users" does not exist
Enter fullscreen mode Exit fullscreen mode

What that mean ? It's because Vow by default don't run migration. But us a developer we don't want to run it manually on every test that will be painful. What can we do ? Adonis make again our live easy. Go in the file vowfile.js and uncomment the code already wrote for this

On line 14: const ace = require('@adonisjs/ace')
On line 37: await ace.call('migration:run', {}, { silent: true })
On line 60: await ace.call('migration:reset', {}, { silent: true })
Enter fullscreen mode Exit fullscreen mode

Now if you rerun the test you will see

error { Error: cannot POST /api/challenges (404)
Enter fullscreen mode Exit fullscreen mode

Nice one step further :) This error mean we don't have a route. We need to create it. Open start/routes.js and add this code

Route.post('/api/challenges', 'ChallengeController.store')
Enter fullscreen mode Exit fullscreen mode

Here I say, when we get a post request to the route /api/challenges pass the data to the controller ChallengeController and the methods store. Remember Adonis is MVC so yes we need controller :)

Save the code and rerun the test

Now in the text of the error you will see

Error: Cannot find module \'/Users/equimper/coding/tutorial/movies_challenges/app/Controllers/Http/ChallengeController\'
Enter fullscreen mode Exit fullscreen mode

This mean the controller don't exist :) So we need to create one. Again adonis have a generator for this

adonis make:controller ChallengeController
Enter fullscreen mode Exit fullscreen mode

When ask choose http not websocket

Rerun the test

'RuntimeException: E_UNDEFINED_METHOD: Method store missing on App/Controllers/Http/ChallengeController\n> More details: https://err.sh/adonisjs/errors/E_UNDEFINED_METHOD'
Enter fullscreen mode Exit fullscreen mode

Method store is missing. Fine this is normal the controller is empty. Add this to your file

// app/Controllers/Http/ChallengeController.js
class ChallengeController {
  store() {}
}
Enter fullscreen mode Exit fullscreen mode

Rerun the test

expected 204 to equal 201
204 => 201
Enter fullscreen mode Exit fullscreen mode

So now this is where the fun start, we expected 201 but received 204. We can fix this error by adding

class ChallengeController {
  store({ response }) {
    return response.created({})
  }
}
Enter fullscreen mode Exit fullscreen mode

Adonis give us the response object who can be destructuring from the arguments of the method. Here I want to return 201 who mean created so I can use the created function. I pass an empty object so I can see my test failing further

 expected {} to contain subset { Object (title, description, ...) }
  {
  + title: "Top 5 2018 Movies to watch"
  + description: "A list of 5 movies from 2018 to absolutely watched"
  + user_id: 1
  }
Enter fullscreen mode Exit fullscreen mode

Here the error mean we send nothing but expected stuff. Now time to do the logic.

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

class ChallengeController {
  async store({ response, request }) {
    const challenge = await Challenge.create(
      request.only(['title', 'description'])
    )

    return response.created(challenge)
  }
}
Enter fullscreen mode Exit fullscreen mode

I add an import at the top, this is my challenge model I plan to created in future test. Now I can make use of async and also the request object to create a challenge. The only method info can be see here.

Now if I rerun the test I see

'Error: Cannot find module \'/Users/equimper/coding/tutorial/movies_challenges/app/Models/Challenge\''
Enter fullscreen mode Exit fullscreen mode

Fine make sense the model don't exist

adonis make:model Challenge -m
Enter fullscreen mode Exit fullscreen mode

The -m give you the migration file also

This command will created

✔ create  app/Models/Challenge.js
✔ create  database/migrations/1546449691298_challenge_schema.js
Enter fullscreen mode Exit fullscreen mode

Now if we return the test

'error: insert into "challenges" ("created_at", "description", "title", "updated_at") values ($1, $2, $3, $4) returning "id" - column "description" of relation "challenges" does not exist'
Enter fullscreen mode Exit fullscreen mode

Make sense the table don't have a column description. So we should add one

So open your migration file for the challenge_schema

class ChallengeSchema extends Schema {
  up() {
    this.create('challenges', table => {
      table.text('description')
      table.increments()
      table.timestamps()
    })
  }

  down() {
    this.drop('challenges')
  }
}
Enter fullscreen mode Exit fullscreen mode

Here I add a colum text call description

Rerun the test

'error: insert into "challenges" ("created_at", "description", "title", "updated_at") values ($1, $2, $3, $4) returning "id" - column "title" of relation "challenges" does not exist'
Enter fullscreen mode Exit fullscreen mode

Now is the same error but for title

class ChallengeSchema extends Schema {
  up() {
    this.create('challenges', table => {
      table.string('title')
      table.text('description')
      table.increments()
      table.timestamps()
    })
  }

  down() {
    this.drop('challenges')
  }
}
Enter fullscreen mode Exit fullscreen mode

Here title will be a string. Now rerun the test

  expected { Object (title, description, ...) } to contain subset { Object (title, description, ...) }
  {
  - created_at: "2019-01-02 12:28:37"
  - id: 1
  - updated_at: "2019-01-02 12:28:37"
  + user_id: 1
  }
Enter fullscreen mode Exit fullscreen mode

The error mean the title and description are save, but the user_id don't exist, so we need to add the relation in the migration and the model

Again in the migration file add

class ChallengeSchema extends Schema {
  up() {
    this.create('challenges', table => {
      table.string('title')
      table.text('description')
      table
        .integer('user_id')
        .unsigned()
        .references('id')
        .inTable('users')
      table.increments()
      table.timestamps()
    })
  }

  down() {
    this.drop('challenges')
  }
}
Enter fullscreen mode Exit fullscreen mode

Here the user_id is a integer, reference the id of a user in the users table

Now open the Challenge model in app/Models/Challenge.js and add this code

class Challenge extends Model {
  user() {
    return this.belongsTo('App/Models/User')
  }
}
Enter fullscreen mode Exit fullscreen mode

And we need to do the other way of relation so open app/Models/User.js and add at the bottom after tokens

challenges() {
  return this.hasMany('App/Models/Challenge')
}
Enter fullscreen mode Exit fullscreen mode

Wow I love this syntax and how easy we can see the relations. Thank to Adonis team and Lucid ORM :)

Run the test

 expected { Object (title, description, ...) } to contain subset { Object (title, description, ...) }
  {
  - created_at: "2019-01-02 12:35:20"
  - id: 1
  - updated_at: "2019-01-02 12:35:20"
  + user_id: 1
  }
Enter fullscreen mode Exit fullscreen mode

Same error ? Yes when we create we didn't put the user_id. So we need to

class ChallengeController {
  async store({ response, request, auth }) {
    const user = await auth.getUser()

    const challenge = await Challenge.create({
      ...request.only(['title', 'description']),
      user_id: user.id,
    })

    return response.created(challenge)
  }
}
Enter fullscreen mode Exit fullscreen mode

Here I make use of auth, who is a object we method touching the authentication. Here I can use the current user with the function auth.getUser. This will return the user from the jwt. Now I can then merge this to the object when create.

Now if you run your test all should work. BUTTTTT this is not done. We need a test to make sure the user is really authenticate, cause now this endpoint is accessible by everyone.

Add to our test file

test('cannot create a challenge if not authenticated', async ({
  assert,
  client,
}) => {})
Enter fullscreen mode Exit fullscreen mode

Again we gonna work with the same idea, building the assertion first and going backward

test('cannot create a challenge if not authenticated', async ({
  assert,
  client,
}) => {
  response.assertStatus(401)
})
Enter fullscreen mode Exit fullscreen mode

Here we want the status to be 401 unauthorized

test('cannot create a challenge if not authenticated', async ({
  assert,
  client,
}) => {
  const data = {
    title: 'Top 5 2018 Movies to watch',
    description: 'A list of 5 movies from 2018 to absolutely watched',
  }

  const response = await client
    .post('/api/challenges')
    .send(data)
    .end()

  console.log('error', response.error)

  response.assertStatus(401)
})
Enter fullscreen mode Exit fullscreen mode

First make sure to delete the console.log from the other test. Now your test should look like that here.

Open your routes file

Route.post('/api/challenges', 'ChallengeController.store').middleware(['auth'])
Enter fullscreen mode Exit fullscreen mode

If you run the test all will be green :)

But now I will like to test the fact then title is required and both description and title need to be a string how can I do this ?

Adonis give us access to another really nice tool can validator.

We need to install the validator library

adonis install @adonisjs/validator
Enter fullscreen mode Exit fullscreen mode

Go to start/app.js and add the provider

const providers = [
  '@adonisjs/framework/providers/AppProvider',
  '@adonisjs/auth/providers/AuthProvider',
  '@adonisjs/bodyparser/providers/BodyParserProvider',
  '@adonisjs/cors/providers/CorsProvider',
  '@adonisjs/lucid/providers/LucidProvider',
  '@adonisjs/validator/providers/ValidatorProvider',
]
Enter fullscreen mode Exit fullscreen mode

Now go back to our test file for challenge and add a new one

test('cannot create a challenge if no title', async ({ assert }) => {})
Enter fullscreen mode Exit fullscreen mode

Before going further, I don't like the fact I need to manually wrote the title and description. I would like to be able to make the factory create it for us. This is possible, first go to database/factory.js

We need to create a Factory for the Challenge

Factory.blueprint('App/Models/Challenge', faker => {
  return {
    title: faker.sentence(),
    description: faker.sentence()
  }
});
Enter fullscreen mode Exit fullscreen mode

Now we can use this with the help of make

const { title, description } = await Factory.model(
  'App/Models/Challenge'
).make()
Enter fullscreen mode Exit fullscreen mode

This will give us a fake title and description but without being save to the db.

Going back to the test will would like to receive error if the title is not in the body

test('cannot create a challenge if no title', async ({ assert, client }) => {
  response.assertStatus(400)
  response.assertJSONSubset([
    {
      message: 'title is required',
      field: 'title',
      validation: 'required',
    },
  ])
})
Enter fullscreen mode Exit fullscreen mode

Now we need to write the code to get to this. I will skip some process, but hey continue it, this is how we get better. I will just not wrote it cause that take lot and lot of line :)

test('cannot create a challenge if no title', async ({ assert, client }) => {
  const user = await Factory.model('App/Models/User').create()
  const { description } = await Factory.model('App/Models/Challenge').make()

  const data = {
    description,
  }

  const response = await client
    .post('/api/challenges')
    .loginVia(user, 'jwt')
    .send(data)
    .end()

  response.assertStatus(400)
  response.assertJSONSubset([
    {
      message: 'title is required',
      field: 'title',
      validation: 'required',
    },
  ])
})
Enter fullscreen mode Exit fullscreen mode

First we create a user to be able to log, cause we need to be authenticated remember :)

Second I get a fake description from my factory. I just send this one.

I assert I receive a 400 for bad request and a json array of error message.

If I run the test now I receive

expected 201 to equal 400
  201 => 400
Enter fullscreen mode Exit fullscreen mode

That mean the Challenge get create but shouldn't

So we need to add a validator for this

adonis make:validator CreateChallenge
Enter fullscreen mode Exit fullscreen mode

Go inside your routes file and we want to use this

Route.post('/api/challenges', 'ChallengeController.store')
  .validator('CreateChallenge')
  .middleware(['auth'])
Enter fullscreen mode Exit fullscreen mode

Now if you run the test you will see

expected 201 to equal 400
  201 => 400
Enter fullscreen mode Exit fullscreen mode

Make sense the validator break stuff. Time to wrote some code. Open app/Validators/CreateChallenge.js

class CreateChallenge {
  get rules() {
    return {
      title: 'required|string',
      description: 'string',
    }
  }

  get messages() {
    return {
      required: '{{ field }} is required',
      string: '{{ field }} is not a valid string',
    }
  }

  get validateAll() {
    return true
  }

  async fails(errorMessages) {
    return this.ctx.response.status(400).json(errorMessages)
  }
}
Enter fullscreen mode Exit fullscreen mode

Here I add some rules, messages, and I also show the fails with a status 400 for bad request. I also put the validateAll to make sure I validate all stuff, not just one by one.

If you run the test now all should work :)

We can also add the notNullable field to the title column in the migrations

table.string('title').notNullable()
Enter fullscreen mode Exit fullscreen mode

A last test can be create to test both description and title need to be a string.

test('cannot create a challenge if title and description are not a string', async ({
  assert,
  client,
}) => {
  const user = await Factory.model('App/Models/User').create()

  const data = {
    title: 123,
    description: 123,
  }

  const response = await client
    .post('/api/challenges')
    .loginVia(user, 'jwt')
    .send(data)
    .end()

  response.assertStatus(400)
  response.assertJSONSubset([
    {
      message: 'title is not a valid string',
      field: 'title',
      validation: 'string',
    },
    {
      message: 'description is not a valid string',
      field: 'description',
      validation: 'string',
    },
  ])
})
Enter fullscreen mode Exit fullscreen mode

And if we run again the test BOOM all green.


End word

I hope you enjoy the part 1 of this tutorial. Don't forget to subscribe to get notifications when I will post the part 2.

If you find any typo, or your want to let me know something about this project, don't hesitate to let a comment below :)

The code can be find here on github

This is a cross-platform post from my blog. You can read the original here: https://equimper.com/blog/build-a-rest-api-with-adonisjs-and-tdd-part-1

Top comments (0)