Тестирование собственных загрузчиков и плагинов

Собственные загрузчики (loaders) и плагины (plugins) являются расширениями внутреннего механизма Webpack. Ошибки в них способны приводить к:

  • повреждению выходной сборки;
  • утечкам памяти;
  • бесконечным пересборкам;
  • деградации производительности;
  • неправильной генерации source map;
  • нестабильному поведению HMR;
  • несовместимости с разными версиями Webpack.

Поэтому тестирование подобных расширений требует более глубокого подхода, чем проверка обычных JavaScript-функций.

Основные категории тестов:

Тип тестирования Назначение
Unit-тесты Проверка изолированной логики
Интеграционные тесты Проверка взаимодействия с Webpack
Snapshot-тесты Контроль структуры результатов
E2E-тесты Проверка полноценной сборки
Performance-тесты Анализ времени выполнения
Compatibility-тесты Проверка версий Webpack и Node.js

Тестирование загрузчиков Webpack

Что необходимо проверять в loader

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

Типичный loader:

module.exports = function loader(source) {
  return source.replace(/DEBUG/g, 'false');
};

Критически важно тестировать:

  • преобразование содержимого;
  • работу с опциями;
  • source maps;
  • асинхронный режим;
  • ошибки;
  • кэширование;
  • совместимость с Webpack API;
  • обработку бинарных данных;
  • корректность контекста this.

Структура проекта для тестирования

Пример организации:

project/
├── src/
│   ├── loader.js
│   └── plugin.js
├── test/
│   ├── fixtures/
│   ├── loaders/
│   ├── plugins/
│   └── integration/
├── jest.config.js
├── package.json
└── webpack.config.js

Инструменты тестирования

Jest

Наиболее популярное решение.

Установка:

npm install --save-dev jest

Минимальная конфигурация:

module.exports = {
  testEnvironment: 'node'
};

Тестирование loader через loader-runner

Библиотека loader-runner

Пакет позволяет запускать loader без полноценной сборки Webpack.

Установка:

npm install --save-dev loader-runner

Базовый unit-тест loader

Исходный loader

module.exports = function(source) {
  return source.toUpperCase();
};

Тест

const path = require('path');
const { runLoaders } = require('loader-runner');

describe('uppercase loader', () => {
  test('converts content to uppercase', done => {
    runLoaders(
      {
        resource: path.resolve(__dirname, './fixture.txt'),
        loaders: [
          path.resolve(__dirname, '../. ./src/loader.js')
        ],
        readResource: (filename, callback) => {
          callback(null, 'hello world');
        }
      },
      (err, result) => {
        expect(err).toBeNull();
        expect(result.result[0]).toBe('HELLO WORLD');

        done();
      }
    );
  });
});

Проверка опций loader

Loader с опциями

module.exports = function(source) {
  const options = this.getOptions();

  if (options.uppercase) {
    return source.toUpperCase();
  }

  return source;
};

Тестирование параметров

runLoaders(
  {
    resource: 'file.txt',
    loaders: [
      {
        loader: loaderPath,
        options: {
          uppercase: true
        }
      }
    ],
    readResource(_, callback) {
      callback(null, 'hello');
    }
  },
  (err, result) => {
    expect(result.result[0]).toBe('HELLO');
  }
);

Тестирование асинхронных loader

Асинхронный loader

module.exports = function(source) {
  const callback = this.async();

  setTimeout(() => {
    callback(null, source.toUpperCase());
  }, 100);
};

Тест

test('async loader works correctly', done => {
  runLoaders(
    {
      resource: 'file.txt',
      loaders: [loaderPath],
      readResource(_, callback) {
        callback(null, 'webpack');
      }
    },
    (err, result) => {
      expect(result.result[0]).toBe('WEBPACK');
      done();
    }
  );
});

Тестирование source maps

Loader с source map

module.exports = function(source, map) {
  const callback = this.async();

  const transformed = source.replace('var', 'const');

  callback(null, transformed, map);
};

Проверка карты исходников

expect(result.result[1]).toBeDefined();

Часто дополнительно проверяются:

  • корректность mappings;
  • наличие sources;
  • сохранение строк;
  • отсутствие сдвига позиций.

Проверка ошибок loader

Генерация ошибки

module.exports = function(source) {
  throw new Error('Loader failed');
};

Тест

test('throws loader error', done => {
  runLoaders(
    {
      resource: 'file.txt',
      loaders: [loaderPath],
      readResource(_, callback) {
        callback(null, 'test');
      }
    },
    err => {
      expect(err).toBeTruthy();
      expect(err.message).toContain('Loader failed');

      done();
    }
  );
});

Тестирование raw-loader режима

Некоторые загрузчики работают с Buffer.

Raw loader

module.exports.raw = true;

module.exports = function(buffer) {
  return buffer.toString('utf8');
};

Проверка

expect(Buffer.isBuffer(input)).toBe(true);

Проверка контекста loader

Контекст содержит множество внутренних API Webpack.

Наиболее важные методы:

Метод Назначение
this.emitFile Генерация файлов
this.addDependency Добавление зависимостей
this.cacheable Управление кэшем
this.getOptions Получение опций
this.async Асинхронный режим

Мокирование loader context

Пример мокирования

const context = {
  query: {},
  cacheable: jest.fn(),
  emitFile: jest.fn(),
  addDependency: jest.fn()
};

loader.call(context, 'content');

expect(context.cacheable).toHaveBeenCalled();

Snapshot-тестирование loader

Полезно для сложных преобразований AST.

Snapshot

expect(result.result[0]).toMatchSnapshot();

Пример snapshot:

exports[`transforms code 1`] = `
"const value = 42;"
`;

Тестирование AST-преобразований

Многие loader используют:

  • Babel;
  • PostCSS;
  • SWC;
  • TypeScript compiler API;
  • Acorn;
  • Esprima.

Пример Babel-loader логики

const babel = require('@babel/core');

module.exports = function(source) {
  const result = babel.transform(source, {
    presets: ['@babel/preset-env']
  });

  return result.code;
};

Проверка AST-трансформации

expect(output).toContain('"use strict"');

Интеграционное тестирование loader

Unit-тестов недостаточно, поскольку loader работает внутри pipeline Webpack.

Проверяется:

  • взаимодействие с другими loader;
  • цепочки преобразований;
  • cache invalidation;
  • HMR;
  • asset modules;
  • code splitting.

Запуск реальной сборки Webpack

Вспомогательная функция

const webpack = require('webpack');

function compile(config) {
  return new Promise((resolve, reject) => {
    webpack(config, (err, stats) => {
      if (err) {
        reject(err);
        return;
      }

      if (stats.hasErrors()) {
        reject(stats.toJson().errors);
        return;
      }

      resolve(stats);
    });
  });
}

Интеграционный тест loader

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

const path = require('path');

module.exports = {
  mode: 'development',

  entry: path.resolve(__dirname, './fixture.js'),

  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'bundle.js'
  },

  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          path.resolve(__dirname, '../. ./src/loader.js')
        ]
      }
    ]
  }
};

Тест

test('loader works in webpack build', async () => {
  const stats = await compile(config);

  const json = stats.toJson();

  expect(json.errors).toHaveLength(0);
});

Проверка output bundle

Анализ выходного файла

const fs = require('fs');

const bundle = fs.readFileSync(bundlePath, 'utf8');

expect(bundle).toContain('production');

Тестирование плагинов Webpack

Особенности plugin-тестирования

Плагин взаимодействует с внутренней системой хуков Webpack.

Основные сложности:

  • большое количество lifecycle hooks;
  • асинхронность;
  • работа с compilation;
  • изменение assets;
  • зависимость от стадии сборки;
  • сложная внутренняя архитектура compiler.

Базовый plugin

class MyPlugin {
  apply(compiler) {
    compiler.hooks.emit.tap('MyPlugin', compilation => {
      console.log('emit hook');
    });
  }
}

module.exports = MyPlugin;

Unit-тестирование plugin

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

const plugin = new MyPlugin();

const emit = {
  tap: jest.fn()
};

const compiler = {
  hooks: {
    emit
  }
};

plugin.apply(compiler);

expect(emit.tap).toHaveBeenCalled();

Проверка имени hook registration

expect(emit.tap).toHaveBeenCalledWith(
  'MyPlugin',
  expect.any(Function)
);

Тестирование callback hook

Получение callback

const callback = emit.tap.mock.calls[0][1];

Выполнение hook

const compilation = {};

callback(compilation);

Тестирование emitFile

Plugin

class BannerPlugin {
  apply(compiler) {
    compiler.hooks.emit.tap(
      'BannerPlugin',
      compilation => {
        compilation.assets['banner.txt'] = {
          source: () => 'banner',
          size: () => 6
        };
      }
    );
  }
}

Проверка

expect(compilation.assets['banner.txt']).toBeDefined();

Интеграционное тестирование plugin

Реальная сборка

const config = {
  mode: 'development',

  plugins: [
    new MyPlugin()
  ]
};

Проверка сборки

const stats = await compile(config);

expect(stats.hasErrors()).toBe(false);

Проверка generated assets

Анализ assets

const assets = stats.compilation.assets;

expect(assets['banner.txt']).toBeDefined();

Тестирование compilation hooks

Webpack предоставляет десятки hooks.

Часто тестируются:

Hook Назначение
emit Генерация assets
compilation Создание compilation
make Построение графа
afterEmit Завершение emit
done Завершение сборки
optimizeChunks Оптимизация чанков

Проверка Tapable hooks

Webpack построен на библиотеке Tapable.

Типы hooks:

Hook Поведение
SyncHook Синхронный
AsyncSeriesHook Последовательный async
AsyncParallelHook Параллельный async
SyncWaterfallHook Передача результата

Тестирование Async Hooks

Plugin

class AsyncPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync(
      'AsyncPlugin',
      (compilation, callback) => {
        setTimeout(() => {
          callback();
        }, 100);
      }
    );
  }
}

Проверка

expect(emit.tapAsync).toHaveBeenCalled();

Тестирование Promise Hooks

Promise plugin

class PromisePlugin {
  apply(compiler) {
    compiler.hooks.emit.tapPromise(
      'PromisePlugin',
      async compilation => {
        await Promise.resolve();
      }
    );
  }
}

Проверка ошибок plugin

Plugin с ошибкой

class FailingPlugin {
  apply(compiler) {
    compiler.hooks.emit.tap(
      'FailingPlugin',
      () => {
        throw new Error('Plugin error');
      }
    );
  }
}

Тестирование

await expect(
  compile(config)
).rejects.toBeTruthy();

Тестирование взаимодействия loader и plugin

Некоторые решения используют plugin + loader одновременно.

Примеры:

  • Vue Loader;
  • MiniCssExtractPlugin;
  • style-loader;
  • HtmlWebpackPlugin ecosystem.

Проверка совместной работы

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

module.exports = {
  module: {
    rules: [
      {
        test: /\.txt$/,
        use: [
          loaderPath
        ]
      }
    ]
  },

  plugins: [
    new MyPlugin()
  ]
};

Проверка

expect(bundle).toContain('processed');
expect(asset).toContain('generated');

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

Проблема файловой системы

Реальные файлы:

  • замедляют тесты;
  • создают побочные эффекты;
  • требуют очистки.

Виртуальная файловая система

Установка

npm install --save-dev memfs

Пример

const { Volume } = require('memfs');

const vol = Volume.fromJSON({
  '/src/index.js': 'console.log("test")'
});

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

Позволяет объединять виртуальную и реальную файловые системы.

const { ufs } = require('unionfs');

Тестирование через in-memory filesystem

compiler.outputFileSystem = memfs;

После сборки:

const bundle = memfs.readFileSync(
  '/dist/bundle.js',
  'utf8'
);

Проверка производительности

Loader и plugin могут существенно замедлять сборку.

Тестируются:

  • время исполнения;
  • объём памяти;
  • количество проходов AST;
  • размер generated assets.

Измерение времени

Пример

const start = performance.now();

await compile(config);

const end = performance.now();

expect(end - start).toBeLessThan(1000);

Проверка cache behavior

Webpack 5 активно использует filesystem cache.

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

cache: {
  type: 'filesystem'
}

Проверка

Вторая сборка должна быть быстрее первой.


Тестирование watch mode

Watch compiler

const watching = compiler.watch({}, callback);

Проверяется:

  • корректность rebuild;
  • invalidation;
  • пересборка зависимостей;
  • отсутствие утечек.

Проверка addDependency

Loader

module.exports = function(source) {
  this.addDependency('/config/theme.json');

  return source;
};

Тест

expect(addDependency).toHaveBeenCalledWith(
  '/config/theme.json'
);

Тестирование emitWarning

Loader warning

this.emitWarning(
  new Error('Deprecated API')
);

Проверка

expect(context.emitWarning)
  .toHaveBeenCalled();

Тестирование emitError

expect(context.emitError)
  .toHaveBeenCalled();

Тестирование Webpack 4 и Webpack 5

API существенно изменился между версиями.

Особенно:

  • hooks;
  • asset processing;
  • caching;
  • loader context;
  • chunk graph;
  • module graph.

Проверка мультиверсий

Часто используется matrix testing:

node-version:
  - 16
  - 18
  - 20

webpack-version:
  - 4
  - 5

Тестирование через GitHub Actions

Workflow

name: test

on: [push]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node: [16, 18, 20]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}

      - run: npm install
      - run: npm test

Проверка coverage

Настройка Jest

{
  "collectCoverage": true
}

Метрики

Особенно важны:

  • branches;
  • statements;
  • functions;
  • lines.

Распространённые ошибки тестирования loader и plugin

Неполное тестирование hook lifecycle

Ошибка:

compiler.hooks.emit.tap(...)

без проверки других фаз.

Следствие:

  • plugin работает только в development;
  • plugin ломается в production;
  • asset отсутствует после optimize phase.

Игнорирование source maps

Loader может визуально работать корректно, но ломать:

  • stack trace;
  • devtools;
  • breakpoints.

Отсутствие интеграционных тестов

Unit-тесты не гарантируют работу внутри настоящего compiler pipeline.


Тестирование только happy-path

Необходимо проверять:

  • пустые файлы;
  • бинарные данные;
  • огромные файлы;
  • синтаксические ошибки;
  • отсутствующие зависимости;
  • invalid options.

Проверка schema validation

Webpack loader и plugin обычно используют schema-utils.

Пример

const { validate } = require('schema-utils');

Проверка invalid config

expect(() => {
  loader.call(context, source);
}).toThrow();

Тестирование сериализации cache

Webpack filesystem cache требует сериализуемости объектов.

Проблемный код:

compilation.cache.set('key', {
  fn: () => {}
});

Функции не сериализуются.


Проверка memory leaks

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

  • watch mode;
  • persistent cache;
  • больших monorepo.

Проверяются:

  • незакрытые watcher;
  • глобальные ссылки;
  • повторная регистрация hooks.

Диагностика hook duplication

Ошибка:

compiler.hooks.emit.tap(...)

выполняется многократно при HMR.

Следствие:

  • duplicated assets;
  • exponential rebuild;
  • утечки памяти.

Лучшие практики тестирования

Изоляция fixtures

Каждый тест должен иметь:

  • собственный entry;
  • собственный output;
  • независимую конфигурацию.

Минимизация snapshot

Слишком большие snapshot:

  • сложно читать;
  • сложно обновлять;
  • сложно анализировать.

Проверка реальных bundles

Тестирование только mock-объектов недостаточно.


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

const os = require('os');
const fs = require('fs');

Временные директории предотвращают конфликты тестов.


Очистка compiler resources

watching.close(() => {});

Иначе возможны зависания CI.


Тестирование loader chain

Порядок loader имеет значение.

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

use: [
  'style-loader',
  'css-loader',
  loaderPath
]

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

expect(executionOrder).toEqual([
  'custom-loader',
  'css-loader',
  'style-loader'
]);

Тестирование child compiler

Некоторые плагины создают дочерние compiler.

Примеры:

  • HtmlWebpackPlugin;
  • MiniCssExtractPlugin.

Проверяются:

  • наследование hooks;
  • output options;
  • asset emission;
  • isolation compilation.

Тестирование asset modules

Webpack 5 заменил многие legacy-loader.

Необходимо проверять совместимость с:

type: 'asset/resource'
type: 'asset/inline'
type: 'asset/source'

Проверка persistent cache invalidation

Ошибки invalidation приводят к:

  • устаревшей сборке;
  • пропущенным rebuild;
  • неконсистентному output.

Проверка deterministic builds

Результат одинаковой сборки должен быть идентичным.

Проверка

expect(hash1).toBe(hash2);

Тестирование plugin order

Порядок plugins также критически важен.

Пример проблемы

plugins: [
  new CompressionPlugin(),
  new BannerPlugin()
]

Banner может не попасть в архив.


Проверка совместимости Node.js API

Некоторые plugin используют:

  • worker_threads;
  • fs/promises;
  • stream API;
  • crypto API.

Необходимо проверять разные версии Node.js.


Глубокое тестирование compilation object

Проверяются:

  • chunks;
  • modules;
  • assets;
  • entrypoints;
  • runtime modules;
  • chunk graph.

Пример

expect(compilation.chunks.size)
  .toBeGreaterThan(0);

Проверка asset contents

const source =
  compilation.assets['bundle.js'].source();

expect(source).toContain('webpack');

Проверка stats output

const statsJson = stats.toJson({
  assets: true,
  chunks: true,
  modules: true
});

Тестирование rebuild stability

В watch mode важно проверять:

  • одинаковость successive builds;
  • отсутствие duplicated modules;
  • корректность invalidation graph.

Проверка side effects

Plugin не должен:

  • изменять глобальное состояние;
  • мутировать shared config;
  • модифицировать чужие assets без необходимости.

Тестирование параллельных сборок

Некоторые plugin ломаются при multi-compiler.

MultiCompiler

webpack([
  configA,
  configB
]);

Проверка race conditions

Особенно важно для async plugin.

Проблемы:

  • asset overwrite;
  • missing chunks;
  • corrupted cache;
  • inconsistent hashes.

Тестирование serialization hooks

Webpack 5 активно использует сериализацию.

Проверяются:

  • custom cache objects;
  • lazy compilation state;
  • snapshot data.

Проверка invalid hook stage

Некоторые hooks доступны только на определённых стадиях compilation lifecycle.

Ошибка стадии может приводить к:

  • отсутствию assets;
  • silent failure;
  • corrupted bundle.

Комплексный подход к тестированию

Надёжная стратегия обычно включает:

  1. Unit-тесты логики.
  2. Интеграционные сборки.
  3. Snapshot output.
  4. Проверку production mode.
  5. Проверку watch mode.
  6. Performance benchmarks.
  7. Matrix testing версий.
  8. Проверку persistent cache.
  9. Тестирование source maps.
  10. Анализ generated assets.