skip to content

The Dismal *Amazing* Thoughts and Adventures of

James Gardner

Testing Fastify Apps like a Boss

Testing Fastify Apps Like a Boss

/ 8 min read

Yes, I’m still alive, and it’s been a while since I last posted here. To be honest, when I first created this blog, things were pretty quiet, and I had the time and space to express myself freely. Now that my life has become a bit busier, finding that time hasn’t been as easy. But I’ve still got a long list of topics I’m eager to dive into, and as you can see, testing is high up on that list!

I’m not a TDD evangelist that’ll send you to the scaffold if your coverage falls below 99%. Testing is about balance and, as I often say, being able to sleep at night. If you have code destined for the wild that doesn’t have any test coverage then that might be something you want to prioritise — just a suggestion. Yes, I’m a believer in TDD and I have utilised it successfully in projects but that doesn’t mean I literally always start with my work with a test. To me, it simply means that my mindset is geared towards writing testable code and utilising tests as soon as it becomes useful.

Testing Express Apps

Before we dive into fastify let’s talk about Express. I moved to Fastify because I realised that despite all the great middleware and libraries I was creating, I was essentially building my own framework. Don’t get me wrong, I think Express is fine for very simple applications but as soon as complexity increases you’re writing a framework rather than an application. There’s nothing inherently wrong with that but the logical conclusion to this is a set of packages that all future projects are bootstrapped from. You’ve become the proud maintainer of a framework that’s too feeble to compete with it’s battle-tested, open-source counterparts.

Testing Express apps isn’t exceptionally hard if testing is part of your initial approach. That’s true of almost any application you write. In Express the middleware abstraction can be a convenient way of segregating your application into testable domains, using Express’s Router and using high order functions to pass additional dependencies in as arguments. This helps to avoid an excessive need for mocks:

import type { DataLayer } from './data-layer';
import type { Request, Response, NextFunction } from 'express';

const middleware = (dl: DataLayer) => (req: Request, res: Response, next: NextFunction): void => {
  const router = Router();

  router.get('/items', async (req: Request, res: Response) => {
    const items = await dl.getItems();
    res.json(items);
  });

  // ...
};

export default middleware;

Then you might mount the middleware via a .use within your tests and hit it with something like supertest.

import request from 'supertest';

describe('Middleware Test', () => {
  let app;

  beforeAll(() => {
    app = express();
    app.use(express.json()); 
    app.use(middleware(mockDataLayer));
  });

  it('should return a list of items', async () => {
    const response = await request(app).get('/items').expect(200);
    
    expect(response.body).toEqual([
      { id: '1', name: 'Item 1' }, 
      { id: '2', name: 'Item 2' }
    ]);
  });
});

It’s worth mentioning, that even though this is an example, there’s a mixed usage of expect in the above. The first is supertest’s expect method. The latter is your choice of test library, e.g. jest. This is just a convenience to avoid doing callbacks in the supertest chain. Anyway, this is just one technique of many that are available to us but one I use quite often. Let’s quickly go over the testing pyramid vs trophy next.

The Testing Pyramid vs Trophy

I believe it was Mike Cohn who invented the testing pyramid or at the very least approximated it into something popular. The testing pyramind looks something like this:

Testing Pyramid

The layers in the pyramid can be seen an indication of proportion, that is, what proportion of your tests should be unit tests. Coupled with this it has a scale of isolation with unit tests being fast to run because they are small and isolated. At the top you have some form of automation which typically rely on a higher degree of integration (e.g. you’re simulating a client).

The Trophy has more layers to it, starting with a ‘static’ layer which encompasses static code analysis. I’m tempted to include fuzz testing into this layer as well but we’ll leave that subject for another day. Here’s what the trophy looks like:

Testing Trophy

Personally I like the trophy model and I find the emphasis on integration testing curious. It gives pause for thought as to what specifically we should be testing and what the various points of integration are.

Testing Fastify Apps

Our router plugin is where we handle requests and map those requests to any corresponding business rules and logic. The only thing I’m interested in testing here is that the routes produce the right outcome. We’re testing, for example, that the schematics are correctly being applied to things like bodies, path and query parameters. It’s an easy habit to fall into but we shouldn’t be directly testing the service or schema via the router. Just that they’re being utilised as expected:

  it('returns 200 with a student', async () => {
    const student = {
      uuid: '4681cbc2-cfeb-48d1-8468-09371ed5aced',
      schoolId: 'a02cf332-37f9-4bcb-be47-5929387f3763',
      firstName: 'John',
      lastName: 'Doe',
      dateOfBirth: '1990-01-01'
    };

    (repository.getById as sinon.SinonStub).resolves(student);

    const response = await app.inject({
      method: 'get',
      url: '/school/a02cf332-37f9-4bcb-be47-5929387f3763/student/4681cbc2-cfeb-48d1-8468-09371ed5aced',
    });

    assert.equal(response.statusCode, 200);
    assert.deepEqual(JSON.parse(response.body), student);
  });

  it('returns 404 when student is not found', async () => {
    (repository.getById as sinon.SinonStub).throws(new NotFoundError({
      sql: '',
      values: []
    }));

    const response = await app.inject({
      method: 'get',
      url:  '/school/a02cf332-37f9-4bcb-be47-5929387f3763/student/4681cbc2-cfeb-48d1-8468-09371ed5aced', 
    });

    assert.equal(response.statusCode, 404);
  });

In the above I’m contriving outcomes by mocking my repository. The service layer remains untouched. This slight of hand mean I’m testing with all the functionality of the service layer rather than mocking out the whole lot. Also, for those of you who are interested I’m using node’s new test runner here with sinon for mocking. I bootstrap my tests with a fastify instance:

let app: FastifyInstance;

  before(() => {
    app = fastify();
    app.setValidatorCompiler(validatorCompiler);
    app.setSerializerCompiler(serializerCompiler);
    app.register(student, {
      prefix: '/school/:schoolId/student',
      service: createStudentService(repository)
    });
  });

You might have also noticed that I’m using a method on the fastify instance called inject which is Fastify’s built in injection method which you can use to simulate calls. It’s brilliant - and no libraries like supertest are needed!

In a sense, you could consider this an integration test in a fuzzy kind of way. After all, we’re testing an integration point of the application: the router. In my mind, an integration test is any test that involves testing multiple components working together as a whole. The level of isolation here is above that of simplistic unit tests. By comparison let’s look at how I might test my schemas:

import { describe, it, before} from 'node:test';
import assert from 'node:assert';

import {
  StudentRecordSchema,
  UpdateStudentSchema,
  CreateStudentSchema,
  StudentResponseSchema,
  StudentListResponseSchema,
} from '../schema'; 

describe('Student Record Schema', () => {
  it('should validate a complete student record', () => {
    const data = {
      uuid: '123e4567-e89b-12d3-a456-426614174000',
      schoolId: 'school-uuid',
      firstName: 'John',
      lastName: 'Doe',
      dateOfBirth: '2000-01-01',
    };

    // Should pass validation
    assert.doesNotThrow(() => {
      StudentRecordSchema.parse(data);
    });
  });
});

This is a well-isolated test that’s scoped around a schema. I typically test my schemas in this way to make sure they do what I think they do. Though it might appear obvious, as complexity creeps in unexpected things can happen. You’ve also got an opportunity here to battle-test your schemas with rogue data and other conceivable nastiness. Note also the lack of mocks, a good indicator that this is an isolated test.

Another aspect to consider is testing your application with a real database. In the previous example I mocked out my data layer but it would be prudent to test without that using a dedicated test database. Not only does this give you confidence that your application can successfully connect to your database but you can cover off any side effects from rogue and/or missing data. I typically do this with docker, layering up my own postgres image. The difference here however is that you have to spin up and hit the application via HTTP instead of using .inject. Something like docker compose is your friend here and a test might look like this:

describe('students api', () => {
  it('should return a student', async () => {
    const response = await axios.get('http://localhost:3000/school/a02cf332-37f9-4bcb-be47-5929387f3763/student/4681cbc2-cfeb-48d1-8468-09371ed5aced');

    assert.equal(response.statusCode, 200);
    assert.snapshot(response.body);
  })
});

Yes! Node has experimental support for snapshotting.

A lot of the time with these sort of tests there’s repetition and I’ve mentioned in other posts the usefulness of Jest’s test.each method. At the time of writing this there’s nothing like that in Node’s test utilities but it’s fairly easy to implement yourself:

const each = (cases) => {
    return (description, callback) => {
        cases.forEach((testCase) => {
          // ...
        });
    };
};

Then you can harness the each method to run tests from a pre-configured set of calls. I’ll write a detailed post on this soon as it’s quite extensive.

Conclusion

The layers of a pyramid or trophy not only guide us in identifying the types of tests we should consider but also highlight the key areas to target. Depending on the application you’re building, it’s helpful to mentally map your approach through the trophy topology. Once that’s clear, you can explore the tools available within your chosen framework to write those tests. It’s at this point I ceremoniously shout about how Fastify has been built to be testable and point you in the direction of their testing page.