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 Todo
s 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 Todo
s, "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 Todo
s 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?
Top comments (0)