iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🍣

How to Run NestJS Tests with Vitest and a Brief Overview of Plugins

に公開

Introduction

NestJS sets up Jest as the standard testing tool by default.
However, this time we will replace it with Vitest in pursuit of execution speed.
I will describe the procedure and some personal points.
Please note that I will not describe what Vitest is or the advantages of introducing it.
This article is written from the perspective of points to consider when introducing Vitest, so I would appreciate it if you could refer to other articles regarding whether you should introduce it in the first place.

Setup Procedure

You can introduce it using the following steps.

Minimal Configuration

  • Install the packages with npm i --save-dev vitest unplugin-swc.
  • Describe it in vitest.config.(m)ts as follows.
import swc from 'unplugin-swc';
import { defineConfig } from 'vitest/config';

export default defineConfig({
    plugins: [swc.vite()],
});
  • Change the jest command in package.json to vitest.
    • If you do not want to run it in watch mode, change it to vitest run.
    • The following is an excerpt of the necessary parts.
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
  },
  "devDependencies": {
    "unplugin-swc": "^1.5.2",
    "vitest": "^3.1.3"
  },
}

When aliases are set in tsconfig.json

  • Install the packages with npm i --save-dev vitest unplugin-swc vite-tsconfig-paths.
  • Describe it in vitest.config.mts as follows.
    • Be sure to set the extension to mts.
import swc from 'unplugin-swc';
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [
    tsconfigPaths(),
    swc.vite({
      module: { type: 'es6' },
    }),
  ],
});

  • Change the jest command in package.json to vitest.
    • This step is the same as in the minimal configuration.

That is all you need to get it running.
Next, I will write about personal points, so please read the parts that interest you.

Necessity of Plugins

In this section, we will look at why the swc.vite plugin is necessary.

The conclusion is that it is to prevent issues like transpilation by esbuild.

Let's go through it step by step. Actually, when you replace Jest with Vitest in NestJS, the tests themselves can be executed immediately. And depending on the code, the tests will even succeed.

For example, consider the following code:

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  private readonly appService: AppService;
  constructor() {
    this.appService = new AppService();
  }

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

The code for AppService is as follows:

import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

Correspondingly, here is the test code:

import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { describe, beforeEach, it, expect } from 'vitest';

describe('AppController', () => {
  let appController: AppController;

  beforeEach(async () => {
    const app: TestingModule = await Test.createTestingModule({
      controllers: [AppController],
      providers: [AppService],
    }).compile();

    appController = app.get<AppController>(AppController);
  });

  describe('root', () => {
    it('should return "Hello World!"', () => {
      expect(appController.getHello()).toBe('Hello World!');
    });
  });
});

Running this with Vitest results in a successful test. It might seem like the plugin is unnecessary, but let's try replacing the constructor part of the Controller class as follows:

export class AppController {
  constructor(private readonly appService:AppService) {}

  /** Omitted */
}

When you run the test with Vitest now, it results in an error as shown below:

2025-05-18_16h11_44.png

Even though the test is running, an error occurs because the getHello method of the AppService class does not exist. There are two main factors for this:

  • Vitest is executed by esbuild
  • Many of NestJS's decorator processes are designed to utilize Metadata

First, let's look at how Vitest operates. My understanding is that Vitest runs using Vite internally (*citation needed).

As for Vite, it transpiles TypeScript using esbuild. While Vite achieves its speed by using esbuild, esbuild does not support TypeScript's emitDecoratorMetadata.

Leaving the detailed discussion of emitDecoratorMetadata to the official documentation, for now, please understand that for classes, methods, and properties with decorators, the following keys are used to store information in the Reflect object:

  • design:type (method and property decorators only)
  • design:paramtypes (class and method decorators only)
  • design:returntype (method decorators only)

The fact that emitDecoratorMetadata is not supported means that the values above are not set in the Reflect object.

Next, regarding NestJS, many decorators are utilized, starting with @Injectable. In the NestJS source code below, design:paramtypes is defined as a variable:

https://github.com/nestjs/nest/blob/eac355f39de25647813ee5cdb6b168412bdd93fb/packages/common/constants.ts#L11

This variable is used, for example, in injector.ts, where the actual class injection process takes place:

https://github.com/nestjs/nest/blob/eac355f39de25647813ee5cdb6b168412bdd93fb/packages/core/injector/injector.ts#L389

As shown, there are parts of NestJS that operate on the assumption that emitDecoratorMetadata is true. Therefore, when running with esbuild, those parts are not output during compilation, which can result in things not working correctly.

Vitest runs on esbuild, while NestJS is implemented on the assumption that emitDecoratorMetadata is enabled.

To bridge this gap and solve the issue of tests failing to run, swc.vite() exists.

unplugin-swc enables compilation using SWC with Vite or Rollup. To perform compilation with SWC, the unplugin-swc plugin includes code to disable Vite's esbuild option.

Furthermore, it includes processing to enable options like emitDecoratorMetadata within the SWC compilation settings.

As noted in the README, unplugin-swc inherits settings from tsconfig.json. This means if you are using NestJS, these metadata-related settings will be enabled.

Additionally, given that the NestJS documentation also mentions switching to SWC, it seems that SWC is generally a reliable choice.

As described above, by using unplugin-swc to delegate compilation and other settings to SWC, you can counteract the incompatibility between NestJS and esbuild and run tests more quickly.

By the way, the SWC documentation has a benchmarks page claiming that it is comparable to esbuild.

So, you don't need to feel discouraged about not being able to use esbuild.

Inheriting tsconfig Settings

Let's also take a quick look at the other plugin, tsconfigPaths.

This plugin allows you to apply existing tsconfig settings to Vitest. On its own, this might seem unnecessary since the previously mentioned unplugin-swc also refers to tsconfig.json. However, unplugin-swc does not use all of the tsconfig settings. For example, path aliases are not applied. Therefore, you would normally need to define them in the Vitest configuration file. The tsconfigPaths plugin eliminates that hassle.

While not strictly required to run Vitest, I think it is convenient to include it. Note that since this plugin is an ESModule-only library, you need to override the settings with swc.vite({module: { type: 'es6' },}) when using it.

(Aside) Simple DI Implementation Using Metadata

Lastly, although it's mostly unrelated to the main topic, let's look at how to implement Dependency Injection (DI) using decorators in TypeScript.

Please note that I am not sure if this is the same as the mechanism used in NestJS. (While I suspect it might be the same, I couldn't reach a definitive conclusion. However, since I spent quite some time researching it and felt a bit frustrated, I decided to write this section.)

The implementation is as follows:

import 'reflect-metadata';

/** DI Container Class */
class Container {
    /** Resolve constructor dependencies and get an instance */
    resolve<T>(target: any): T {

        /** Get dependency information from class metadata */
        const tokens = Reflect.getMetadata('design:paramtypes', target) || [];

        /** Recursively resolve dependencies */
        const injections = tokens.map((token: any) => this.resolve(token));

        /** Instantiate the class passed as an argument */
        const instance = new target(...injections);

        return instance;
    }
}

/** Injectable decorator function */
function Injectable() {
    return function (target: any) { };
}

/** Controller decorator function */
function Controller() {
    return function (target: any) { };
}
const container = new Container();

/** Service class */
@Injectable()
class LoggerService {
    log(message: string): void {
        console.log(`[INFO] ${message}`);
    }
}

@Injectable()
class DatabaseService {
    connect(): void {
        console.log('Connected to database');
    }
}

@Injectable()
class UserService {
    constructor(
        private logger: LoggerService,
        private database: DatabaseService
    ) { }

    getUsers(): string[] {
        this.logger.log('Fetching users');
        this.database.connect();
        return ['User1', 'User2', 'User3'];
    }
}

@Controller()
class UserController {
    constructor(private userService: UserService) { }

    listUsers(): void {
        const users = this.userService.getUsers();
        console.log('Users:', users);
    }
}

function bootstrap() {
    /** Resolve UserController from the DI container (dependencies are also automatically resolved) */
    const userController = container.resolve<UserController>(UserController);

    /** Call controller method */
    userController.listUsers();
}

bootstrap();

The tsconfig.json settings must include at least the following:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

The point to focus on in the implementation is the Container class.
This collects design:paramtypes registered in the Reflect object, recursively instantiates classes, and sets them in the constructor.
By doing this, you can use the DI mechanism without having to explicitly instantiate and inject everything on the implementation side.

Let's also examine why values can be retrieved with design:paramtypes.

First, here is part of the output from compiling the previous TypeScript file:

var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
    
var UserController = /** @class */ (function () {
    /** ...Omitted */
    UserController = __decorate([
        Controller(),
        __metadata("design:paramtypes", [UserService])
    ], UserController);
    return UserController;
}());

The configuration of the UserController class is performed at the moment the file is loaded. Inside, there is a __metadata function that registers the dependencies injected into the UserController class into the Reflect object, using "design:paramtypes" as the key.

This allows dependent classes to be retrieved and instantiated when calling Reflect.getMetadata('design:paramtypes', targetClass).

My mental image of it is that the mechanism handles everything up to the point of retrieving values using a common key, and the rest is handled appropriately on the implementation side—which I believe is how DI using decorators works in TypeScript.

One thing to note about this implementation is that decorators must be applied to each class.
While the logic inside the decorator can be empty, as in the sample implementation, the class will not be registered in the Reflect object if no decorator is applied.

Here is the compilation result when a decorator is not applied to the UserController class:

var UserController = /** @class */ (function () {
    function UserController(userService) {
        this.userService = userService;
    }
    UserController.prototype.listUsers = function () {
        var users = this.userService.getUsers();
        console.log('Users:', users);
    };
    return UserController;
}());

The metadata-related processing is completely missing.

Because it is missing, nothing is registered in the Reflect object, making it impossible to correctly retrieve dependent classes. Consequently, the Container class cannot resolve dependencies, and an error occurs during execution.

That concludes the simple DI implementation. I felt that decorators and metadata are significantly deeper than I had previously understood.

Conclusion

In this article, we looked at how to run NestJS tests with Vitest and various configurations.
Due to my shallow understanding of Vite, Vitest, esbuild, SWC, Metadata, and so on, it took me a long time to write this article compared to the actual volume of content.
While my understanding is still insufficient, for now, I am satisfied that I can run Vitest with NestJS, and I intend to deepen my understanding gradually.
Thank you for reading this far.

Discussion