Exploring Fastify and TypeScript: Mocking External Dependencies
Tags: fastify, typescript, testing, node
Published: 2023-08-25 14:15:18

## Intro
In this article, I'll walk you through the process of setting up a Fastify + TypeScript project and demonstrate how to effectively mock external dependencies. I've created a demo application to showcase this concept.

## Project Setup

### Create new Fastify Project

```
mkdir fastify-ts-mock
cd fastify-ts-mock

npm init -y
```

### Install Typescript

```
npm i -D typescript @types/node ts-node

tsc --init
```

### Install Fastify

```
npm i fastify fastify-plugin @fastify/autoload
```

### Folder structure

```
(root) fastify-ts-mock
    |-- src
         |-- lib
         |-- plugins
         |-- routes
         |-- type 
    |-- test
```

### Path alias

```
npm i module-alias
npm i -D @types/module-alias tsconfig-paths
```

Add the following lines to `tsconfig.json`
```
{
  "ts-node": {
    "require": ["tsconfig-paths/register"]
  },
  ...
  "baseUrl": "./",
  "paths": {
      "@src/*": ["src/*"],
      "@type/*": ["src/type/*"],
      "@lib/*": ["src/lib/*"],
      "@test/*": ["test/*"],
   },     
   ...  
}
```

### Fastify App 
File: `src/app.ts`

```typescript
import 'module-alias/register';
import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify'
import autoload from '@fastify/autoload'
import { join } from 'path'

export default function createApp(
  opts?: FastifyServerOptions,
): FastifyInstance {
  const defaultOptions = {
    logger: true,
  }

  const app = fastify({ ...defaultOptions, ...opts })

  app.register(autoload, {
    dir: join(__dirname, 'plugins'),
  })

  app.register(autoload, {
    dir: join(__dirname, 'routes'),
    options: { prefix: '/api' },
  })

  return app
}
```

### List all devices Route (`/api/devices`)
File: `src/routes/devices/list.ts`

```typescript
import { FastifyInstance, FastifyPluginOptions } from 'fastify'
import {DeviceDtoCollectionType} from "@type/devices.type";

export default async function (
  fastify: FastifyInstance,
  _opts: FastifyPluginOptions,
): Promise<void> {
  fastify.get<{ Reply: DeviceDtoCollectionType }>(
    '/',
    async (request, reply) => {
      try {
        const devices = await fastify.listDevices()
        return reply.send(devices)
      } catch (error) {
        request.log.error(error)
        return reply.code(500).send()
      }
    },
  )
}
```

### List all devices Feature
File: `src/plugins/features/devices.list.feature.ts`

```typescript
import fp from 'fastify-plugin'
import { FastifyInstance, FastifyPluginOptions } from 'fastify'
import * as DeviceLib from '@lib/devices.lib'
import {DeviceDtoCollectionType} from "@type/devices.type";

declare module 'fastify' {
  interface FastifyInstance {
    listDevices: () => Promise<DeviceDtoCollectionType>
  }
}

async function listDevicesPlugin(
  fastify: FastifyInstance,
  _opts: FastifyPluginOptions,
): Promise<void> {
  const listDevices = async (): Promise<DeviceDtoCollectionType> => DeviceLib.listDevices()

  fastify.decorate('listDevices', listDevices)
}

export default fp(listDevicesPlugin)
```

This plugin use `DeviceLib.listDevices`, which is an external dependency.
In order to properly test the route, I need to mock it.

### Install test libraries

```
npm i tap
npm i -D @types/tap
```

### Test code (`test/routes/devices/list.test.ts`)

```typescript
import {afterEach, test} from "tap"
import createApp from "@src/app";
import * as DeviceLib from '@lib/devices.lib'
import * as Fixtures from '@test/fixtures'
import {DeviceDtoCollectionType} from "@type/devices.type";

test('get all devices', async t => {
  const app = createApp({
    logger: false,
  })

  t.teardown(() => {
    app.close();
  })

  const response = await app.inject({
    method: 'GET',
    url: '/api/devices',
  })

  const deviceCollection = response.json<DeviceDtoCollectionType>()

  t.equal(response.statusCode, 200)
  t.equal(deviceCollection.length, 2)

  ... other test assertion ...
})
```

### First approach: decorate the plugin with a factory

```typescript
async function listDevicesPlugin(
  fastify: FastifyInstance,
  _opts: FastifyPluginOptions,
): Promise<void> {
  
  const listDevices = async (): Promise<DeviceDtoCollectionType> => {
    return await DeviceLib.listDevices()
  }

  const listDevicesTest = async (): Promise<DeviceDtoCollectionType> => {
    return [
      ... some fake data ...
    ]
  }

  function listDevicesFactory() {
    if (fastify.config.ENV === 'test') {
      return listDevicesTest()
    }
    return listDevices()
  }

  fastify.decorate('listDevices', listDevices)
}
```

**PROS:**

* easy approach
* no third-party libraries are needed

**CONS:**

* more plugin complexity = more mock function complexity
* code to maintain
* code that is not bug-free

### A better approach: using ImportMock

Install dependencies:
```
npm i -D sinon ts-mock-imports
```

Update test code (`test/routes/devices/list.test.ts`):
```typescript
import {afterEach, test} from "tap"
import createApp from "@src/app";
import {ImportMock} from "ts-mock-imports";
import * as DeviceLib from '@lib/devices.lib'
import * as Fixtures from '@test/fixtures'
import {DeviceDtoCollectionType} from "@type/devices.type";

afterEach(() => {
  ImportMock.restore();
})

test('get all devices', async t => {
  const app = createApp({
    logger: false,
  })

  t.teardown(() => {
    app.close();
  })

  const listDevicesMock = ImportMock.mockFunction(
      DeviceLib,
      'listDevices',
      Fixtures.devices
  )

  const response = await app.inject({
    method: 'GET',
    url: '/api/devices',
  })

  const deviceCollection = response.json<DeviceDtoCollectionType>()

  t.equal(response.statusCode, 200)
  t.equal(deviceCollection.length, 2)

  ... some data assertions ...

  t.ok(listDevicesMock.calledOnce)
})
```
**PROS:**

* very simple
* easy to maintain

**CONS:**

* third-party libraries are needed

We are not mocking a fastify plugin, but a library used inside the plugin.

**What if we try to mock the plugin instead?**

```typescript
const listDevicesMock = ImportMock.mockFunction(
    app,
    'listDevices',
    Fixtures.devices
)
```

It doesn't work: we get this error:
```
Cannot stub non-existent property listDevices
```

## Conclusion

In a nutshell, writing tests the right way isn't just important – it's crucial. Proper tests ensure our code works as intended and stays reliable. So, let's remember, good tests mean great software!

Feel free to check out the [repository](https://github.com/massimobiagioli/fastify-ts-mock) for a hands-on experience and deeper insights into the process. Happy coding!