Plz yurn on JavaScript

Build a REST API with AdonisJs and TDD Part 1

January 02, 20199 minutes to read

  • #tutorial
  • #adonisjs
  • #tdd
  • #javascript
  • #testing

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.

1Writing 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.

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

1npm i -g @adonisjs/cli

To make sure everything work run the command in your terminal

1adonis --help

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

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

1adonis new movies_challenges --api-only

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

1cd movies_challenges

For running the project the command will be

1adonis serve --dev

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.

1npm i --save pg

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

1DB_CONNECTION=pg
2DB_HOST=127.0.0.1
3DB_PORT=5432
4DB_USER=postgres
5DB_PASSWORD=postgres
6DB_DATABASE=movies_challenges_dev

After I make sure I create the db from my terminal

1createdb movies_challenges_dev

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

1adonis install @adonisjs/vow

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.

1.env.testing
2vowfile.js
3example.spec.js

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

1DB_CONNECTION=pg
2DB_HOST=127.0.0.1
3DB_PORT=5432
4DB_USER=postgres
5DB_PASSWORD=postgres
6DB_DATABASE=movies_challenges_test

After I make sure I create the db from my terminal

1createdb movies_challenges_test

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

1const aceProviders = [
2 '@adonisjs/lucid/providers/MigrationsProvider',
3 '@adonisjs/vow/providers/VowProvider',
4]

Now we can run the command

1adonis make:test CreateChallenge

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

This will create a file

1test/functional/create-challenge.spec.js

Nice first test file create :)

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

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

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.

1test('can create a challenge if valid data', async ({ assert }) => {
2
3 const response = // do api call
4
5 response.assertStatus(201)
6 response.assertJSONSubset({
7 title: 'Top 5 2018 Movies to watch',
8 description: 'A list of 5 movies from 2018 to absolutely watched',
9 user_id: // to do
10 })
11})

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

1const { test, trait } = use('Test/Suite')('Create Challenge')
2
3trait('Test/ApiClient')
4
5test('can create a challenge if valid data', async ({ assert, client }) => {
6
7 const data = {
8 title: 'Top 5 2018 Movies to watch',
9 description: 'A list of 5 movies from 2018 to absolutely watched'
10 }
11
12 const response = await client.post('/api/challenges').send(data).end()
13
14 response.assertStatus(201)
15 response.assertJSONSubset({
16 title: data.title,
17 description: data.description,
18 user_id: // to do
19 })
20})

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

1'use strict'
2
3const Factory = use('Factory')
4const { test, trait } = use('Test/Suite')('Create Challenge')
5
6trait('Test/ApiClient')
7trait('Auth/Client')
8
9test('can create a challenge if valid data', async ({ assert, client }) => {
10 const user = await Factory.model('App/Models/User').create()
11
12 const data = {
13 title: 'Top 5 2018 Movies to watch',
14 description: 'A list of 5 movies from 2018 to absolutely watched',
15 }
16
17 const response = await client
18 .post('/api/challenges')
19 .loginVia(user, 'jwt')
20 .send(data)
21 .end()
22
23 response.assertStatus(201)
24 response.assertJSONSubset({
25 title: data.title,
26 description: data.description,
27 user_id: user.id,
28 })
29})

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

1const Factory = use('Factory')
2
3Factory.blueprint('App/Models/User', faker => {
4 return {
5 username: faker.username(),
6 email: faker.email(),
7 password: 'password123',
8 }
9})

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

1const user = await Factory.model('App/Models/User').create()

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

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

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

1trait('Auth/Client')

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

1response.assertJSONSubset({
2 title: data.title,
3 description: data.description,
4 user_id: user.id,
5})

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

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

Now we can run the test with the command adonis test

You will see the error

1error: relation "users" does not exist

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

1On line 14: const ace = require('@adonisjs/ace')
2On line 37: await ace.call('migration:run', {}, { silent: true })
3On line 60: await ace.call('migration:reset', {}, { silent: true })

Now if you rerun the test you will see

1error { Error: cannot POST /api/challenges (404)

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

1Route.post('/api/challenges', 'ChallengeController.store')

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

1Error: Cannot find module \'/Users/equimper/coding/tutorial/movies_challenges/app/Controllers/Http/ChallengeController\'

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

1adonis make:controller ChallengeController

When ask choose http not websocket

Rerun the test

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

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

1// app/Controllers/Http/ChallengeController.js
2class ChallengeController {
3 store() {}
4}

Rerun the test

1expected 204 to equal 201
2204 => 201

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

1class ChallengeController {
2 store({ response }) {
3 return response.created({})
4 }
5}

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

1expected {} to contain subset { Object (title, description, ...) }
2 {
3 + title: "Top 5 2018 Movies to watch"
4 + description: "A list of 5 movies from 2018 to absolutely watched"
5 + user_id: 1
6 }

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

1const Challenge = use('App/Models/Challenge')
2
3class ChallengeController {
4 async store({ response, request }) {
5 const challenge = await Challenge.create(
6 request.only(['title', 'description'])
7 )
8
9 return response.created(challenge)
10 }
11}

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

1'Error: Cannot find module \'/Users/equimper/coding/tutorial/movies_challenges/app/Models/Challenge\''

Fine make sense the model don't exist

1adonis make:model Challenge -m

The -m give you the migration file also

This command will created

1✔ create app/Models/Challenge.js
2✔ create database/migrations/1546449691298_challenge_schema.js

Now if we return the test

1'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'

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

So open your migration file for the challenge_schema

1class ChallengeSchema extends Schema {
2 up() {
3 this.create('challenges', table => {
4 table.text('description')
5 table.increments()
6 table.timestamps()
7 })
8 }
9
10 down() {
11 this.drop('challenges')
12 }
13}

Here I add a colum text call description

Rerun the test

1'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'

Now is the same error but for title

1class ChallengeSchema extends Schema {
2 up() {
3 this.create('challenges', table => {
4 table.string('title')
5 table.text('description')
6 table.increments()
7 table.timestamps()
8 })
9 }
10
11 down() {
12 this.drop('challenges')
13 }
14}

Here title will be a string. Now rerun the test

1expected { Object (title, description, ...) } to contain subset { Object (title, description, ...) }
2 {
3 - created_at: "2019-01-02 12:28:37"
4 - id: 1
5 - updated_at: "2019-01-02 12:28:37"
6 + user_id: 1
7 }

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

1class ChallengeSchema extends Schema {
2 up() {
3 this.create('challenges', table => {
4 table.string('title')
5 table.text('description')
6 table
7 .integer('user_id')
8 .unsigned()
9 .references('id')
10 .inTable('users')
11 table.increments()
12 table.timestamps()
13 })
14 }
15
16 down() {
17 this.drop('challenges')
18 }
19}

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

1class Challenge extends Model {
2 user() {
3 return this.belongsTo('App/Models/User')
4 }
5}

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

1challenges() {
2 return this.hasMany('App/Models/Challenge')
3}

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

Run the test

1expected { Object (title, description, ...) } to contain subset { Object (title, description, ...) }
2 {
3 - created_at: "2019-01-02 12:35:20"
4 - id: 1
5 - updated_at: "2019-01-02 12:35:20"
6 + user_id: 1
7 }

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

1class ChallengeController {
2 async store({ response, request, auth }) {
3 const user = await auth.getUser()
4
5 const challenge = await Challenge.create({
6 ...request.only(['title', 'description']),
7 user_id: user.id,
8 })
9
10 return response.created(challenge)
11 }
12}

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

1test('cannot create a challenge if not authenticated', async ({
2 assert,
3 client,
4}) => {})

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

1test('cannot create a challenge if not authenticated', async ({
2 assert,
3 client,
4}) => {
5 response.assertStatus(401)
6})

Here we want the status to be 401 unauthorized

1test('cannot create a challenge if not authenticated', async ({
2 assert,
3 client,
4}) => {
5 const data = {
6 title: 'Top 5 2018 Movies to watch',
7 description: 'A list of 5 movies from 2018 to absolutely watched',
8 }
9
10 const response = await client
11 .post('/api/challenges')
12 .send(data)
13 .end()
14
15 console.log('error', response.error)
16
17 response.assertStatus(401)
18})

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

Open your routes file

1Route.post('/api/challenges', 'ChallengeController.store').middleware(['auth'])

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

1adonis install @adonisjs/validator

Go to start/app.js and add the provider

1const providers = [
2 '@adonisjs/framework/providers/AppProvider',
3 '@adonisjs/auth/providers/AuthProvider',
4 '@adonisjs/bodyparser/providers/BodyParserProvider',
5 '@adonisjs/cors/providers/CorsProvider',
6 '@adonisjs/lucid/providers/LucidProvider',
7 '@adonisjs/validator/providers/ValidatorProvider',
8]

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

1test('cannot create a challenge if no title', async ({ assert }) => {})

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

1Factory.blueprint('App/Models/Challenge', faker => {
2 return {
3 title: faker.sentence(),
4 description: faker.sentence()
5 }
6});

Now we can use this with the help of make

1const { title, description } = await Factory.model(
2 'App/Models/Challenge'
3).make()

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

1test('cannot create a challenge if no title', async ({ assert, client }) => {
2 response.assertStatus(400)
3 response.assertJSONSubset([
4 {
5 message: 'title is required',
6 field: 'title',
7 validation: 'required',
8 },
9 ])
10})

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 :)

1test('cannot create a challenge if no title', async ({ assert, client }) => {
2 const user = await Factory.model('App/Models/User').create()
3 const { description } = await Factory.model('App/Models/Challenge').make()
4
5 const data = {
6 description,
7 }
8
9 const response = await client
10 .post('/api/challenges')
11 .loginVia(user, 'jwt')
12 .send(data)
13 .end()
14
15 response.assertStatus(400)
16 response.assertJSONSubset([
17 {
18 message: 'title is required',
19 field: 'title',
20 validation: 'required',
21 },
22 ])
23})

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

1expected 201 to equal 400
2 201 => 400

That mean the Challenge get create but shouldn't

So we need to add a validator for this

1adonis make:validator CreateChallenge

Go inside your routes file and we want to use this

1Route.post('/api/challenges', 'ChallengeController.store')
2 .validator('CreateChallenge')
3 .middleware(['auth'])

Now if you run the test you will see

1expected 201 to equal 400
2 201 => 400

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

1class CreateChallenge {
2 get rules() {
3 return {
4 title: 'required|string',
5 description: 'string',
6 }
7 }
8
9 get messages() {
10 return {
11 required: '{{ field }} is required',
12 string: '{{ field }} is not a valid string',
13 }
14 }
15
16 get validateAll() {
17 return true
18 }
19
20 async fails(errorMessages) {
21 return this.ctx.response.status(400).json(errorMessages)
22 }
23}

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

1table.string('title').notNullable()

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

1test('cannot create a challenge if title and description are not a string', async ({
2 assert,
3 client,
4}) => {
5 const user = await Factory.model('App/Models/User').create()
6
7 const data = {
8 title: 123,
9 description: 123,
10 }
11
12 const response = await client
13 .post('/api/challenges')
14 .loginVia(user, 'jwt')
15 .send(data)
16 .end()
17
18 response.assertStatus(400)
19 response.assertJSONSubset([
20 {
21 message: 'title is not a valid string',
22 field: 'title',
23 validation: 'string',
24 },
25 {
26 message: 'description is not a valid string',
27 field: 'description',
28 validation: 'string',
29 },
30 ])
31})

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

Previous

Attempting to learn Dart and Flutter: Part 1

Next

Setup CORS in Firebase Functions

Join my Newsletter

Subscribe to get my latest content & deal for my incoming courses.

    I respect your privacy. Unsubscribe at any time.

    Powered By ConvertKit

    Comments