Запуск тестов без сборки: Jest и moduleNameMapper

Webpack умеет обрабатывать практически любые типы модулей:

  • CSS и SCSS
  • изображения
  • SVG
  • TypeScript
  • алиасы путей
  • динамические импорты
  • нестандартные расширения файлов

Во время обычной сборки браузер получает уже обработанный результат. Однако тестовый раннер работает иначе. Например, Jest запускает код напрямую в Node.js и не использует Webpack как полноценный bundler.

Из-за этого возникают типичные ошибки:

Cannot find module '@/components/Button'
Unexpected token '.scss'
SyntaxError: Unexpected token '<'
Cannot find module './logo.svg'

Причина заключается в том, что Node.js не понимает:

  • webpack alias
  • импорт стилей
  • asset-модули
  • специальные loader’ы
  • webpack-specific конфигурацию

Для решения этой проблемы Jest предоставляет механизм moduleNameMapper.


Почему Jest не запускает Webpack

Webpack — это сборщик.

Jest — это тестовый раннер.

Во время выполнения тестов Jest:

  1. читает исходные файлы;
  2. преобразует их через Babel или ts-jest;
  3. запускает в среде Node.js;
  4. эмулирует браузер через jsdom.

Webpack в этот процесс не входит.

Например, такой импорт корректно работает в приложении:

import Button from '@/components/Button';

Потому что в Webpack настроен alias:

resolve: {
  alias: {
    '@': path.resolve(__dirname, 'src')
  }
}

Но Jest о нём ничего не знает.


Назначение moduleNameMapper

moduleNameMapper позволяет:

  • переопределять пути модулей;
  • подменять импорты;
  • игнорировать ресурсы;
  • эмулировать alias;
  • мокать стили и изображения.

Конфигурация находится в:

jest.config.js

или:

package.json

Пример:

module.exports = {
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  }
};

Синтаксис moduleNameMapper

Структура:

moduleNameMapper: {
  'regex': 'replacement'
}

Левая часть:

'^@/(.*)$'

— регулярное выражение.

Правая часть:

'<rootDir>/src/$1'

— путь замены.


Разбор регулярного выражения

Символ ^

Начало строки:

'^@/'

Совпадение только если строка начинается с @/.


Конструкция (.*)

Захватывает остаток строки:

@/components/Button

Сохраняется как:

components/Button

Символ $

Конец строки.

Полное выражение:

'^@/(.*)$'

означает:

«Любая строка, начинающаяся с @/ и заканчивающаяся чем угодно».


Переменная $1

Содержит результат первой группы:

'<rootDir>/src/$1'

Преобразование:

@/utils/math

<rootDir>/src/utils/math

Использование <rootDir>

<rootDir> — специальная переменная Jest.

Она указывает на корень проекта.

Пример:

moduleNameMapper: {
  '^@/(.*)$': '<rootDir>/src/$1'
}

Без неё пути могут вычисляться неверно при запуске из разных директорий.


Настройка alias из Webpack

Конфигурация Webpack

resolve: {
  alias: {
    '@': path.resolve(__dirname, 'src'),
    '@components': path.resolve(__dirname, 'src/components'),
    '@utils': path.resolve(__dirname, 'src/utils')
  }
}

Эквивалент для Jest

module.exports = {
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '^@components/(.*)$': '<rootDir>/src/components/$1',
    '^@utils/(.*)$': '<rootDir>/src/utils/$1'
  }
};

Обработка CSS-файлов

Jest не умеет импортировать стили:

import './Button.scss';

Возникает ошибка:

Unexpected token '.'

Игнорирование CSS через identity-obj-proxy

Установка:

npm install identity-obj-proxy --save-dev

Настройка:

module.exports = {
  moduleNameMapper: {
    '\\.(css|scss)$': 'identity-obj-proxy'
  }
};

Что делает identity-obj-proxy

Если компонент использует CSS Modules:

import styles from './Button.module.scss';

styles.button

Jest получит объект:

{
  button: 'button'
}

Это позволяет:

  • не ломать тесты;
  • проверять className;
  • запускать компоненты без CSS-loader.

Проверка CSS Modules

Компонент:

import styles from './Button.module.scss';

export function Button() {
  return <button className={styles.primary}>Save</button>;
}

Тест:

import { render, screen } from '@testing-library/react';
import { Button } from './Button';

test('button class exists', () => {
  render(<Button />);

  expect(screen.getByRole('button'))
    .toHaveClass('primary');
});

Обработка обычных CSS-файлов

Если CSS Modules не используются, можно подключить простой mock.


styleMock.js

module.exports = {};

Конфигурация Jest

moduleNameMapper: {
  '\\.(css|scss)$': '<rootDir>/test/styleMock.js'
}

Мокирование изображений

Webpack умеет импортировать:

import logo from './logo.png';

Jest — нет.


fileMock.js

module.exports = 'test-file-stub';

Настройка

moduleNameMapper: {
  '\\.(jpg|jpeg|png|gif|svg)$':
    '<rootDir>/test/fileMock.js'
}

Проверка изображений

Компонент:

import logo from './logo.png';

export function Header() {
  return <img src={logo} alt="logo" />;
}

Тест:

import { render, screen } from '@testing-library/react';
import { Header } from './Header';

test('renders image', () => {
  render(<Header />);

  expect(screen.getByAltText('logo'))
    .toBeInTheDocument();
});

Обработка SVG как React-компонентов

Webpack часто использует:

import Icon from './icon.svg';

через:

@svgr/webpack

Jest этого не понимает.


svgMock.js

module.exports = 'svg';
module.exports.ReactComponent = 'svg';

Настройка

moduleNameMapper: {
  '\\.svg$': '<rootDir>/test/svgMock.js'
}

Пример тестирования SVG

Компонент:

import { ReactComponent as Icon } from './icon.svg';

export function CloseButton() {
  return <Icon />;
}

Тест:

import { render } from '@testing-library/react';
import { CloseButton } from './CloseButton';

test('renders svg icon', () => {
  render(<CloseButton />);
});

Работа с TypeScript path aliases

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"]
    }
  }
}

Настройка Jest

module.exports = {
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '^@components/(.*)$':
      '<rootDir>/src/components/$1'
  }
};

Автоматическая синхронизация aliases

Ручное дублирование конфигурации неудобно.

Для TypeScript существует:

npm install ts-jest --save-dev

pathsToModuleNameMapper

const { pathsToModuleNameMapper } =
  require('ts-jest');

const { compilerOptions } =
  require('./tsconfig');

module.exports = {
  moduleNameMapper:
    pathsToModuleNameMapper(
      compilerOptions.paths,
      {
        prefix: '<rootDir>/'
      }
    )
};

Проблема ESM-модулей

Некоторые библиотеки поставляются только как ESM:

import lodash from 'lodash-es';

Jest может выдавать:

SyntaxError: Cannot use import statement outside a module

Маппинг ESM на CommonJS

moduleNameMapper: {
  '^lodash-es$': 'lodash'
}

Подмена библиотек

Иногда тяжёлые библиотеки мешают тестированию.

Например:

import Chart from 'chart.js';

Можно заменить библиотеку mock-версией.


chartMock.js

module.exports = {};

Настройка

moduleNameMapper: {
  '^chart.js$':
    '<rootDir>/test/chartMock.js'
}

Мокирование Monaco Editor

monaco-editor плохо работает в jsdom.


Конфигурация

moduleNameMapper: {
  '^monaco-editor$':
    '<rootDir>/test/monacoMock.js'
}

monacoMock.js

module.exports = {
  editor: {
    create: jest.fn()
  }
};

Разделение конфигураций

Большие проекты обычно используют отдельные mock-файлы:

test/
  mocks/
    fileMock.js
    styleMock.js
    svgMock.js

Полная конфигурация Jest

module.exports = {
  testEnvironment: 'jsdom',

  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',

    '\\.(css|scss)$':
      'identity-obj-proxy',

    '\\.(jpg|jpeg|png|gif|svg)$':
      '<rootDir>/test/mocks/fileMock.js'
  }
};

Порядок проверки правил

Jest проверяет правила сверху вниз.

Например:

moduleNameMapper: {
  '\\.svg$': '<rootDir>/svgMock.js',
  '\\.(jpg|png|svg)$':
    '<rootDir>/fileMock.js'
}

Первое совпадение победит.

Поэтому специфичные правила должны идти раньше общих.


Ошибки регулярных выражений

Отсутствие экранирования

Неправильно:

'.css$'

Точка означает «любой символ».

Правильно:

'\\.css$'

Отсутствие якорей

Неправильно:

'@/'

Совпадение может произойти случайно.

Правильно:

'^@/'

Поддержка нескольких расширений

'\\.(css|scss|sass|less)$'

Использование массива путей

Jest поддерживает массив replacements:

moduleNameMapper: {
  '^assets/(.*)$': [
    '<rootDir>/src/assets/$1',
    '<rootDir>/public/assets/$1'
  ]
}

Поиск выполняется по порядку.


Совместимость с Babel

Если используется Babel:

npm install babel-jest @babel/core

То alias в Babel тоже должны совпадать.


Babel module-resolver

plugins: [
  [
    'module-resolver',
    {
      alias: {
        '@': './src'
      }
    }
  ]
]

Иначе:

  • Webpack видит alias;
  • Jest видит alias;
  • Babel — нет.

Конфликт alias между инструментами

Типичная проблема:

Инструмент Alias
Webpack Есть
TypeScript Есть
Jest Нет
Babel Нет

Результат:

  • приложение запускается;
  • тесты падают;
  • IDE показывает ошибки.

Единый источник alias

Наиболее стабильная архитектура:

tsconfig.json
    ↓
Webpack
    ↓
Jest
    ↓
Babel

TypeScript paths становятся главным источником alias.


moduleDirectories

Дополнительно можно использовать:

moduleDirectories: [
  'node_modules',
  'src'
]

Тогда импорты:

import Button from 'components/Button';

будут работать без alias.

Однако такой подход менее явный и иногда вызывает конфликты имён.


Тестирование без Webpack

Главная идея Jest:

  • не собирать проект;
  • не запускать bundler;
  • тестировать исходный код напрямую.

Это делает тесты:

  • быстрее;
  • проще;
  • изолированнее;
  • независимыми от production-сборки.

Но требует ручной синхронизации:

  • alias;
  • asset imports;
  • CSS;
  • SVG;
  • TypeScript paths;
  • mock-модулей.

Именно moduleNameMapper становится центральным механизмом адаптации webpack-проекта под среду Jest.