Last active
February 22, 2026 20:26
-
-
Save eugenk/3d116d2a1cc34fdc446aef97d2afdcb9 to your computer and use it in GitHub Desktop.
Revisions
-
eugenk revised this gist
Jul 1, 2024 . 1 changed file with 69 additions and 9 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -143,7 +143,7 @@ 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`: ```javascript const fs = require("node:fs"); @@ -158,6 +158,7 @@ module.exports = { "@swc/jest", { ...swcConfig, /* custom configuration in Jest */ jsc: { ...(swcConfig.jsc ?? {}), experimental: { @@ -182,7 +183,20 @@ module.exports = { }, extensionsToTreatAsEsm: [".ts", ".tsx"], setupFiles: ["<rootDir>/jest-setup.mjs"], }; ``` `jest.config.cjs`: ```javascript 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, ... }; ``` @@ -274,21 +288,67 @@ 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: ```jsonc { "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: -
eugenk revised this gist
Jun 24, 2024 . 1 changed file with 47 additions and 6 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -1,8 +1,12 @@ # 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](https://github.com/nestjs/nest/issues/7021#issuecomment-831799620). There is a way, though, to start an ESM-based NestJS application by setting `"type": "module"` in the `package.json` among other things. @@ -25,8 +29,6 @@ First, let's configure Typescript to not build to Javascript files and not get i { "extends": "@tsconfig/node20/tsconfig.json", "compilerOptions": { "noEmit": true, "esModuleInterop": true, "noErrorTruncation": true, @@ -60,7 +62,7 @@ Then, create the configuration file of SWC in your application root (next to `pa ```json { "exclude": "node_modules/", "sourceMaps": "inline", "module": { "type": "nodenext" }, @@ -82,6 +84,9 @@ Then, create the configuration file of SWC in your application root (next to `pa 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. @@ -220,8 +225,8 @@ The `test` script sets flags to run Jest with ESM support. "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", ... }, ... @@ -258,6 +263,42 @@ 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 for tests with Jest: ```jsonc { "name": "Test", "request": "launch", "skipFiles": ["<node_internals>/**", "node_modules/**/*"], "type": "node", "internalConsoleOptions": "openOnSessionStart", "program": "node_modules/.bin/jest", "args": ["${file}"], // use this line only if you want to just run the focused file "env": { "NODE_OPTIONS": "--experimental-vm-modules" } }, ``` If you are using the `vscode-jest` extension, you need to add this to your workspace config: ```json "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. -
eugenk created this gist
Jun 19, 2024 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,268 @@ # Marrying ESM with 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. NestJS itself currently does not officially support ESM and [there are also no plans to change it](https://github.com/nestjs/nest/issues/7021#issuecomment-831799620). 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`: ```json { "extends": "@tsconfig/node20/tsconfig.json", "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext", "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](https://swc.rs) is a fast compiler/bundler that allows to use NestJS with ESM packages. First, install it along with Jest-related packages: ```sh 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`: ```json { "exclude": "node_modules/", "sourceMaps": true, "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. ## 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`: ```diff - 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: ```diff - 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`: ```javascript 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.config.cjs`: ```javascript 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, 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"], testMatch: ["**/*.spec.ts"], ... }; ``` 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`: ```javascript 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`: ```json { "name": "...", "version": "...", "type": "module", "scripts": { "build": "tsc && swc src --out-dir dist", "test": "node --experimental-vm-modules --no-compilation-cache node_modules/.bin/jest --runInBand --config=jest.config.cjs", "test-e2e": "node --experimental-vm-modules --no-compilation-cache node_modules/.bin/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`: ```typescript 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`: ```typescript import { ErrorBase } from "./error-base.js"; export class NotFoundError extends ErrorBase {} ``` ## 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.