Setup

Contents

This chapter covers setting up Jest for your Enonic app.

Sample project

You may follow the steps in this guide to configure Jest for an existing project. For simplicity, we will base this tutorial on the TypeScript starter.

To create a project for the tutorial, run the following command:

enonic create jest-tutorial -r starter-ts

This will create an app and place it in a folder called jest-tutorial.

Node modules

In order to write tests in TypeScript and run them using Jest, a few Node modules need to be installed.

Run the following command at the root of the Enonic project you created in the previous step:

npm install --save-dev jest jest-environment-jsdom @swc/core @swc/jest

Configuration

Next, you will need to add and tune a few configuration files:

jest.config.mjs

Jest needs to be configured to run Typescript tests. This is done by creating a jest.config.mjs file in the root of the project:

jest.config.mjs
const DIR_SRC = 'src/main/resources';
const DIR_SRC_JEST = 'src/jest';
const DIR_SRC_JEST_CLIENT = `${DIR_SRC_JEST}/client`;
const DIR_SRC_JEST_SERVER = `${DIR_SRC_JEST}/server`;
const AND_BELOW = '**';
const SOURCE_FILES = `*.{ts,tsx}`;
const TEST_EXT = `{spec,test}.{ts,tsx}`;
const TEST_FILES = `*.${TEST_EXT}`;


// TypeScript (and TSX) test files are transpiled by SWC. Unlike ts-jest, SWC
// only strips types — it does not type-check. Type errors in tests are caught
// by your editor and the `check:types` script.
const transform = {
  '^.+\\.(ts|js)x?$': [
    '@swc/jest',
    {
      jsc: {
        parser: {
          syntax: 'typescript',
          tsx: true,
        },
        target: 'es2022',
      },
      sourceMaps: 'inline', // Important to get correct line numbers when running coverage tests
    },
  ],
};

const commonConfig = {
  collectCoverageFrom: [
    `${DIR_SRC}/${AND_BELOW}/${SOURCE_FILES}`,
  ],

  transform,
};

const clientSideConfig = {
  ...commonConfig,
  displayName: {
    color: 'white',
    name: 'CLIENT',
  },

  // A map from regular expressions to module names or to arrays of module
  // names that allow to stub out resources, like images or styles with a
  // single module.
  // Use <rootDir> string token to refer to rootDir value if you want to use
  // file paths.
  // Additionally, you can substitute captured regex groups using numbered
  // backreferences.
  moduleNameMapper: {
    '/assets/(.*)': `<rootDir>/${DIR_SRC}/assets/$1`,
  },

  // Run clientside tests with DOM globals such as document and window
  testEnvironment: 'jsdom',

  // The glob patterns Jest uses to detect test files. By default it looks for
  // .js, .jsx, .ts and .tsx files inside of __tests__ folders, as well as any
  // files with a suffix of .test or .spec (e.g. Component.test.js or
  // Component.spec.js). It will also find files called test.js or spec.js.
  // (default: [
  //   "**/__tests__/**/*.[jt]s?(x)",
  //   "**/?(*.)+(spec|test).[jt]s?(x)"
  // ])
  testMatch: [
    `<rootDir>/${DIR_SRC_JEST_CLIENT}/${AND_BELOW}/${TEST_FILES}`,
  ],
};

const serverSideConfig = {
  ...commonConfig,
  displayName: {
    color: 'blue',
    name: 'SERVER',
  },

  // A set of global variables that need to be available in all test
  // environments.
  // If you specify a global reference value (like an object or array) here,
  // and some code mutates that value in the midst of running a test, that
  // mutation will not be persisted across test runs for other test files.
  // In addition, the globals object must be json-serializable, so it can't be
  // used to specify global functions. For that, you should use setupFiles.
  globals: {
    app: {
      name: 'com.example.myproject',
      config: {},
      version: '1.0.0'
    },
  },

  // A map from regular expressions to module names or to arrays of module
  // names that allow to stub out resources, like images or styles with a
  // single module.
  // Use <rootDir> string token to refer to rootDir value if you want to use
  // file paths.
  // Additionally, you can substitute captured regex groups using numbered
  // backreferences.
  moduleNameMapper: {
    '/lib/myproject/(.*)': `<rootDir>/${DIR_SRC}/lib/myproject/$1`,
  },

  // Run serverside tests without DOM globals such as document and window
  testEnvironment: 'node',

  // The glob patterns Jest uses to detect test files. By default it looks for
  // .js, .jsx, .ts and .tsx files inside of __tests__ folders, as well as any
  // files with a suffix of .test or .spec (e.g. Component.test.js or
  // Component.spec.js). It will also find files called test.js or spec.js.
  // (default: [
  //   "**/__tests__/**/*.[jt]s?(x)",
  //   "**/?(*.)+(spec|test).[jt]s?(x)"
  // ])
  testMatch: [
    `<rootDir>/${DIR_SRC_JEST_SERVER}/${AND_BELOW}/${TEST_FILES}`,
  ],
};

/** @type {import('jest').Config} */
const customJestConfig = {
  coverageProvider: 'v8', // To get correct line numbers under jsdom
  passWithNoTests: true,
  projects: [clientSideConfig, serverSideConfig],
};

export default customJestConfig;

Test files are transpiled by SWC (via @swc/jest), which strips the types without checking them — type errors in tests are caught by your editor. This is also why the config is a plain .mjs file: the project uses TypeScript 7, the native compiler, whose npm package does not ship the JavaScript compiler API that ts-jest and ts-node (needed for a jest.config.ts) depend on.

Read more about configuring Jest here.

tsconfig.json

In an Enonic Typescript project there can be both server-side and client-side code. In order for such code to be properly type checked and compiled, multiple tsconfig.json files are used.

Test files need a slightly different Typescript configuration than the sources they test. Jest itself does not read these files (SWC only strips types), but they give your editor the right type context for the test files. This is the purpose of the src/jest/client/tsconfig.json and src/jest/server/tsconfig.json files:

src/jest/client/tsconfig.json
{
  "extends": "../../main/resources/assets/tsconfig.json",

  "include": [
    "./**/*.spec.ts",
    "./**/*.spec.tsx",
    "./**/*.test.ts",
    "./**/*.test.tsx",
  ],

  "compilerOptions": {
    // "baseUrl": ".",
    "esModuleInterop": true,
    // A series of entries which re-map imports to lookup locations relative
    // to the baseUrl if set, or to the tsconfig file itself otherwise.
    "paths": {
      "/assets/*": ["../../main/resources/assets/*"],
    },

    "sourceMap": true, // Important to get correct line numbers when running coverage tests

    // By default all visible ”@types” packages are included in your
    // compilation. Packages in node_modules/@types of any enclosing folder
    // are considered visible. For example, that means packages within
    // ./node_modules/@types/, ../node_modules/@types/,
    // ../../node_modules/@types/, and so on.
    // If types is specified, only packages listed will be included in the
    // global scope.
    // This feature differs from typeRoots in that it is about specifying
    // only the exact types you want included, whereas typeRoots supports
    // saying you want particular folders.
    // "types": [
      // "jest", // Doesn't even work for test files in this folder?
    // ],

  }, // compilerOptions

}
src/jest/server/tsconfig.json
{
  "extends": "../../main/resources/tsconfig.json",

  // Specifies an array of filenames or patterns to include in the program.
  // These filenames are resolved relative to the directory containing the
  // tsconfig.json file.
  "include": [
    "./**/*.spec.ts",
    "./**/*.spec.tsx",
    "./**/*.test.ts",
    "./**/*.test.tsx",
  ],

  "compilerOptions": {
    // Import CommonJS modules in compliance with es6 modules spec
    "esModuleInterop": true,

    // A series of entries which re-map imports to lookup locations relative
    // to the baseUrl if set, or to the tsconfig file itself otherwise.
    "paths": {
      // Keep in sync with src/main/resources/tsconfig.json — explicit entries
      // make the IDE's auto-import suggest the runtime-correct specifiers.
      "/lib/xp/admin": ["../../../node_modules/@enonic-types/lib-admin/admin"],
      "/lib/xp/app": ["../../../node_modules/@enonic-types/lib-app/app"],
      "/lib/xp/auditlog": ["../../../node_modules/@enonic-types/lib-auditlog/auditlog"],
      "/lib/xp/auth": ["../../../node_modules/@enonic-types/lib-auth/auth"],
      "/lib/xp/cluster": ["../../../node_modules/@enonic-types/lib-cluster/cluster"],
      "/lib/xp/common": ["../../../node_modules/@enonic-types/lib-common/common"],
      "/lib/xp/content": ["../../../node_modules/@enonic-types/lib-content/content"],
      "/lib/xp/context": ["../../../node_modules/@enonic-types/lib-context/context"],
      "/lib/xp/event": ["../../../node_modules/@enonic-types/lib-event/event"],
      "/lib/xp/export": ["../../../node_modules/@enonic-types/lib-export/export"],
      "/lib/xp/grid": ["../../../node_modules/@enonic-types/lib-grid/grid"],
      "/lib/xp/i18n": ["../../../node_modules/@enonic-types/lib-i18n/i18n"],
      "/lib/xp/io": ["../../../node_modules/@enonic-types/lib-io/io"],
      "/lib/xp/mail": ["../../../node_modules/@enonic-types/lib-mail/mail"],
      "/lib/xp/node": ["../../../node_modules/@enonic-types/lib-node/node"],
      "/lib/xp/portal": ["../../../node_modules/@enonic-types/lib-portal/portal"],
      "/lib/xp/project": ["../../../node_modules/@enonic-types/lib-project/project"],
      "/lib/xp/repo": ["../../../node_modules/@enonic-types/lib-repo/repo"],
      "/lib/xp/scheduler": ["../../../node_modules/@enonic-types/lib-scheduler/scheduler"],
      "/lib/xp/schema": ["../../../node_modules/@enonic-types/lib-schema/schema"],
      "/lib/xp/sse": ["../../../node_modules/@enonic-types/lib-sse/sse"],
      "/lib/xp/task": ["../../../node_modules/@enonic-types/lib-task/task"],
      "/lib/xp/value": ["../../../node_modules/@enonic-types/lib-value/value"],
      "/lib/xp/vhost": ["../../../node_modules/@enonic-types/lib-vhost/vhost"],
      "/lib/xp/websocket": ["../../../node_modules/@enonic-types/lib-websocket/websocket"],
      "/lib/xp/*": ["../../../node_modules/@enonic-types/lib-*"],
      "/*": ["../../main/resources/*"],
    },
    "sourceMap": true, // Important to get correct line numbers when running coverage tests
    "types": [
      "@enonic-types/global",
      // "jest", // Doesn't even work for test files in this folder?
      "node", // console
    ],
  }, // compilerOptions
}

Execution

Finally, we wire the test command to execute Jest tests.

Using NPM

In order to run tests the following needs to be added to the package.json file:

package.json
{
  "scripts": {
    "test": "jest --no-cache --coverage"
  }
}

You should now be able to run the tests with the following command:

npm test
You may also run a specific test like this: npm test src/jest/server/fibonacci.test.ts

Using Enonic CLI

You may also run the tests using Enonic CLI. CLI uses the default build system (Gradle), so you must update the build.gradle file with the following lines:

build.gradle
tasks.register('npmTest', NpmTask) {
    args = [
        'run',
        'test'
    ]
    dependsOn npmInstall
    environment = [
        'FORCE_COLOR': 'true',
    ]
    inputs.dir 'src/jest'
    outputs.dir 'coverage'
}

test.dependsOn npmTest

As you can see, this simply tells Gradle to invoke the NPM’s test script. You may now test with the following command as well:

enonic project test

Summary

With the above setup in place, it is possible to write tests in Typescript and run them using Jest. The tests will be run on every build and the coverage will be reported.

Let’s learn how to write our first test.


Contents

Contents