Мокирование статических ресурсов в тестах

При тестировании JavaScript-приложений через Jest и Webpack часто возникает проблема обработки статических файлов:

  • изображений;
  • SVG;
  • шрифтов;
  • CSS;
  • SCSS;
  • LESS;
  • media-файлов;
  • Webpack-алиасов;
  • CSS Modules.

Webpack умеет работать с такими ресурсами благодаря loader-механизму, однако среда Jest не использует Webpack во время выполнения тестов. Из-за этого импорт файла может приводить к ошибкам:

SyntaxError: Unexpected token
Cannot find module './styles.scss'

или:

Jest encountered an unexpected token

Мокирование статических ресурсов позволяет:

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

Почему Jest не понимает статические ресурсы

Webpack преобразует импорты при помощи loader:

import './styles.scss';
import logo from './logo.png';

Во время сборки Webpack:

  • компилирует SCSS;
  • копирует изображения;
  • генерирует хешированные пути;
  • внедряет CSS в bundle.

Jest работает иначе:

  • не запускает Webpack;
  • исполняет файлы напрямую через Node.js;
  • не знает, что делать с .scss, .png, .svg.

Поэтому требуется дополнительная конфигурация.


Подходы к мокированию

Существует несколько основных стратегий:

Подход Назначение
moduleNameMapper Подмена файлов-заглушек
identity-obj-proxy Мок CSS Modules
Ручные mock-файлы Гибкое мокирование
Трансформеры Обработка ресурсов
jest.mock() Точечное мокирование

moduleNameMapper

Основной механизм мокирования статических ресурсов в Jest — moduleNameMapper.

Пример:

module.exports = {
  moduleNameMapper: {
    '\\.(css|scss)$': '<rootDir>/__mocks__/styleMock.js',
    '\\.(png|jpg|svg)$': '<rootDir>/__mocks__/fileMock.js',
  },
};

Мокирование CSS

Простейший styleMock

module.exports = {};

Файл:

__mocks__/styleMock.js

Теперь импорт:

import './styles.scss';

не вызовет ошибку.


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

fileMock.js

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

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

module.exports = {
  moduleNameMapper: {
    '\\.(jpg|jpeg|png|gif|svg)$':
      '<rootDir>/__mocks__/fileMock.js',
  },
};

Теперь:

import logo from './logo.png';

вернет:

'test-file-stub'

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

На практике обычно мокируются все основные типы ресурсов.

jest.config.js

module.exports = {
  moduleNameMapper: {
    '\\.(css|less|scss|sass)$':
      '<rootDir>/__mocks__/styleMock.js',

    '\\.(gif|ttf|eot|svg|png|jpg|jpeg)$':
      '<rootDir>/__mocks__/fileMock.js',
  },
};

Мокирование CSS Modules

Проблема CSS Modules

При использовании CSS Modules:

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

button.className = styles.primary;

обычный мок:

module.exports = {};

сломает тесты:

styles.primary === undefined

identity-obj-proxy

Для CSS Modules используется пакет:

identity-obj-proxy

Установка:

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

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

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

    '\\.(css|scss)$':
      '<rootDir>/__mocks__/styleMock.js',
  },
};

Как работает identity-obj-proxy

Импорт:

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

Класс:

styles.primary

вернет:

'primary'

Это особенно важно для:

  • snapshot-тестов;
  • проверки className;
  • React Testing Library;
  • Enzyme.

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

Button.module.scss

.primary {
  color: white;
}

Button.jsx

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

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

Button.test.jsx

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

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

  const button = screen.getByRole('button');

  expect(button.className).toBe('primary');
});

Мокирование SVG

SVG может использоваться несколькими способами:

import icon from './icon.svg';

или:

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

Проблема ReactComponent

Webpack через @svgr/webpack умеет превращать SVG в React-компонент.

Jest — нет.


Мокирование SVG-компонентов

svgMock.js

module.exports = {
  __esModule: true,
  default: 'SvgMock',
  ReactComponent: 'svg',
};

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

module.exports = {
  moduleNameMapper: {
    '\\.svg$': '<rootDir>/__mocks__/svgMock.js',
  },
};

Использование в тестах

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

console.log(Icon);
console.log(ReactComponent);

Результат:

'SvgMock'
'svg'

Мокирование шрифтов

Шрифты также импортируются Webpack:

import './fonts/Roboto.woff2';

Jest не умеет их обрабатывать.


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

module.exports = {
  moduleNameMapper: {
    '\\.(woff|woff2|eot|ttf|otf)$':
      '<rootDir>/__mocks__/fileMock.js',
  },
};

Мокирование media-файлов

Аудио и видео:

import sound from './sound.mp3';
import video from './movie.mp4';

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

module.exports = {
  moduleNameMapper: {
    '\\.(mp4|mp3|wav|avi)$':
      '<rootDir>/__mocks__/fileMock.js',
  },
};

Объединение всех ресурсов

Практическая конфигурация

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

    '\\.(css|scss|sass|less)$':
      '<rootDir>/__mocks__/styleMock.js',

    '\\.(jpg|jpeg|png|gif|webp|svg)$':
      '<rootDir>/__mocks__/fileMock.js',

    '\\.(woff|woff2|eot|ttf|otf)$':
      '<rootDir>/__mocks__/fileMock.js',

    '\\.(mp4|mp3|wav)$':
      '<rootDir>/__mocks__/fileMock.js',
  },
};

Разделение обычных стилей и CSS Modules

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

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

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

Первое правило перехватит все файлы.

Правильно:

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

transform и transformIgnorePatterns

Иногда статические ресурсы проходят через transform.

Пример:

module.exports = {
  transform: {
    '^.+\\.[jt]sx?$': 'babel-jest',
  },
};

Статические ресурсы обычно исключаются из transform и мокируются отдельно.


Когда нужны трансформеры вместо моков

Мок подходит не всегда.

Иногда необходимо:

  • реально обрабатывать SVG;
  • генерировать CSS;
  • анализировать содержимое файлов;
  • тестировать loader-поведение.

В этом случае используются:

  • jest-transform-stub;
  • пользовательские transform;
  • babel-jest;
  • ts-jest.

jest-transform-stub

Пакет:

jest-transform-stub

Установка:

npm install --save-dev jest-transform-stub

Использование transform

module.exports = {
  transform: {
    '^.+\\.(css|scss|png|jpg|svg)$':
      'jest-transform-stub',
  },
};

Отличие transform от moduleNameMapper

moduleNameMapper

Подменяет импорт:

import './style.scss';

на mock-модуль.

transform

Преобразует содержимое файла перед исполнением.


Когда лучше использовать moduleNameMapper

Подходит для:

  • unit-тестов;
  • React-компонентов;
  • snapshot-тестов;
  • большинства frontend-проектов.

Когда нужен transform

Подходит для:

  • сложных SVG;
  • нестандартных импортов;
  • интеграционных тестов;
  • собственных loader;
  • анализа содержимого ресурсов.

Мокирование через jest.mock

Иногда мок требуется только в одном тесте.

Пример

jest.mock('./logo.png', () => 'logo-file');

Мокирование SVG-компонента

jest.mock('./icon.svg', () => ({
  __esModule: true,
  default: 'Icon',
  ReactComponent: 'svg',
}));

Ручные mock-директории

Jest поддерживает автоматические mocks.

Структура

__mocks__/
  fileMock.js
  styleMock.js
  svgMock.js

Универсальный fileMock

Иногда полезно возвращать имя файла.

Пример

module.exports = 'mock-file';

или:

module.exports = {
  src: 'mock-file',
};

Интеграция с TypeScript

TypeScript не знает о статических импортируемых файлах.

Появляются ошибки:

Cannot find module './logo.svg'

Объявление модулей

declarations.d.ts

declare module '*.scss';
declare module '*.css';

declare module '*.png' {
  const value: string;
  export default value;
}

declare module '*.svg' {
  const value: string;
  export default value;
}

SVG ReactComponent в TypeScript

declare module '*.svg' {
  import React = require('react');

  export const ReactComponent:
    React.FC<React.SVGProps<SVGSVGElement>>;

  const src: string;

  export default src;
}

Интеграция с React Testing Library

Статические ресурсы часто используются в компонентах.

Компонент

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 logo', () => {
  render(<Header />);

  const image = screen.getByAltText('logo');

  expect(image.src).toContain('test-file-stub');
});

Snapshot-тесты и мокирование

Без мокирования snapshot может содержать:

  • длинные пути;
  • бинарные данные;
  • хешированные ресурсы;
  • нестабильные значения.

Моки стабилизируют snapshots.


Пример snapshot

Без моков

<img src="/assets/logo.8f23ab1.png">

С моками

<img src="test-file-stub">

Мокирование Webpack aliases

Webpack позволяет:

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

Jest не знает об alias.


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

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

Комбинация alias и статических ресурсов

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

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

    '\\.(scss|css)$':
      '<rootDir>/__mocks__/styleMock.js',

    '\\.(png|svg|jpg)$':
      '<rootDir>/__mocks__/fileMock.js',
  },
};

Ошибки при мокировании

Неправильный RegExp

Ошибка:

'.css$'

Правильно:

'\\.css$'

Отсутствие rootDir

Ошибка:

'__mocks__/fileMock.js'

Правильно:

'<rootDir>/__mocks__/fileMock.js'

Конфликт правил

Неправильный порядок:

'\\.(css|scss)$'
'\\.module\\.(css|scss)$'

Специфичные правила всегда размещаются выше общих.


ESM и мокирование ресурсов

При использовании ESM:

export default 'file';

CommonJS-вариант может вызывать ошибки.


ESM mock

export default 'test-file-stub';

или:

export default {};

Совместимость CommonJS и ESM

Универсальный mock:

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

Мокирование в monorepo

В monorepo часто используется общий Jest preset.

Пример

module.exports = {
  moduleNameMapper: {
    '\\.(css|scss)$':
      '<rootDir>/config/jest/styleMock.js',
  },
};

Централизация mocks

Крупные проекты обычно выносят mocks:

config/
  jest/
    fileMock.js
    styleMock.js
    svgMock.js

Это упрощает:

  • поддержку;
  • обновление конфигурации;
  • повторное использование;
  • миграции.

Практическая production-конфигурация

jest.config.js

module.exports = {
  testEnvironment: 'jsdom',

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

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

    '\\.(css|scss|sass|less)$':
      '<rootDir>/config/jest/styleMock.js',

    '\\.(jpg|jpeg|png|gif|svg|webp)$':
      '<rootDir>/config/jest/fileMock.js',

    '\\.(woff|woff2|eot|ttf|otf)$':
      '<rootDir>/config/jest/fileMock.js',
  },

  transform: {
    '^.+\\.[jt]sx?$': 'babel-jest',
  },
};

Практические рекомендации

Для большинства проектов достаточно:

  • moduleNameMapper;
  • styleMock;
  • fileMock;
  • identity-obj-proxy.

SVG требует отдельного внимания

Особенно при использовании:

  • SVGR;
  • ReactComponent;
  • inline SVG.

transform нужен редко

В большинстве unit-тестов достаточно обычных mock-файлов.


CSS Modules лучше мокировать через identity-obj-proxy

Это сохраняет:

  • реальные имена классов;
  • корректные snapshot;
  • совместимость с RTL.

Унификация mocks упрощает поддержку

Единая директория:

config/jest/

делает конфигурацию предсказуемой и удобной для масштабирования.