Introduction

Every time you make a change to your application, you want to test the behavior to ensure that it works fine. Manually testing changes by visiting each web page or API is impossible and hence automated testing makes sure that everything works fine.

In this guide, we learn about the benefits and different ways to test your application

Test cases

If you are new to testing, you may find it hard to discover the benefits of testing, but once you get into the habit of writing test, your code quality and confidence over code improve drastically.

To build a better mental model, testing is divided into multiple categories, so that you can write different types of test cases with a clear boundary.

Unit tests

Unit tests are written to test a small piece of code in isolation. For example: Testing a service directly, without worrying about how that service is used in real world.

Unit tests ensure that each part of the application works fine on its own and also it is easier to write them since you do not need the whole application to work before you can test it.

Example
const { test } = use('Test/Suite')('Example unit test')
const UserValidator = use('App/Services/UserValidator')

test('validate user details', async ({ assert }) => {
  const validation = await UserValidator.validate({
    email: 'wrong email'
  })

  assert.isTrue(validation.fails())
  assert.deepEqual(validation.messages(), [
    {
      field: 'email',
      message: 'Invalid user email address'
    }
  ])
})

Functional tests

Functional tests are written to test your app like an end user. Which means opening up a browser programmatically and visiting the web pages to ensure they all work fine.

Example
const { test, trait } = use('Test/Suite')('Example unit test')
trait('Test/Browser')

test('validate user details', async ({ browser }) => {
  const page = await browser.visit('/')

  await page
    .type('email', 'wrong email')
    .submit('form')
    .waitForNavigation()

  page.session.assertError('email', 'Invalid user email address')
})

Both the above examples, validate the email address for a given user, but the approach is different based on the type of test you are writing.

Setup

First, we need to set up the testing engine by installing it from npm.

adonis install @adonisjs/vow

Next, make sure to register the provider inside aceProviders array, since we do not want to boot testing engine when running your application in production.

start/app.js
const aceProviders = [
  '@adonisjs/vow/providers/VowProvider'
]

Once @adonisjs/vow is installed, it creates a sample test for you, along with some other files described below.

vowfile.js

The vowfiles.js is loaded before your tests are executed. You can use this file to define tasks that should occur before and after running all the tests.

.env.testing

This file contains the environment variables to be used when running tests. This file gets merged with .env file so must only define values you want to override from the .env file.

test

All of the application tests are stored inside subfolders of test directory.

Running tests

Vow provider automatically creates a unit test for you, which can be executed by running the following command.

adonis test

Output

Example
  ✓ make sure 2 + 2 is 4 (2ms)

PASSED
total       : 1
passed      : 1
time        : 6ms

Testing suite & traits

Before we dive into writing tests, let’s understand some fundamentals which are important to understanding the flow of tests.

Suite

Each file is a test suite, which defines a group of tests of same behavior. For example, We can have a suite of tests for User registration.

const Suite = use('Test/Suite')('User registeration')

// or destructuring
const { test } = use('Test/Suite')('User registeration')

The test function obtained from the Suite instance is used for defining tests.

test('return error when credentials are wrong', async (ctx) => {
  // implementation
})

Traits

Traits are building blocks for your test suite. Since AdonisJs test runner is not bloated with a bunch of functionality, we ship different pieces of code as traits.

For example: Using the browser to run your test.

const { test, trait } = use('Test/Suite')('User registeration')

trait('Test/Browser')

test('return error when credentials are wrong', async ({ browser }) => {
  const page = await browser.visit('/user')
})

The beauty of this approach is that Traits can enhance your tests transparently without doing much work. For instance, if we remove Test/Browser trait. The browser object gets undefined inside our tests.

Also, you can define your traits either by defining a closure or an IoC container binding.

You do not have to create a trait for everything. Majority of the work can be done by using Lifecycle hooks. Traits are helpful when you want to bundle a package to be used by others.
const { test, trait } = use('Test/Suite')('User registeration')

trait(function (suite) {
  suite.Context.getter('foo', () => {
    return 'bar'
  })
})

test('foo must be bar', async ({ foo, assert }) => {
  assert.equal(foo, 'bar')
})

Context

Since each test has an isolated context, you can pass values to it by defining getters or macros and access them inside the test closure.

By default, the context has only one property called assert, which is an instance of chaijs/assert to run assertions.

Lifecycle hooks

Each suite has some lifecycle hooks, which can be used to perform repetitive tasks, like cleaning the database after each test and so on.

const Suite = use('Test/Suite')('User registeration')

const { before, beforeEach, after, afterEach } = Suite

before(async () => {
  // executed before all the tests for a given suite
})

beforeEach(async () => {
  // executed before each test inside a given suite
})

after(async () => {
  // executed after all the tests for a given suite
})

afterEach(async () => {
  // executed after each test inside a given suite
})

Assertions

The assert object is an instance of chaijs/assert which is passed to each test as a property on test context.

To make your tests more reliable, you can also plan assertions to be executed for a given test. Let’s consider this example.

test('must throw exception', async ({ assert }) => {
  try {
    await badOperation()
  } catch ({ message }) {
    assert.equal(message, 'Some error message')
  }
})

The above test passes even if an exception was never thrown and no assertions were run. Which means it is a bad test, which is passed because we structured it badly.

To overcome this situation, you must plan some assertions, to make sure the catch block is always executed and an assertion has been made.

test('must throw exception', async ({ assert }) => {
  assert.plan(1)

  try {
    await badOperation()
  } catch ({ message }) {
    assert.equal(message, 'Some error message')
  }
})

This time, if badOperation doesn’t throw an exception, the test still fails since we planned for 1 assertion and 0 were made.