Skip to content

Instantly share code, notes, and snippets.

@eugenk
Last active July 1, 2024 10:20
Show Gist options
  • Save eugenk/3d116d2a1cc34fdc446aef97d2afdcb9 to your computer and use it in GitHub Desktop.
Save eugenk/3d116d2a1cc34fdc446aef97d2afdcb9 to your computer and use it in GitHub Desktop.
Marrying ESM with NestJS and Jest

Marrying ESM and NestJS and Jest

Many popular NodeJS packages are moving towards ESM-only support. This leads to the necessity to build applications with ESM support as well.

Some dependencies may be built for both, ESM and CommonJS. If, however, deeply in the dependency tree there is an ESM-only package, the application itself must be ESM. It does not suffice to make sure that your own packages are built for ESM if you choose to stay on CommonJS.

NestJS itself currently does not officially support ESM and there are also no plans to change it. There is a way, though, to start an ESM-based NestJS application by setting "type": "module" in the package.json among other things.

One challenge remains, though: Jest. A regular ESM NodeJS application has, unfortunately, other requirements to the production code than tests that are using Jest.

The TypeScript compiler does not bring all of the features that are needed into a single bundle. Therefore, it needs to be replaced by a different compiler. Let's see how we can configure the application and build system to work with ESM and Jest at the same time.

[TOC]

Disable Typescript Compiler

First, let's configure Typescript to not build to Javascript files and not get into the way of the replacement compiler:

tsconfig.json:

{
  "extends": "@tsconfig/node20/tsconfig.json",
  "compilerOptions": {
    "noEmit": true,
    "esModuleInterop": true,
    "noErrorTruncation": true,
    "experimentalDecorators": true
  },
  "exclude": ["dist", "node_modules"]
}

This config basically disables the Typescript compiler (with noEmit) but still allows to use tsc for type checking while still factoring in ESM and NestJS's decorators.

Use SWC as Compiler

SWC is a fast compiler/bundler that allows to use NestJS with ESM packages. First, install it along with Jest-related packages:

npm i -D \
  @swc/cli \
  @swc/core \
  @swc/plugin-transform-imports \
  @swc/jest \
  jest \
  @types/jest

Then, create the configuration file of SWC in your application root (next to package.json):

.swcrc:

{
  "exclude": "node_modules/",
  "sourceMaps": "inline",
  "module": {
    "type": "nodenext"
  },
  "jsc": {
    "target": "esnext",
    "parser": {
      "syntax": "typescript",
      "topLevelAwait": true,
      "decorators": true
    },
    "transform": {
      "legacyDecorator": true,
      "decoratorMetadata": true
    }
  }
}

This configuration tells SWC to handle decorators like intended by NestJS. It also sets the target to esnext as well as module to nodenext which allows usage of ESM packages.

Note: sourceMaps needs to be set to "inline" because they are incorrect when set to true. Using the debugger will be difficult without "inline".

Add .js Suffix to all Local Imports

ESM requires different import paths to reduce the guesswork of node and to allow the same import style in the web.

When importing a file, say foo.ts, you now need to import the compiled foo.js:

- import { ... } from "./foo"
+ import { ... } from "./foo.js"

You also cannot import the implicit index file any more. Say you have a file foo/index.ts, you now need to import this file directly:

- import { ... } from "./foo"
+ import { ... } from "./foo/index.js"

All local imports need to be changed in the whole application code.

The first of these two can be easily done with regex-search and replace with these inputs:

search:  (from "\.[^"]+)"
replace: $1.js"

Jest Config

One requirement for our tests is usually to compile the Typescript-based test files on the fly when running them. We do not want to compile and run tests in two distinct steps. This requires Jest to transform the code. By default, this is done by ts-node only using the tsconfig.json.

However, there is one more caveat: Jest cannot handle imports with .js suffix. Bummer. Rewriting every import in your code base to remove the .js suffix is not a valid option because you want to test the exact same code that you ship. Or is it? SWC has a plugin that transforms imports. We use this plugin to remove the .js suffix only during runtime of the tests. For it to work, we need to merge this into the Jest config.

When using SWC, however, the global jest constant is not available out of the box. You can set it with this file, though (next to the package.json):

jest-setup.mjs:

import { jest } from "@jest/globals";

global.jest = jest;

With the following snippet of the Jest config, we use SWC to transform the tests and use the same compiler config as the production code (except for the .js suffix):

jest.esm-config.cjs:

const fs = require("node:fs");

const swcConfig = JSON.parse(fs.readFileSync(`${__dirname}/.swcrc`, "utf-8"));

module.exports = {
  testEnvironment: "node",
  moduleFileExtensions: ["js", "json", "ts"],
  transform: {
    "^.+\\.(t|j)sx?$": [
      "@swc/jest",
      {
        ...swcConfig,
        /* custom configuration in Jest */
        jsc: {
          ...(swcConfig.jsc ?? {}),
          experimental: {
            ...(swcConfig.jsc?.experimental ?? {}),
            plugins: [
              ...(swcConfig.jsc?.experimental?.plugins ?? []),
              [
                "@swc/plugin-transform-imports",
                {
                  "^(.*?)(\\.js)$": {
                    skipDefaultConversion: true,
                    // remove js suffix from local imports:
                    transform: "{{matches.[1]}}",
                  },
                },
              ],
            ],
          },
        },
      },
    ],
  },
  extensionsToTreatAsEsm: [".ts", ".tsx"],
  setupFiles: ["<rootDir>/jest-setup.mjs"],
};

jest.config.cjs:

const jestEsmConfig = require("./jest.esm-config.cjs");

module.exports = {
  ...jestEsmConfig,
  // whatever your options for path patterns, mocks, coverage etc. may be
  testMatch: ["**/*.spec.ts"],
  resetMocks: true,
  collectCoverage: true,
  ...
};

This Jest config injects jest as a global and makes it compatible with ESM as well as the .js-suffixed imports. Otherwise, it treats the code exactly the same as the production build.

Derived Jest Config for E2E Tests

If you need to use a separate config for, for instance, end to end (E2E) tests, you can simply derive from the main Jest config:

jest.e2e-config.cjs:

const jestConfig = require("./jest.config.cjs");

module.exports = {
  ...jestConfig,
  testMatch: ["**/*.e2e-spec.ts"],
  forceExit: true,
};

Settings of the package.json

The application itself needs to be a module for it to be able to import ESM packages. Set "type": "module" in your package.json.

The build script first checks the types and then compiles to Javascript. If you have any additional .ts files that are not inside the src/ directory, you need to list them as well (Example: swc src generated/openapi.ts --out-dir dist).

The test script sets flags to run Jest with ESM support.

package.json:

{
  "name": "...",
  "version": "...",
  "type": "module",
  "scripts": {
    "build": "tsc && swc src --out-dir dist",
    "test": "NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --config=jest.config.cjs",
    "test-e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --config=jest.e2e-config.cjs",
    ...
  },
  ...

Rewrite Errors

Error classes in ESM work different from error classes in CommonJS where you would use es6-error and extend from its default export ExtendableError. In ESM, you need to extend from this:

error-base.ts:

export class ErrorBase extends Error {
  constructor(message?: string) {
    super(message);
    Object.defineProperty(this, "name", {
      value: this.constructor.name,
      configurable: true,
      writable: true,
    });
    Error.captureStackTrace(this, this.constructor);
  }
}

like this:

not-found-error.ts:

import { ErrorBase } from "./error-base.js";

export class NotFoundError extends ErrorBase {}

config

While the config package supports using config/default.{js,ts}, it is not supported with ESM. The config package imports/requires your config files dynamically during runtime. ESM, however, does not allow importing/requiring modules during runtime. They would need to be loaded with await import() which config does not do.

So config files need to be static JSON or YAML.

Visual Studio Code

If you are using Visual Studio Code, you can use this snippet as a launch config:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Launch Server",
      "skipFiles": ["<node_internals>/**"],
      "program": "${workspaceFolder}/dist/src/index.js",
      "preLaunchTask": "npm: build",
      "outFiles": ["${workspaceFolder}/dist/**/*.js"],
    },
    {
      "type": "node",
      "request": "launch",
      "name": "Jest",
      "skipFiles": ["<node_internals>/**"],
      "program": "${workspaceFolder}/node_modules/.bin/jest",
      "args": ["--runInBand", "--config=jest.config.cjs"],
      "env": {
        "NODE_OPTIONS": "--experimental-vm-modules",
      },
    },
    {
      "type": "node",
      "request": "launch",
      "name": "Jest E2E",
      "skipFiles": ["<node_internals>/**"],
      "program": "${workspaceFolder}/node_modules/.bin/jest",
      "args": ["--runInBand", "--config=jest.e2e-config.cjs"],
      "env": {
        "NODE_OPTIONS": "--experimental-vm-modules",
      },
    },
    // the following config allows to use the debugger in the vscode-jest extension
    {
      "type": "node",
      "name": "vscode-jest-tests.v2.esm-commonjs-nestjs-jest-example",
      "request": "launch",
      "env": {
        "NODE_OPTIONS": "--experimental-vm-modules",
      },
      "args": [
        "--config=jest.esm-config.cjs",
        "--runInBand",
        "--watchAll=false",
        "--testNamePattern",
        "${jest.testNamePattern}",
        "--runTestsByPath",
        "${jest.testFile}",
      ],
      "cwd": "${workspaceFolder}",
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen",
      "program": "${workspaceFolder}/node_modules/.bin/jest",
    },
  ],
}

If you are using the vscode-jest extension, you need to add this to your workspace config:

  "jest.nodeEnv": {
    "NODE_OPTIONS": "--experimental-vm-modules"
  }

Conclusion

There is a lot of setup work required to allow usage of NestJS decorators with ESM while still being able to test the application with Jest. The above configuration snippets provide you with a way to set up your application. With these you can finally upgrade popular packages to their current versions.

While it could be possible to transform local import statements during compile time to not require the .js suffix and save yourself some effort, it is advised to simply add the .js. ESM is the future of the Javascript ecosystem and your code should not introduce such hacks to cling on to legacy concepts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment