Webpack умеет обрабатывать практически любые типы модулей:
Во время обычной сборки браузер получает уже обработанный результат. Однако тестовый раннер работает иначе. Например, Jest запускает код напрямую в Node.js и не использует Webpack как полноценный bundler.
Из-за этого возникают типичные ошибки:
Cannot find module '@/components/Button'
Unexpected token '.scss'
SyntaxError: Unexpected token '<'
Cannot find module './logo.svg'
Причина заключается в том, что Node.js не понимает:
Для решения этой проблемы Jest предоставляет механизм
moduleNameMapper.
Webpack — это сборщик.
Jest — это тестовый раннер.
Во время выполнения тестов Jest:
Webpack в этот процесс не входит.
Например, такой импорт корректно работает в приложении:
import Button from '@/components/Button';
Потому что в Webpack настроен alias:
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
}
Но Jest о нём ничего не знает.
moduleNameMapper позволяет:
Конфигурация находится в:
jest.config.js
или:
package.json
Пример:
module.exports = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
}
};
Структура:
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'
}
Без неё пути могут вычисляться неверно при запуске из разных директорий.
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@components': path.resolve(__dirname, 'src/components'),
'@utils': path.resolve(__dirname, 'src/utils')
}
}
module.exports = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@components/(.*)$': '<rootDir>/src/components/$1',
'^@utils/(.*)$': '<rootDir>/src/utils/$1'
}
};
Jest не умеет импортировать стили:
import './Button.scss';
Возникает ошибка:
Unexpected token '.'
Установка:
npm install identity-obj-proxy --save-dev
Настройка:
module.exports = {
moduleNameMapper: {
'\\.(css|scss)$': 'identity-obj-proxy'
}
};
Если компонент использует CSS Modules:
import styles from './Button.module.scss';
styles.button
Jest получит объект:
{
button: 'button'
}
Это позволяет:
Компонент:
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 Modules не используются, можно подключить простой mock.
module.exports = {};
moduleNameMapper: {
'\\.(css|scss)$': '<rootDir>/test/styleMock.js'
}
Webpack умеет импортировать:
import logo from './logo.png';
Jest — нет.
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();
});
Webpack часто использует:
import Icon from './icon.svg';
через:
@svgr/webpack
Jest этого не понимает.
module.exports = 'svg';
module.exports.ReactComponent = 'svg';
moduleNameMapper: {
'\\.svg$': '<rootDir>/test/svgMock.js'
}
Компонент:
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 />);
});
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"]
}
}
}
module.exports = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@components/(.*)$':
'<rootDir>/src/components/$1'
}
};
Ручное дублирование конфигурации неудобно.
Для TypeScript существует:
npm install ts-jest --save-dev
const { pathsToModuleNameMapper } =
require('ts-jest');
const { compilerOptions } =
require('./tsconfig');
module.exports = {
moduleNameMapper:
pathsToModuleNameMapper(
compilerOptions.paths,
{
prefix: '<rootDir>/'
}
)
};
Некоторые библиотеки поставляются только как ESM:
import lodash from 'lodash-es';
Jest может выдавать:
SyntaxError: Cannot use import statement outside a module
moduleNameMapper: {
'^lodash-es$': 'lodash'
}
Иногда тяжёлые библиотеки мешают тестированию.
Например:
import Chart from 'chart.js';
Можно заменить библиотеку mock-версией.
module.exports = {};
moduleNameMapper: {
'^chart.js$':
'<rootDir>/test/chartMock.js'
}
monaco-editor плохо работает в jsdom.
moduleNameMapper: {
'^monaco-editor$':
'<rootDir>/test/monacoMock.js'
}
module.exports = {
editor: {
create: jest.fn()
}
};
Большие проекты обычно используют отдельные mock-файлы:
test/
mocks/
fileMock.js
styleMock.js
svgMock.js
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:
npm install babel-jest @babel/core
То alias в Babel тоже должны совпадать.
plugins: [
[
'module-resolver',
{
alias: {
'@': './src'
}
}
]
]
Иначе:
Типичная проблема:
| Инструмент | Alias |
|---|---|
| Webpack | Есть |
| TypeScript | Есть |
| Jest | Нет |
| Babel | Нет |
Результат:
Наиболее стабильная архитектура:
tsconfig.json
↓
Webpack
↓
Jest
↓
Babel
TypeScript paths становятся главным источником alias.
Дополнительно можно использовать:
moduleDirectories: [
'node_modules',
'src'
]
Тогда импорты:
import Button from 'components/Button';
будут работать без alias.
Однако такой подход менее явный и иногда вызывает конфликты имён.
Главная идея Jest:
Это делает тесты:
Но требует ручной синхронизации:
Именно moduleNameMapper становится центральным
механизмом адаптации webpack-проекта под среду Jest.