How to test a Fastify application

eomm

Manuel Spigolon

Posted on July 5, 2023

How to test a Fastify application

by Manuel Spigolon

In this article, I will show you how to test a Fastify and MongoDB application!

Note that this post is the follow up of the "How to use Fastify and MongoDb"article where you learnt how to create a CRUD application.

What kind of tests?

We must say that it exists a lot of tests in the world:

  • Unit tests: tests that are written to test a single unit of code.
  • Integration tests: tests that are written to test the whole application.
  • End to end tests: tests that are written to test the whole application from the client-side to the server-side.
  • UI tests: tests that are written to test the user interface of the application.
  • Performance tests: tests that are written to test the performance of the application.
  • Security tests: tests that are written to test the security of the application.
  • Smoke tests: tests that are written to test the application in a simple way.
  • Stress tests: tests that are written to test the application in a stressful way.

I think I could continue to list all the kinds of tests for hours.

If you would like to go deeper, you can read the Martin Fowler's site. It contains a lot of articles about testing.

Now, we will focus on the integration tests.

I choose to write the integration tests because I think:

  • You can focus on the business logic of the application.
  • In a dockerized environment, spinning up a container is cheap and easy.
  • The testing code is lesser: you don't need to mock and fix your mocks too.
  • It is helpful to crystallize some bugs you had in production.

For sure, adding unit tests is a good approach, but I usually do it for specific components. We will see an example at the end of this article.

Choosing the right testing framework

Fastify has a the .inject() methodthat allows you to inject a request into the application without starting the HTTP server.

This feature within the node-tap testing framework gives you the power to test your application, faster.

The combo here is that node-tap runs the tests in parallel (one process for each test, file). So, the more files you will create and faster your tests will be. The Fastify's inject feature will avoid the boring Address already in use error.

Let's go with a brief tap example after installing it with npm install tap -D.

// test/index.test.js
const t = require('tap')

t.test('my first test', async t => {
  t.ok(true, 'my first assertion')
})
Enter fullscreen mode Exit fullscreen mode

As you can see, this framework is very easy and clear to use: you define some .test() within a function that must run the assertions.node-tap is a feature-complete testing framework you will love its capabilities.

Now you can run the file simply with:

node test/index.test.js
Enter fullscreen mode Exit fullscreen mode

Or adding a script into the package.json:

{
  "scripts": {
    "test": "tap test/**.test.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating a testable application

In our previous article, we created a CRUD application. We are going to optimize it and make it more testable.

To make it testable, we need to improve the app.js file and decouple the configuration loading. Right now, the fastify-env loads the configuration from the .env file by default.

When we run tests, we want to load the configuration from the .env.test or inject it directly.

module.exports = fp(async function application (app, opts) {
  await app.register(require('fastify-env'), {
    data: opts.envData,
    schema: {
    // ... old code
Enter fullscreen mode Exit fullscreen mode

We have added the data parameter. Now the fastify-env will apply the schema check to the data object. When the data is falsy, the process.env object will be used. In this way, we can test the configuration we want by injecting it directly.

The fastify-env plugin will also load the .env file if it exists, but it won't overwrite the data object.

That's all! Now we can write our test cases!

Writing the tests

The test cases our application needs are the following:

  • create a TODO
  • read a TODO
  • update a TODO
  • read the updated TODO
  • delete a TODO
  • update a TODO that doesn't exist
  • delete a TODO that doesn't exist

Let's create the first test case into the test/legacy.test.js file:

const t = require('tap')
const fastify = require('fastify')
const appModule = require('../app')

t.test('create', async t => {
  const app = fastify()
  app.register(appModule, {
    envData: {
      NODE_ENV: 'testing',
      MONGO_URL: 'mongodb://localhost:27017/my-todo-app'
    }
  })
  t.teardown(() => app.close()) // let's close the server after the test

  const response = await app.inject({
    method: 'POST',
    url: '/todos',
    payload: {
      text: 'test todo'
    }
  })

  t.equal(response.statusCode, 201)

  const todo = response.json()
  t.ok(todo.id, 'the id has been returned')
})
Enter fullscreen mode Exit fullscreen mode

Now running node test/legacy.test.js will show this output:

TAP version 13
# Subtest: create
    ok 1 - should be equal
    ok 2 - the id has been returned
    1..2
ok 1 - create # time=206.184ms

1..1
Enter fullscreen mode Exit fullscreen mode

Great 🎉! We have created a test case that creates a TODO and returns the id.

Memo: remember to run npm run mongo:start before running the tests to spin up the database.

As you saw, the inject method accepts an input object to set:

  • method: the HTTP method
  • url: the URL
  • payload: the payload to send
  • headers: the headers to send
  • query: the query string

And much more!

Let's continue with the other test cases:

  // ...previous code..
  const todo = response.json()
  t.ok(todo.id, 'the id has been returned')

  t.test('read', async t => {
    const response = await app.inject({
      method: 'GET',
      url: '/todos'
    })

    t.equal(response.statusCode, 200)
    t.equal(response.json().length, 1)
    t.notOk(response.json()[0].done)
  })
})
Enter fullscreen mode Exit fullscreen mode

Now we are creating a subtest: read into the create test case. As you can see, we are checking the status code and the length of the response: we have inserted a TODO, then we are reading the TODO list and checking that there is only one element.

If we run it again, we will get an error!

...
 diff: |
  --- expected
  +++ actual
  @@ -1,1 +1,1 @@
  -1
  +2
...
Enter fullscreen mode Exit fullscreen mode

This is due to the fact that we are using the same database and the previous test case has inserted a TODO and in the second run, we have added another one.

This is a common problem when implementing integration tests. Creating integration tests require one phase before starting:

  • spin up: when we prepare the database to run the tests.

As said before, this is useful to replicate issues found in production. In a spin-up phase, we can create the data we need to replicate the bug and then verify that the bug is fixed after our implementation.

This is useful because after the tests run, you can check the data on the database too! So you will be immediately ready to fix the bug and play locally with the application.

Let's update our test file:

const mongoClean = require('mongo-clean')
const { MongoClient } = require('mongodb')

t.test('spin up phase - prepare data', async t => {
  const c = await MongoClient.connect('mongodb://localhost:27017/my-todo-app')
  await mongoClean(c.db())
  c.close()
})

t.test('create', async t => {
  // ...
Enter fullscreen mode Exit fullscreen mode

Now, running our test will be successful:

# Subtest: create
    ok 1 - should be equal
    ok 2 - the id has been returned
    # Subtest: read
        ok 1 - should be equal
        ok 2 - should be equal
        ok 3 - expect falsey value
        1..3
    ok 3 - read # time=5.999ms

    1..3
ok 1 - create # time=211.436ms

1..1
# time=216.488ms
Enter fullscreen mode Exit fullscreen mode

Well done! We have now working integration tests!

Now you have all the pieces to build a testable application and complete the missing test cases we listed before.

Insights: if you create multiple files to test your application, remember to use different database names. You need to customize the envData.MONGO_URL parameter only. This will avoid conflicts between tests.

If you want to see them all, give it a check to the fastify-in-practice repository.

Summary

We have seen a lot of things in this tutorial.

  • how to use node-tap for our tests.
  • how to do a testable application using fastify-env plugin.
  • the fastify.inject method to call the application's endpoints.
  • how to deal with integration tests.

Now you need to practice and build your own application within Fastify!

Comment and share if you enjoyed this article!

💖 💪 🙅 🚩
eomm
Manuel Spigolon

Posted on July 5, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related