Собственные загрузчики (loaders) и плагины
(plugins) являются расширениями внутреннего механизма
Webpack. Ошибки в них способны приводить к:
Поэтому тестирование подобных расширений требует более глубокого подхода, чем проверка обычных JavaScript-функций.
Основные категории тестов:
| Тип тестирования | Назначение |
|---|---|
| Unit-тесты | Проверка изолированной логики |
| Интеграционные тесты | Проверка взаимодействия с Webpack |
| Snapshot-тесты | Контроль структуры результатов |
| E2E-тесты | Проверка полноценной сборки |
| Performance-тесты | Анализ времени выполнения |
| Compatibility-тесты | Проверка версий Webpack и Node.js |
Загрузчик представляет собой функцию преобразования содержимого модуля.
Типичный loader:
module.exports = function loader(source) {
return source.replace(/DEBUG/g, 'false');
};
Критически важно тестировать:
this.Пример организации:
project/
├── src/
│ ├── loader.js
│ └── plugin.js
├── test/
│ ├── fixtures/
│ ├── loaders/
│ ├── plugins/
│ └── integration/
├── jest.config.js
├── package.json
└── webpack.config.js
Наиболее популярное решение.
Установка:
npm install --save-dev jest
Минимальная конфигурация:
module.exports = {
testEnvironment: 'node'
};
Пакет позволяет запускать loader без полноценной сборки Webpack.
Установка:
npm install --save-dev loader-runner
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();
}
);
});
});
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');
}
);
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();
}
);
});
module.exports = function(source, map) {
const callback = this.async();
const transformed = source.replace('var', 'const');
callback(null, transformed, map);
};
expect(result.result[1]).toBeDefined();
Часто дополнительно проверяются:
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();
}
);
});
Некоторые загрузчики работают с Buffer.
module.exports.raw = true;
module.exports = function(buffer) {
return buffer.toString('utf8');
};
expect(Buffer.isBuffer(input)).toBe(true);
Контекст содержит множество внутренних API Webpack.
Наиболее важные методы:
| Метод | Назначение |
|---|---|
| this.emitFile | Генерация файлов |
| this.addDependency | Добавление зависимостей |
| this.cacheable | Управление кэшем |
| this.getOptions | Получение опций |
| this.async | Асинхронный режим |
const context = {
query: {},
cacheable: jest.fn(),
emitFile: jest.fn(),
addDependency: jest.fn()
};
loader.call(context, 'content');
expect(context.cacheable).toHaveBeenCalled();
Полезно для сложных преобразований AST.
expect(result.result[0]).toMatchSnapshot();
Пример snapshot:
exports[`transforms code 1`] = `
"const value = 42;"
`;
Многие loader используют:
const babel = require('@babel/core');
module.exports = function(source) {
const result = babel.transform(source, {
presets: ['@babel/preset-env']
});
return result.code;
};
expect(output).toContain('"use strict"');
Unit-тестов недостаточно, поскольку loader работает внутри pipeline 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);
});
});
}
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);
});
const fs = require('fs');
const bundle = fs.readFileSync(bundlePath, 'utf8');
expect(bundle).toContain('production');
Плагин взаимодействует с внутренней системой хуков Webpack.
Основные сложности:
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tap('MyPlugin', compilation => {
console.log('emit hook');
});
}
}
module.exports = MyPlugin;
const plugin = new MyPlugin();
const emit = {
tap: jest.fn()
};
const compiler = {
hooks: {
emit
}
};
plugin.apply(compiler);
expect(emit.tap).toHaveBeenCalled();
expect(emit.tap).toHaveBeenCalledWith(
'MyPlugin',
expect.any(Function)
);
const callback = emit.tap.mock.calls[0][1];
const compilation = {};
callback(compilation);
class BannerPlugin {
apply(compiler) {
compiler.hooks.emit.tap(
'BannerPlugin',
compilation => {
compilation.assets['banner.txt'] = {
source: () => 'banner',
size: () => 6
};
}
);
}
}
expect(compilation.assets['banner.txt']).toBeDefined();
const config = {
mode: 'development',
plugins: [
new MyPlugin()
]
};
const stats = await compile(config);
expect(stats.hasErrors()).toBe(false);
const assets = stats.compilation.assets;
expect(assets['banner.txt']).toBeDefined();
Webpack предоставляет десятки hooks.
Часто тестируются:
| Hook | Назначение |
|---|---|
| emit | Генерация assets |
| compilation | Создание compilation |
| make | Построение графа |
| afterEmit | Завершение emit |
| done | Завершение сборки |
| optimizeChunks | Оптимизация чанков |
Webpack построен на библиотеке Tapable.
Типы hooks:
| Hook | Поведение |
|---|---|
| SyncHook | Синхронный |
| AsyncSeriesHook | Последовательный async |
| AsyncParallelHook | Параллельный async |
| SyncWaterfallHook | Передача результата |
class AsyncPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync(
'AsyncPlugin',
(compilation, callback) => {
setTimeout(() => {
callback();
}, 100);
}
);
}
}
expect(emit.tapAsync).toHaveBeenCalled();
class PromisePlugin {
apply(compiler) {
compiler.hooks.emit.tapPromise(
'PromisePlugin',
async compilation => {
await Promise.resolve();
}
);
}
}
class FailingPlugin {
apply(compiler) {
compiler.hooks.emit.tap(
'FailingPlugin',
() => {
throw new Error('Plugin error');
}
);
}
}
await expect(
compile(config)
).rejects.toBeTruthy();
Некоторые решения используют plugin + loader одновременно.
Примеры:
module.exports = {
module: {
rules: [
{
test: /\.txt$/,
use: [
loaderPath
]
}
]
},
plugins: [
new MyPlugin()
]
};
expect(bundle).toContain('processed');
expect(asset).toContain('generated');
Реальные файлы:
npm install --save-dev memfs
const { Volume } = require('memfs');
const vol = Volume.fromJSON({
'/src/index.js': 'console.log("test")'
});
Позволяет объединять виртуальную и реальную файловые системы.
const { ufs } = require('unionfs');
compiler.outputFileSystem = memfs;
После сборки:
const bundle = memfs.readFileSync(
'/dist/bundle.js',
'utf8'
);
Loader и plugin могут существенно замедлять сборку.
Тестируются:
const start = performance.now();
await compile(config);
const end = performance.now();
expect(end - start).toBeLessThan(1000);
Webpack 5 активно использует filesystem cache.
cache: {
type: 'filesystem'
}
Вторая сборка должна быть быстрее первой.
const watching = compiler.watch({}, callback);
Проверяется:
module.exports = function(source) {
this.addDependency('/config/theme.json');
return source;
};
expect(addDependency).toHaveBeenCalledWith(
'/config/theme.json'
);
this.emitWarning(
new Error('Deprecated API')
);
expect(context.emitWarning)
.toHaveBeenCalled();
expect(context.emitError)
.toHaveBeenCalled();
API существенно изменился между версиями.
Особенно:
Часто используется matrix testing:
node-version:
- 16
- 18
- 20
webpack-version:
- 4
- 5
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
{
"collectCoverage": true
}
Особенно важны:
Ошибка:
compiler.hooks.emit.tap(...)
без проверки других фаз.
Следствие:
Loader может визуально работать корректно, но ломать:
Unit-тесты не гарантируют работу внутри настоящего compiler pipeline.
Необходимо проверять:
Webpack loader и plugin обычно используют
schema-utils.
const { validate } = require('schema-utils');
expect(() => {
loader.call(context, source);
}).toThrow();
Webpack filesystem cache требует сериализуемости объектов.
Проблемный код:
compilation.cache.set('key', {
fn: () => {}
});
Функции не сериализуются.
Особенно важно для:
Проверяются:
Ошибка:
compiler.hooks.emit.tap(...)
выполняется многократно при HMR.
Следствие:
Каждый тест должен иметь:
Слишком большие snapshot:
Тестирование только mock-объектов недостаточно.
const os = require('os');
const fs = require('fs');
Временные директории предотвращают конфликты тестов.
watching.close(() => {});
Иначе возможны зависания CI.
Порядок loader имеет значение.
use: [
'style-loader',
'css-loader',
loaderPath
]
expect(executionOrder).toEqual([
'custom-loader',
'css-loader',
'style-loader'
]);
Некоторые плагины создают дочерние compiler.
Примеры:
Проверяются:
Webpack 5 заменил многие legacy-loader.
Необходимо проверять совместимость с:
type: 'asset/resource'
type: 'asset/inline'
type: 'asset/source'
Ошибки invalidation приводят к:
Результат одинаковой сборки должен быть идентичным.
expect(hash1).toBe(hash2);
Порядок plugins также критически важен.
plugins: [
new CompressionPlugin(),
new BannerPlugin()
]
Banner может не попасть в архив.
Некоторые plugin используют:
Необходимо проверять разные версии Node.js.
Проверяются:
expect(compilation.chunks.size)
.toBeGreaterThan(0);
const source =
compilation.assets['bundle.js'].source();
expect(source).toContain('webpack');
const statsJson = stats.toJson({
assets: true,
chunks: true,
modules: true
});
В watch mode важно проверять:
Plugin не должен:
Некоторые plugin ломаются при multi-compiler.
webpack([
configA,
configB
]);
Особенно важно для async plugin.
Проблемы:
Webpack 5 активно использует сериализацию.
Проверяются:
Некоторые hooks доступны только на определённых стадиях compilation lifecycle.
Ошибка стадии может приводить к:
Надёжная стратегия обычно включает: