Testing Authenticated Routes in AdonisJS

August 11, 2021

If you haven't created an AdonisJS 5.0 app yet, you can check out my previous post or follow the docs here.

We'll be testing authenticated routes, so if you haven't added authentication to your AdonisJS project, take a look at Add Authentication to Your AdonisJS Project. For some background on the libraries used, check out this post Aman Virk wrote.

Set Up the Test Runner

So it's time to add tests to your brand new AdonisJS project, but what to do? AdonisJS doesn't come with a test-runner out-of-the-box at the moment. Well, for the most part, it's fairly simple if you just follow these simple steps.

First, install the dependencies:

# npm
npm i -D japa execa get-port supertest @types/supertest jsdom @types/jsdom

# yarn
yarn add -D japa execa get-port supertest @types/supertest jsdom @types/jsdom

Now, just copy japaFile.ts from the article here. We'll need to interact with the database so just copy it verbatim and place it at the base directory of the project:

import { HttpServer } from "@adonisjs/core/build/src/Ignitor/HttpServer";
import execa from "execa";
import getPort from "get-port";
import { configure } from "japa";
import { join } from "path";
import "reflect-metadata";
import sourceMapSupport from "source-map-support";

process.env.NODE_ENV = "testing";
process.env.ADONIS_ACE_CWD = join(__dirname);
sourceMapSupport.install({ handleUncaughtExceptions: false });

export let app: HttpServer;

async function runMigrations() {
  await execa.node("ace", ["migration:run"], {
    stdio: "inherit",
  });
}

async function rollbackMigrations() {
  await execa.node("ace", ["migration:rollback"], {
    stdio: "inherit",
  });
}

async function startHttpServer() {
  const { Ignitor } = await import("@adonisjs/core/build/src/Ignitor");
  process.env.PORT = String(await getPort());
  app = new Ignitor(__dirname).httpServer();
  await app.start();
}

async function stopHttpServer() {
  await app.close();
}

configure({
  files: ["test/**/*.spec.ts"],
  before: [runMigrations, startHttpServer],
  after: [stopHttpServer, rollbackMigrations],
});

To run the test, we'll create a test script in our package.json file:

{
  "scripts": {
    "test": "node -r @adonisjs/assembler/build/register japaFile.ts"
  }
}

When working locally, I like to have a different database for dev and testing. AdonisJS can read the .env.testing file when NODE_ENV=testing, which was set in the japaFile.ts file. The easiest thing to do is to copy the .env file and rename it to .env.testing. Then go and add _test to the end of the current database name you have for your dev environment.

...
PG_DB_NAME=todos_test

Since we configured our test runner to look in the test directory for any file with the .spec.ts extension, we can just place any file matching that pattern in the test directory, and we will run it with the npm test command.

Set Up the Authentication Secured Routes (To-dos)

As with any tutorial, we want to have a simple, but practical, example. Let's just use a Tt-do list app as an example. Let's go over what we want to do with our To-dos.

I want a user to be signed-in in order to create and/or update a todo. What good are todos if no one can see them? So let's allow anyone to look at the list of todos, as well as look at each individual todo. I don't think I want anyone to delete a todo, maybe just to change the status (Open, Completed, or Closed).

Let's leverage the generators to create the model, controller, and migration.

Let's make:migration

node ace make:migration todos

Let's add a name, a description, and a foreign key of user_id to our new table:

import BaseSchema from "@ioc:Adonis/Lucid/Schema";

export default class Todos extends BaseSchema {
  protected tableName = "todos";

  public async up() {
    this.schema.createTable(this.tableName, table => {
      table.increments("id");
      table.string("name").notNullable();
      table.text("description");

      table.integer("user_id").notNullable();

      /**
       * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL
       */
      table.timestamp("created_at", { useTz: true });
      table.timestamp("updated_at", { useTz: true });

      table.foreign("user_id").references("users_id");
    });
  }

  public async down() {
    this.schema.dropTable(this.tableName);
  }
}

Run the migration:

node ace migration:run

Let's make:model

node ace make:model Todo

We'll want to add the same 3 fields we added to our migration, but we'll also want to add a belongsTo relationship to our model linking the User through the creator property:

import { BaseModel, BelongsTo, belongsTo, column } from "@ioc:Adonis/Lucid/Orm";
import { DateTime } from "luxon";
import User from "App/Models/User";

export default class Todo extends BaseModel {
  @column({ isPrimary: true })
  public id: number;

  @column()
  public userId: number;

  @column()
  public name: string;

  @column()
  public description: string;

  @belongsTo(() => User)
  public creator: BelongsTo<typeof User>;

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime;

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime;
}

Add the corresponding hasMany relationship to the User model now:

...
import Todo from "App/Models/Todo";

export default class User extends BaseModel {
  ...
  @hasMany(() => Todo)
  public todos: HasMany<typeof Todo>;
  ...
}

Let's make:controller

node ace make:controller Todo

Now let's add our new /todos path to the routes.ts file:

...
Route.resource("todos", "TodosController").except(["destroy"]).middleware({
  create: "auth",
  edit: "auth",
  store: "auth",
  update: "auth",
});

Here, we want a RESTful resource, except destroy. I also want the request to run through the "auth" middleware for the create, edit, store, and update resources. Basically, anyone can view index and show, but anything else will require authentication.

We can see a list of our new routes with the node ace list:routes command. It's handy that it show which routes require authentication. It also lists the route names (handy for redirecting the linking).

┌────────────┬────────────────────────────────────┬────────────────────────────┬────────────┬────────────────────────┐
│ Method     │ Route                              │ Handler                    │ Middleware │ Name                   │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /                                  │ Closure                    │            │ home                   │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /login                             │ SessionsController.create  │            │ login                  │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ POST       │ /login                             │ SessionsController.store   │            │                        │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ POST       │ /logout                            │ SessionsController.destroy │            │                        │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /register                          │ UsersController.create     │            │                        │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ POST       │ /register                          │ UsersController.store      │            │                        │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /users/:id                         │ UsersController.show       │            │ users.show             │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /todos                             │ TodosController.index      │            │ todos.index            │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /todos/create                      │ TodosController.create     │ auth       │ todos.create           │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ POST       │ /todos                             │ TodosController.store      │ auth       │ todos.store            │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /todos/:id                         │ TodosController.show       │            │ todos.show             │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /todos/:id/edit                    │ TodosController.edit       │ auth       │ todos.edit             │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ PUT, PATCH │ /todos/:id                         │ TodosController.update     │ auth       │ todos.update           │
└────────────┴────────────────────────────────────┴────────────────────────────┴────────────┴────────────────────────┘

Back to Our Tests

Let's create a new test file called test/functional/todos.spec.ts. While I normally just start writing tests as I they come to my head, that's probably not idea. For just a high level overview, I know I'd like to test the To-do features. So far, it's just creating, saving, editing, and updating. Also, I'd want to make sure I test that anyone can access the index and show routes, but only an authenticated user can see the others.

Testing "To-dos"

  • Todo list shows up at the index route.
  • Individual todo shows up a the show route.
  • Create a todo and check the show route to see if it exists.
  • Edit a todo and check the show route to see if the data is updated.
  • Navigate to the create route without logging in to test if we get redirected to the sign-in page.
  • Navigate to the edit route without loggin in to test if we get redirected to the sign-in page.

This should cover it for now. As always, feel free to add more if you feel like it.

Write the tests

Testing the index Route

Anyone should be able to view the list of todos. A good question to ask is what should someone see if there are no todos to see (the null state). Well, there should at least be a link to the create route to create a new todo. If there are todos, we should show them.

First, let's start off testing for a page to load when we go to the index route, /todos. I have an inkling that I will massively refactor this later, but let's just start out simple. No point in premature optimization, expecially if it turns out we need less tests than we think.

import supertest from "supertest";
import test from "japa";

const baseUrl = `http://${process.env.HOST}:${process.env.PORT}`;

test.group("Todos", () => {
  test("'index' should show a link to create a new todo", async assert => {
    await supertest(baseUrl).get("/todos").expect(200);
  });
});

Here we use the supertest library to see if we get a status of 200 back when we navigate to /todos. After running the test with npm test, it looks like we forgot to even open up our controller file.

Missing method "index" on "TodosController"
...
  ✖ 'index' should show a link to create a new todo
    Error: expected 200 "OK", got 500 "Internal Server Error"

Let's go a create that index method and the Edge template that goes along with it:

import { HttpContextContract } from "@ioc:Adonis/Core/HttpContext";

export default class TodosController {
  public async index({ view }: HttpContextContract) {
    return await view.render("todos/index");
  }
}
node ace make:view todos/index
@layout('layouts/default')

@section('body')
<a href="{{ route('todos.create') }}">Create Todo</a>
@endsection

Looks like we're passing the tests after adding this little bit of code. Red-green-refactor FTW!

Let's add some more to our test. I want to test for that link.

  test("'index' should show a link to create a new todo", async assert => {
    const { text } = await supertest(baseUrl).get("/todos").expect(200);
    const { document } = new JSDOM(text).window;
    const createTodosLink = document.querySelector("#create-todo");

    assert.exists(createTodosLink);
  });

Here I want to query the document for an element with the create-todos id. Once I put the id on my "Create Todo" link, I should be green again.

<a href="{{ route('todos.create') }}" id="create-todo">Create Todo</a>

Now comes time to actually persist some Todos in the database and test to see if we can see them on /todos. Let's just simply create 2 new todos and test for their existence on the page.

  test("'index' should show all todos created", async assert => {
    const items = ["Have lunch", "Grocery shopping"];
    items.forEach(async name => await Todo.create({ name }));

    const { text } = await supertest(baseUrl).get("/todos");

    assert.include(text, items[0]);
    assert.include(text, items[1]);
  });

This looks simple enough. Let's create 2 Todos, "Have lunch" and "Grocery shopping". Once these are saved, I should be able to navigate to /todos and see both. Since we're doing red-green-refactor, let's run our tests first to get our "red" before we try to turn it "green" by implementing our solution.

"uncaughtException" detected. Process will shutdown
    error: insert into "todos" ("created_at", "name", "updated_at") values ($1, $2, $3) returning "id" - null value in column "user_id" of relation "todos" violates not-null constraint

Oops, looks like we forgot to add a user_id to our Todo. Let's create a user first, then add these Todos as "related" to the User.

  test("'index' should show all todos created", async assert => {
    const items = ["Have lunch", "Grocery shopping"];

    const user = await User.create({ email: "alice@email.com", password: "password" });
    await user.related("todos").createMany([{ name: items[0] }, { name: items[1] }]);
    
    const { text } = await supertest(baseUrl).get("/todos");

    assert.include(text, items[0]);
    assert.include(text, items[1]);
  });

Okay, now we're still not passing, but we don't have that knarly "uncaughtException" anymore. Now let's render out our list of todos. To do that, we'll need to query for the list of all todos in the controller, and then pass it to our view.

import Todo from "App/Models/Todo";

export default class TodosController {
  public async index({ view }: HttpContextContract) {

    const todos = await Todo.all();
    
    return await view.render("todos/index", { todos });
  }
}
@section('body')

<ul>
  @each(todo in todos)
  <li>{{ todo.name }}</li>
  @endeach
</ul>

<a href="{{ route('todos.create') }}" id="create-todo">Create Todo</a>
@endsection

Awesome. Back to "green".

Now let's work on the show route. We should be able to navigate there once the todo has been created.

test.group("Todos", () => {
  ...
  test("'show' should show the todo details", async assert => {
    const user = await User.create({ email: "alice@email.com", password: "password" });
    const todo = await user
      .related("todos")
      .create({ name: "Buy shoes", description: "Air Jordan 1" });
    const { text } = await supertest(baseUrl).get(`/todos/${todo.id}`);

    assert.include(text, todo.name);
    assert.include(text, todo.description);
  });
});

We're flying now. Our tests seem to have a lot of similar setup code. Possible refactor candidate. I'll note that for later.

export default class TodosController {
  ...
  public async show({ params, view }: HttpContextContract) {
    const id = params["id"];
    const todo = await Todo.findOrFail(id);
    return await view.render("todos/show", { todo });
  }
}

As with the index route, we'll need to create the view for our show route:

node ace make:view todos/show
@layout('layouts/default')

@section('body')
<h1>{{ todo.name }}</h1>
<p>{{ todo.description }}</p>
@endsection

Great, let's run the tests to see where we're at.

  ✖ 'show' should show the todo details
    error: insert into "users" ("created_at", "email", "password", "updated_at") values ($1, $2, $3, $4) returning "id" - duplicate key value violates unique constraint "users_email_unique"

Okay, you might have already thought, why's this guy creating another User with the same email? Well, what if I created this user in a test that's at the bottom of the file separated by hundreds of lines? What if the user was created for a test in another file? It would be really hard if we had to depend on some database state created who knows where.

Let's make sure we start each test, as if the database were brand new. Let's add some setup and teardown code:

test.group("Todos", group => {
  group.beforeEach(async () => {
    await Database.beginGlobalTransaction();
  });

  group.afterEach(async () => {
    await Database.rollbackGlobalTransaction();
  });
  ...
});

Alright! Back to green. So far, we've knocked off 2 tests from our "Testing todos" list we wrote before we started all the testing work.

Now it's time to tackle the create and update tests. Let's start it off like we started the others, with a test. Let's turn our "green" tests back to "red".

  test("'create' should 'store' a new `Todo` in the database", async assert => {
    const { text } = await supertest(baseUrl).get("/todos/create").expect(200);
    const { document } = new JSDOM(text).window;
    const createTodoForm = document.querySelector("#create-todo-form");

    assert.exists(createTodoForm);
  });
  ✖ 'create' should 'store' a new `Todo` in the database
    Error: expected 200 "OK", got 302 "Found"

Ahh, there we go. Our first issue with authentication. We need to be signed in to view this route, but how can we do that? After some Googling, looks like the supertest library has our solution. supertest allows you to access superagent, which will retain the session cookies between requests, so we'll just need to "register" a new user prior to visiting the store route.

  test("'create' should 'store' a new `Todo` in the database", async assert => {
    const agent = supertest.agent(baseUrl);
    await User.create({ email: "alice@email.com", password: "password" });
    await agent
      .post("/login")
      .field("email", "alice@email.com")
      .field("password", "password");
    const { text } = await agent.get("/todos/create").expect(200);
    const { document } = new JSDOM(text).window;
    const createTodoForm = document.querySelector("#create-todo-form");

    assert.exists(createTodoForm);
  });
export default class TodosController {
  ...
  public async create({ view }: HttpContextContract) {
    return await view.render("todos/create");
  }
}
node ace make:view todos/create
@layout('layouts/default')

@section('body')
<form action="{{ route('todos.store') }}" method="post" id="create-todo-form">
  <div>
    <label for="name"></label>
    <input type="text" name="name" id="name">
  </div>
  <div>
    <label for="description"></label>
    <textarea name="description" id="description" cols="30" rows="10"></textarea>
  </div>
</form>
@endsection

We really are flying now. By adding the form with the id of create-todo-form, we're passing our tests again. We've checked that the form is there, but does it work? That's the real question. And from the experience of signing the user in with supertest.agent, we know that we just need to post to the store route with fields of name and description.

  test("'create' should 'store' a new `Todo` in the database", async assert => {
    ...
    await agent
      .post("/todos")
      .field("name", "Clean room")
      .field("description", "It's filthy!");
    const todo = await Todo.findBy("name", "Clean room");
    assert.exists(todo);
  });

Okay, back to "red" with a missing store method on TodosController. By now, you don't even need to read the error message and you'll know what to do. But still, it's nice to run the tests at every step so you only work on the smallest bits to get your tests to turn back "green".

import Todo, { todoSchema } from "App/Models/Todo";
...
export default class TodosController {
  ...
  public async store({
    auth,
    request,
    response,
    session,
  }: HttpContextContract) {
    const { user } = auth;
    if (user) {
      const payload = await request.validate({ schema: todoSchema });
      const todo = await user.related("todos").create(payload);
      response.redirect().toRoute("todos.show", { id: todo.id });
    } else {
      session.flash({ warning: "Something went wrong." });
      response.redirect().toRoute("login");
    }
  }
}
import { schema } from "@ioc:Adonis/Core/Validator";
...
export const todoSchema = schema.create({
  name: schema.string({ trim: true }),
  description: schema.string(),
});

We're doing a little more with this one. First off, the signed in user is already exists in the context of the application and is accessible through the auth property. I created a schema called todoSchema which is used to validate the data passed from the form. This does 2 things that I don't have to worry about explicitly, if there are any errors, those errors will be available from flashMessages upon the next view render (which will be the create form). The resulting payload can be used directly to create the new Todo.

If, for some reason, I don't find the signed in user from auth, I can flash a warning message and redirect the user back to the login screen.

Now let's test our edit route. Since I had to sign for this test as well, I extracted that functionality to a helper function called loginUser. agent retains the session cookies and the User is returned to use to associate the newly created Todo. I update the name and description of the Todo then navigate to the show route and make sure the updated values exist on the page.

test.group("Todos", group => {
  ...
  test("'edit' should 'update' an existing `Todo` in the database", async assert => {
    const user = await loginUser(agent);
    const todo = await user.related("todos").create({
      name: "See dentist",
      description: "Root canal",
    });
    await agent.get(`/todos/${todo.id}/edit`).expect(200);
    await agent
      .put(`/todos/${todo.id}`)
      .field("name", "See movie")
      .field("name", "Horror flick!");
    const { text } = await agent.get(`/todos/${todo.id}`).expect(200);
    assert.include(text, "See movie");
    assert.include(text, "Horror flick!");
  });
});

async function loginUser(agent: supertest.SuperAgentTest) {
  const user = await User.create({
    email: "alice@email.com",
    password: "password",
  });
  await agent
    .post("/login")
    .field("email", "alice@email.com")
    .field("password", "password");
  return user;
}

As with the create test, the edit should show a form, but prepopulated with the current values. For now, let's just copy the todos/create view template for todos/edit. We'll need to update the values of the input and textarea elements with the current values.

export default class TodosController {
  ...
  public async edit({ params, view }: HttpContextContract) {
    const id = params["id"];
    const todo = Todo.findOrFail(id);
    return await view.render("todos/edit", { todo });
  }
}
node ace make:view todos/edit
@layout('layouts/default')

@section('body')
<form action="{{ route('todos.update', {id: todo.id}, {qs: {_method: 'put'}}) }}" method="post" id="edit-todo-form">
  <div>
    <label for="name"></label>
    <input type="text" name="name" id="name" value="{{ flashMessages.get('name') || todo.name }}">
  </div>
  <div>
    <label for="description"></label>
    <textarea name="description" id="description" cols="30" rows="10">
      {{ flashMessages.get('description') || todo.description }}
    </textarea>
  </div>
  <div>
    <input type="submit" value="Create">
  </div>
</form>
@endsection

Here we need to do some method spoofing, thus you see the strange action. This is just a way for AdonisJS spoof PUT, since HTTP only has GET and POST. You'll have to go to the app.ts file and set allowMethodSpoofing to true.

export const http: ServerConfig = {
  ...
  allowMethodSpoofing: true,
  ...
}
  public async update({ params, request, response }: HttpContextContract) {
    const id = params["id"];
    const payload = await request.validate({ schema: todoSchema });
    const todo = await Todo.updateOrCreate({ id }, payload);
    response.redirect().toRoute("todos.show", { id: todo.id });
  }

The last 2 tests we need to write are to check that going to create or edit redirects us to the sign-in page. There isn't any implementation since these are already done, but the negative case test is nice to have in case something breaks in the future.

  test("unauthenticated user to 'create' should redirect to signin", async assert => {
    const response = await agent.get("/todos/create").expect(302);
    assert.equal(response.headers.location, "/login");
  });

  test("unauthenticated user to 'edit' should redirect to signin", async assert => {
    const user = await User.create({
      email: "bob@email.com",
      password: "password",
    });
    const todo = await user.related("todos").create({ name: "Go hiking" });
    const response = await agent.get(`/todos/${todo.id}/edit`).expect(302);
    assert.equal(response.headers.location, "/login");
  });

These should both pass immediately. And now we're "green". We hit all the test cases we initially wanted to write, but our job is far from over. There's a fair bit of refactoring that needs to be done, not in the production code, but in the tests. If you see your tests as "documentation of intent", then there is definitely more editing to make things more clear.

While we're not done, this is a good place to stop. We've completed a feature. We have completed the tests we initially set out to write. We cycled between "red" and "green" several times. Now it's your turn. Are there any more tests you think you'd need to write. How about some refactoring?

Subscribe

I'd like to send you a weekly recap of all the articles I write as well as my take on the latest news on web development with Vue, React, Elixir/Phoenix, and others. Just fill out your email and name below!


Prev: Testing Authenticated Routes in Phoenix
Next: Create Your Own AdonisJS Commands to Create and Drop Databases