Adonis.js V6 - It's even better!

Published 5/5/2024

It's been 3.5 years since I created the series TDD course with Adonis.js using Adonis.js version 4. Since then Adonis.js has gone through major releases, primarly the switch to TypeScript. I haven't used either Adonis.js v5 or v6, so I thought it would be interesting to try to recreate parts of the TDD course in the latest version by cross checking my old series with the up-to-date documentation.

Like back then, we will create an application that lets you manage threads, similar to a site like reddit.

During the making I realized how similar the versions are to each other, which is a good thing btw, so I will start by showing the final version and then the different parts it's made of.

Setup

You start off by initiating the project using the following command:

npm init adonisjs@latest adonis-tdd-v6

You can choose between web, api, and slim. we choose the api kit for this example which is meant for REST APIs. Web would be for fullstack applications.

Comparison to V4:

  • The setup has been streamlined by no longer requiring the global adonis cli.
  • Testing no longer requires any additional installing and configurations

Writing our first test

Also explained here: https://docs.adonisjs.com/guides/http-tests

We start off by creating a test. While we can create files manually, Adonis.js also comes with easy to use commands to speed up development.

Execute node ace make:test thread and then choose functional, as we want to test an API route.

In this test, we make an API request to "/threads" and then assert that the response status is 200:

// tests/functional/thread.spec.ts

import { test } from "@japa/runner";

test.group("Thread", () => {
  test("can create threads", async ({ client }) => {
    const response = await client.post("/threads").json({
      title: "test title",
      body: "body",
    });

    response.assertStatus(200);
  });
});

We then proceed to create the actual controller using node ace make:controller threads and add the store method to the class:

// app/controllers/threads_controller.ts

export default class ThreadsController {
  async store() {}
}

and we register this controller in the routes file:

// start/routes.ts

import router from "@adonisjs/core/services/router";
import ThreadsController from "#controllers/threads_controller";

router.post("threads", [ThreadsController, "store"]);

Running the tests via npm test, npm t, or node ace test should already show a passing test.

Difference to V4:

  • Tests no longer require the "trait" function.
  • Controllers are now explictly imported in the routes file rather than a stringifed "ThreadController.store" which is great as it allows for faster navigation and prevents typo errors.
  • Files now use snake case instead of Pascal case

Creating the model

Now our test isn't doing anything yet so let's add the creation of threads.

We create the model using node ace make:model Thread -m. The flag -m also creates the database migration file to create the table.

We update the test to add the thread assertion:

// tests/functional/thread.spec.ts

import { test } from "@japa/runner";

test.group("Thread", () => {
  test("can create threads", async ({ client }) => {
    const response = await client.post("/threads").json({
      title: "test title",
      body: "body",
    });

    response.assertStatus(200);

    // 👇 Changes
    const thread = await Thread.firstOrFail();
    response.assertBodyContains(
      thread.serialize({ fields: ["id", "title", "body"] })
    );
  });
});

Run the test and it will tell you that the table doesn't exist. Adonis V6 uses sqlite by default and it's already set up for us. You can check out config/Database.ts for the configurations.

DIfference to V4:

  • no manual installation and set up of sqlite.

Now to make the table exist in this sqlite db, we have to run the database migrations at the start of tests.

This can be done in tests/bootstrap.ts as explained here: https://docs.adonisjs.com/guides/database-tests

// tests/bootstrap.ts

import testUtils from "@adonisjs/core/services/test_utils";

export const runnerHooks: Required<Pick<Config, "setup" | "teardown">> = {
  setup: [() => testUtils.db().migrate()],
  teardown: [],
};

Running the tests now will no longer complain that the table doesn't exist, but it will fail because "Thread.firstOrFail()" didn't return any data. So let's make our controller action create the thread!

// app/controllers/threads_controller.ts

import type { HttpContext } from "@adonisjs/core/http";
import Thread from "#models/thread";

export default class ThreadsController {
  async store({ request, response }: HttpContext) {
    const thread = await Thread.create(request.only(["title", "body"]));
    return response.json(thread);
  }
}

This part is identical with V4. Don't worry about the missing validation and authorization for this example, suffice to say, Adonis.js also has first class support for it.

The tests now fail with Cannot define \"title\" on \"Thread\" model, since it is not defined as a model property so let's set up our model fields!

In the migration file, add the title and body fields like this:

// database/migrations/<timestamp>_create_threads_table.ts

import { BaseSchema } from "@adonisjs/lucid/schema";

export default class extends BaseSchema {
  protected tableName = "threads";

  async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments("id");
      // 👇 Changes
      table.text("body").notNullable();
      table.string("title").notNullable();

      table.timestamp("created_at");
      table.timestamp("updated_at");
    });
  }

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

We also declare the fields in the model:

// app/models/thread.ts

import { DateTime } from "luxon";
import { BaseModel, column } from "@adonisjs/lucid/orm";

export default class Thread extends BaseModel {
  @column({ isPrimary: true })
  declare id: number;
  // 👇 Changes
  @column()
  declare body: string;
  // 👇 Changes
  @column()
  declare title: string;

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

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

With this change, the tests should be green again!

Changes to V4:

  • To achieve type safety, we now need to specify the columns on the model as well.

Delete threads

Let's add another test to delete threads in our functional test file:

// tests/functional/thread.spec.ts

test("can delete threads", async ({ client }) => {
  const thread = await Thread.create({
    title: "test title",
    body: "test body",
  });

  const response = await client.delete(`threads/${thread.id}`);
  response.assertStatus(200);
});

Back in routes.ts we can replace writing out each route by using "router.resource" which will follow the convential CRUD routes and names (see details in docs):

// start/routes.ts

router.resource("threads", ThreadsController).only(["store", "destroy"]);

Next, we add the actual controller action as a method in the class:

// app/controllers/threads_controller.ts

async destroy({ params }: HttpContext) {
  const thread = await Thread.findOrFail(params.id)
  await thread.delete()
}

That works, but let's also test there are zero threads left in the DB.

// tests/functional/thread.spec.ts

const threads = await Thread.all();
assert.equal(threads.length, 0);

changes to v4:

  • it seems v6 is missing the Model.getCount() method to easily do a count(*). Hope it will be added back.

Running this will fail as there is still one thread apparently. Let's try running this test in isolation: node ace test --tests 'can delete threads'

Hey it works! This makes sense because the thread from the other test is still there. We need to set up that the DB is reset after each test as well.

To do this, we need to add this to our test file:

// tests/functional/thread.spec.ts

import { test } from "@japa/runner";
import Thread from "#models/thread";
import testUtils from "@adonisjs/core/services/test_utils";

test.group("Thread", (group) => {
  group.each.setup(() => testUtils.db().withGlobalTransaction());

  // ...
});

This is very similar to V4, and again I wished if we could just set up something like this once in bootstrap.ts. Hey, I even prepared a PR for it back then https://github.com/adonisjs/vow/pull/58.

But it's nothing we can't create ourselves.

For example, you could create a snippet for this, your own npm/ace command, or you could simply abstract this if you wanted in a new utility file:

// tests/db.ts

import testUtils from "@adonisjs/core/services/test_utils";
import { Group } from "@japa/runner/core";
import { test } from "@japa/runner";

export { test } from "@japa/runner";

export const group = (groupTitle: string, callback: (group: Group) => void) => {
  return test.group(groupTitle, (group) => {
    group.each.setup(() => testUtils.db().truncate());
    return callback(group);
  });
};

Now we can just write tests like this:

// tests/functional/thread.spec.ts

import { test, group } from "#tests/db";

group("Thread", () => {
  test("create threads", async ({ client }) => {});
});

But generally, I like not deviating too much from the framework.

Factories

In our tests we will need to create many fake instances of threads, like in the thread deletion test. Let's refactor this part a little using factories so we won't have to update this test again in the future when we add new fields.

First we create the factory for our thread table using: node ace make:factory thread and add the title and body columns:

// database\factories\thread_factory.ts

import factory from "@adonisjs/lucid/factories";
import Thread from "#models/thread";

export const ThreadFactory = factory
  .define(Thread, async ({ faker }) => {
    return {
      title: faker.lorem.sentence(),
      body: faker.lorem.text(),
    };
  })
  .build();

With this, in the test, we can now refactor

// tests/functional/thread.spec.ts

const thread = await Thread.create({
  title: "test title",
  body: "test body",
});

to just:

// tests/functional/thread.spec.ts

import { ThreadFactory } from "#database/factories/thread_factory";

const thread = await ThreadFactory.create();

Note that because everything is TypeScript now, you can just type ThreadF... and it should auto suggest ThreadFactory so you don't have to write out the import statements for factories.

For the creation, where we pass title and body to the api client, we can also utilize the factory and the make method, which will only new up an instance but not persist it in the database (unlike create):

// tests/functional/thread.spec.ts

const input = await factories.threads.make();
const response = await client
  .post("/threads")
  .json(input.serialize({ fields: ["title", "body"] }));

auth middleware

So far any user can create threads, let's add some authorization to the routes.

First we create the new test:

// tests/functional/thread.spec.ts

test("unauthenticated user cannot create threads", async ({ client }) => {
  const input = await factories.threads.make();
  const response = await client
    .post("/threads")
    .json(input.serialize({ fields: ["title", "body"] }));

  response.assertStatus(401);
});

Next we add the auth middleware to our routes:

// start/routes.ts

import { middleware } from "#start/kernel";
import router from "@adonisjs/core/services/router";
import ThreadsController from "#controllers/threads_controller";

router
  .resource("threads", ThreadsController)
  .only(["store", "destroy"])
  .use("*", middleware.auth());

That's basically it, but there's two tiny more steps to set up session based auth setup for testing:

We add the plugin to tests/bootstrap.ts:

// tests/bootstrap.ts

import { authApiClient } from "@adonisjs/auth/plugins/api_client";
// 👇 Changes
import { sessionApiClient } from "@adonisjs/session/plugins/api_client";

export const plugins: Config["plugins"] = [
  assert(),
  apiClient(),
  pluginAdonisJS(app),
  authApiClient(app),
  // 👇 Changes
  sessionApiClient(app),
];

We also create a new file in the root directory to store environment variables for the test environment:

// .env.test

SESSION_DRIVER = memory;

The new test is passing now while the others fail. To fix the other tests we need to send the API request with an authenticated user.


To do this, we first set up the user table.

A migration for users was created automatically, you can find in the database/migrations folder.

Let's create a factory for users: node ace make:factory user

// database\factories\user_factory.ts

import factory from "@adonisjs/lucid/factories";
import User from "#models/user";

export const UserFactory = factory
  .define(User, async ({ faker }) => {
    return {
      full_name: faker.internet.userName(),
      email: faker.internet.email(),
      password: faker.internet.password(),
    };
  })
  .build();

and we can now add the user to our two failing tests, for example:

// tests/functional/thread.spec.ts

const user = await UserFactory.create();

const response = await client.delete(`threads/${thread.id}`).loginAs(user);

and it works! Sure it's not optimal that any user can delete any thread, but we are getting there...

Relationships

Next, we'll be linking the threads and user models together by adding the "user_id" to threads. One user can have many threads. Instead of manually linking user_id (which we can totally do), we can encode the relationship in Adonis.js.

We first update the controller to create the thread through the user model:

// app/controllers/threads_controller.ts
export default class ThreadsController {
  async store({ auth, request, response }: HttpContext) {
    const thread = await auth
      .user!.related("threads")
      .create(request.only(["title", "body"]));
    return response.json({ thread });
  }

  // ...
}

And we update both the user and the threads model to add the relationship and the new field:

// app/models/user.ts

import { BaseModel, column, hasMany } from "@adonisjs/lucid/orm";
import type { HasMany } from "@adonisjs/lucid/types/relations";
import Thread from "./thread.js";

export default class User extends compose(BaseModel, AuthFinder) {
  // ...other columns

  @hasMany(() => Thread)
  declare threads: HasMany<typeof Thread>;
}
// app/models/thread.ts

export default class Thread extends BaseModel {
  // ...other columns

  @column()
  declare userId: string; // Note we define the fields in camel case
}

Next, we add the user_id and foreign key to the migration file:

// database/migrations/<timestamp>_create_threads_table.ts

table.integer("user_id").unsigned().notNullable();
table.foreign("user_id").references("id").inTable("users");

The only part that's missing now is to add user_id to the factory as well for tests. What's interesting is we can also define our relationship there:

// database\factories\user_factory.ts

export const UserFactory = factory
  .define(User, async ({ faker }) => {
    return {
      full_name: faker.internet.userName(),
      email: faker.internet.email(),
      password: faker.internet.password(),
    };
  })
  // 👇 Changes
  .relation("threads", () => ThreadFactory)
  .build();

And now we update our existing threads to use "UserFactory.with('threads')" instead of the "ThreadFactory" so it auto-establishes the relationship for us.

// tests/functional/thread.spec.ts

test("can delete threads", async ({ assert, client }) => {
  const user = await UserFactory.with("threads").create();

  const response = await client
    .delete(`threads/${user.threads[0].id}`)
    .loginAs(user);
  response.assertStatus(200);
  const threads = await Thread.all();
  assert.equal(threads.length, 0);
});

Closing

I'm really happy with Adonis.js V6! The documentation is very fleshed out, the development process is streamlined, the simple to use APIs from back then are all still here, it's fun to write code with it, and the type system is stellar.

I know this is missing some parts from the original TDD course like validation but I'm confident you can do it from here ;)

I do wish a plugin existed to sync models with factories and possibly migrations so there's not so many files to update when adding a field. I was able to hack together a "auto factory builder" quickly. Let me know if you are interested and I can share it!