How to run Jest in OHIF extensions?

Describe Your Question

Hi. I’m using OHIF 3.7.0 and I’m trying to run some Jest tests, but I get the error:

/ohif/dev/viewer/platform/ui/src/index.js:7
    import * as Types from './types';
    ^^^^^^

    SyntaxError: Cannot use import statement outside a module

      1 | import React, { useState } from "react";
    > 2 | import { Select } from "@ohif/ui";
        | ^

    at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1505:14)
      at Object.<anonymous> (src/Form/form.tsx:2:1)
      at Object.<anonymous> (__tests__/Form/form-test.tsx:4:1)

What steps can we follow to reproduce the bug?

  1. Using OHIF 3.7.0, create a new custom extension using the CLI, and display a React Component in it, using the component Select from the ohif/ui module (see my code below)
  2. Install the dependencies (see my code below) and write a test where you render the React Component mentioned in the previous step
  3. Configure Babel, Jest and Typescript using the code below
  4. Run Jest using yarn jest

Project structure

  • ohif
    – dev
    — extensions (for custom extensions)
    — modes (for custom modes)
    — viewer (the default OHIF’s repo)

Code

  • My extension’s package.json file (snippet):
"peerDependencies": {
    "@ohif/core": "^3.0.0",
    "@ohif/extension-cornerstone": "^3.0.0",
    "@ohif/extension-default": "^3.0.0",
    "@ohif/i18n": "^1.0.0",
    "@ohif/ui": "^3.7.0",
    "prop-types": "^15.6.2",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-i18next": "^12.2.2",
    "react-router": "^6.8.1",
    "react-router-dom": "^6.8.1",
    "webpack": "^5.50.0",
    "webpack-merge": "^5.7.3"
  },
  "dependencies": {
    "@babel/runtime": "^7.20.13",
    "@fortawesome/free-solid-svg-icons": "^6.5.2",
    "@fortawesome/react-fontawesome": "^0.2.2",
    "react-i18next": "^14.1.2"
  },
  "devDependencies": {
    "@babel/core": "^7.21.4",
    "@babel/plugin-proposal-class-properties": "^7.16.7",
    "@babel/plugin-proposal-object-rest-spread": "^7.17.3",
    "@babel/plugin-proposal-private-methods": "^7.18.6",
    "@babel/plugin-proposal-private-property-in-object": "7.21.11",
    "@babel/plugin-syntax-dynamic-import": "^7.8.3",
    "@babel/plugin-transform-arrow-functions": "^7.16.7",
    "@babel/plugin-transform-regenerator": "^7.16.7",
    "@babel/plugin-transform-runtime": "^7.17.0",
    "@babel/plugin-transform-typescript": "^7.13.0",
    "@babel/preset-env": "^7.24.6",
    "@babel/preset-react": "^7.24.6",
    "@babel/preset-typescript": "^7.13.0",
    "@testing-library/dom": "^10.1.0",
    "@testing-library/react": "^16.0.0",
    "@types/jest": "^29.5.12",
    "@types/react": "^18.3.3",
    "@types/react-dom": "^18.3.0",
    "babel-eslint": "9.x",
    "babel-jest": "^29.7.0",
    "babel-loader": "^8.2.4",
    "babel-plugin-inline-react-svg": "^2.0.2",
    "babel-plugin-module-resolver": "^5.0.0",
    "clean-webpack-plugin": "^4.0.0",
    "copy-webpack-plugin": "^10.2.0",
    "cross-env": "^7.0.3",
    "dotenv": "^14.1.0",
    "eslint": "^5.0.1",
    "eslint-loader": "^2.0.0",
    "jest": "^29.7.0",
    "jest-environment-jsdom": "^29.7.0",
    "jest-junit": "^16.0.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-test-renderer": "^18.3.1",
    "ts-jest": "^29.1.4",
    "typescript": "^5.4.5",
    "webpack": "^5.50.0",
    "webpack-cli": "^4.7.2",
    "webpack-merge": "^5.7.3"
  }
  • My extension’s babel.config.js file
// https://babeljs.io/docs/en/options#babelrcroots
const { extendDefaultPlugins } = require('svgo');

module.exports = {
  plugins: [
    [
      'inline-react-svg',
      {
        svgo: {
          plugins: extendDefaultPlugins([
            {
              name: 'removeViewBox',
              active: false,
            },
          ]),
        },
      },
    ],
    ['@babel/plugin-proposal-class-properties', { loose: true }],
    '@babel/plugin-transform-typescript',
    ['@babel/plugin-proposal-private-property-in-object', { loose: true }],
    ['@babel/plugin-proposal-private-methods', { loose: true }],
  ],
  env: {
    test: {
      presets: [
        [
          // TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2
          '@babel/preset-env',
          {
            modules: 'commonjs',
            debug: false,
          },
        ],
        '@babel/preset-react',
        '@babel/preset-typescript',
      ],
      plugins: [
        '@babel/plugin-proposal-object-rest-spread',
        '@babel/plugin-syntax-dynamic-import',
        '@babel/plugin-transform-regenerator',
        '@babel/plugin-transform-runtime',
        '@babel/plugin-transform-typescript',
      ],
    },
    production: {
      presets: [
        // WebPack handles ES6 --> Target Syntax
        ['@babel/preset-env', { modules: false }],
        '@babel/preset-react',
        '@babel/preset-typescript',
      ],
      ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
    },
    development: {
      presets: [
        // WebPack handles ES6 --> Target Syntax
        ['@babel/preset-env', { modules: false }],
        '@babel/preset-react',
        '@babel/preset-typescript',
      ],
      plugins: ['react-hot-loader/babel'],
      ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
    },
  },
};
  • My extension’s jest.config.js file
// https://github.com/facebook/jest/issues/3613
// Yarn Doctor: `npx @yarnpkg/doctor .` -->
// '<rootDir>' warning:
// Stringsimport { pathsToModuleNameMapper, JestConfigWithTsJest } from "ts-jest";
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("./tsconfig.json");

module.exports = {
  preset: "ts-jest",
  verbose: true,
  // roots: ['<rootDir>/src'],
  testMatch: ["<rootDir>/src/**/*.test.js", "<rootDir>/__tests__/**/*"],
  testPathIgnorePatterns: ["<rootDir>/node_modules/"],
  testEnvironment: "jsdom",
  moduleFileExtensions: ["js", "jsx", "ts", "tsx"],
  moduleNameMapper: {
    "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
    "<rootDir>/src/__mocks__/fileMock.js",
    "\\.(css|less)$": "identity-obj-proxy",
    ...pathsToModuleNameMapper(compilerOptions.paths, { prefix: "<rootDir>" }),
  },
  // Setup
  // setupFiles: ["jest-canvas-mock/lib/index.js"],
  // Coverage
  reporters: [
    "default",
    // Docs: https://www.npmjs.com/package/jest-junit
    [
      "jest-junit",
      {
        addFileAttribute: true, // CircleCI Only
      },
    ],
  ],
  collectCoverage: false,
  collectCoverageFrom: [
    "<rootDir>/src/**/*.{js,jsx}",
    // Not
    "!<rootDir>/src/**/*.test.js",
    "!**/node_modules/**",
    "!**/__tests__/**",
    "!<rootDir>/dist/**",
  ],
};
  • My extension’s tsconfig.json file:
{
	"compilerOptions": {
		"baseUrl": "./",
		"checkJs": true,
		"isolatedModules": true,
		"emitDeclarationOnly": true,
		"jsx": "react",
		"resolveJsonModule": true,
		"esModuleInterop": true,
		"sourceMap": true,
		"declaration": true,
		"moduleResolution": "node",
		"outDir": "./dist",
		"paths": {
			"@ohif/core": ["../../viewer/platform/core/src"],
			"@ohif/ui": ["../../viewer/platform/ui/src"],
			"@ohif/i18n": ["../../viewer/platform/i18n/src"],
			"@ohif/app": ["../../viewer/platform/app/src"],
			"@ohif/extension-common": ["../common/src"],
			"~/*": ["./src/*"]
		}
	},
	"exclude": ["node_modules", "dist"]
}
  • My extension (snippet):
import React, { useState } from "react";
import { Select } from "@ohif/ui";

const Form = ({ servicesManager, extensionManager, studies, dataSource }) => {
    // Load the cohorts
    const { MyService } = servicesManager.services;
	const cohorts = [{ name: "Select cohort..." }].concat(
		MyService.getState().memberCohorts,
	);
	const [cohort, setCohort] = useState<any | null>(null);

	return (
		<div className="h-[480px]">
			<div className="border-primary-main px-2 py-2">
				<div className="flex flex-row gap-2 py-2">
					<label className="text-base text-primary-light whitespace-nowrap">
						Select the cohort you want to work with:
					</label>
					<Select
						id="cohorts"
						isMulti={false}
						isClearable={false}
						closeMenuOnSelect={true}
						hideSelectedOptions={false}
						options={cohorts.map((c) => ({
							label: c.name,
							value: c.name,
						}))}
						onChange={(v) => setCohort(v)}
						value={cohort}
					/>
				</div>
			</div>
		</div>
	);
};

export default Form;
  • My test:
import React from "react";
import { render } from "@testing-library/react";

import Form from "~/Form/form";

const mockServicesManager = {
  services: {
    MyService: {
      getState: jest.fn().mockReturnValue({
        memberCohorts: [{ name: 'Cohort 1' }, { name: 'Cohort 2' }],
      }),
    },
  },
};

const mockExtensionManager = {};
const mockStudies = [];
const mockDataSource = {};

test("should render", () => {
  render(
    <Form
      servicesManager={mockServicesManager}
      extensionManager={mockExtensionManager}
      studies={mockStudies}
      dataSource={mockDataSource}
    />
  );
})