При обычной сборке Webpack записывает результат работы на диск:
формирует директорию dist, генерирует JavaScript-бандлы,
source maps, CSS-файлы и дополнительные ресурсы. Во время тестирования
такая схема создаёт ряд проблем:
Для решения этих задач используется memory-fs —
реализация виртуальной файловой системы, полностью работающая в
оперативной памяти.
Webpack умеет взаимодействовать не только с Node.js fs,
но и с любым объектом, реализующим совместимый API. Благодаря этому
компилятор можно перевести в полностью изолированный тестовый режим.
Webpack активно использует файловую систему практически на каждом этапе:
Внутри компилятора используются два ключевых интерфейса:
compiler.inputFileSystem
compiler.outputFileSystem
По умолчанию:
compiler.inputFileSystem = fs
compiler.outputFileSystem = fs
Однако эти свойства можно заменить:
const MemoryFS = require('memory-fs')
const mfs = new MemoryFS()
compiler.outputFileSystem = mfs
После этого Webpack перестаёт писать файлы на диск.
memory-fsВ классическом Webpack 4:
npm install memory-fs --save-dev
Для Webpack 5 отдельный пакет используется значительно реже, так как
часть логики была перенесена во внутренние механизмы виртуальных
файловых систем, однако memory-fs по-прежнему встречается в
тестовой инфраструктуре и старых проектах.
Минимальный тестовый раннер выглядит следующим образом:
const webpack = require('webpack')
const MemoryFS = require('memory-fs')
const config = {
mode: 'development',
entry: './src/index.js',
output: {
path: '/dist',
filename: 'bundle.js'
}
}
const compiler = webpack(config)
const mfs = new MemoryFS()
compiler.outputFileSystem = mfs
compiler.run((err, stats) => {
if (err) {
console.error(err)
return
}
const bundle = mfs.readFileSync(
'/dist/bundle.js',
'utf8'
)
console.log(bundle)
})
В этом примере:
memory-fs.Физическая файловая система медленнее оперативной памяти по нескольким причинам:
memory-fs устраняет эти накладные расходы.
Особенно заметен эффект при:
memory-fs как виртуального дереваВнутри memory-fs создаётся собственная структура
каталогов.
Пример:
mfs.mkdirpSync('/dist')
mfs.writeFileSync(
'/dist/app.js',
'console.log("test")'
)
const content = mfs.readFileSync(
'/dist/app.js',
'utf8'
)
console.log(content)
Фактически библиотека реализует аналог POSIX-файловой системы.
Поддерживаются:
Одна из основных задач — проверка содержимого итогового бандла.
Пример теста на Jest:
const webpack = require('webpack')
const MemoryFS = require('memory-fs')
describe('webpack build', () => {
test('should compile bundle', done => {
const config = {
mode: 'development',
entry: './fixtures/simple.js',
output: {
path: '/dist',
filename: 'bundle.js'
}
}
const compiler = webpack(config)
const mfs = new MemoryFS()
compiler.outputFileSystem = mfs
compiler.run((err, stats) => {
expect(err).toBeFalsy()
const bundle = mfs.readFileSync(
'/dist/bundle.js',
'utf8'
)
expect(bundle).toContain('hello')
done()
})
})
})
Такой подход полностью исключает диск из тестового окружения.
memory-fs особенно полезен при тестировании ошибочных
конфигураций.
Пример:
compiler.run((err, stats) => {
const info = stats.toJson()
expect(info.errors.length).toBeGreaterThan(0)
})
Можно проверять:
stats в тестовом режимеWebpack возвращает объект статистики:
stats.toJson()
В тестах обычно проверяют:
const json = stats.toJson({
assets: true,
chunks: true,
modules: true
})
Пример:
expect(json.assets[0].name)
.toBe('bundle.js')
Либо:
expect(json.errors).toHaveLength(0)
memory-fsОдно из важнейших применений — тестирование собственных loader.
Пример loader:
module.exports = function(source) {
return source.replace('__DEV__', 'true')
}
Конфигурация теста:
const config = {
mode: 'development',
entry: './fixtures/index.js',
output: {
path: '/dist',
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: require.resolve('../loader')
}
}
]
}
}
После компиляции:
const bundle = mfs.readFileSync(
'/dist/bundle.js',
'utf8'
)
expect(bundle).toContain('true')
Плагины часто взаимодействуют с assets через compilation hooks.
memory-fs позволяет проверять результат без реальных
файлов.
Пример:
class BannerPlugin {
apply(compiler) {
compiler.hooks.emit.tap(
'BannerPlugin',
compilation => {
compilation.assets['banner.txt'] = {
source: () => 'banner',
size: () => 6
}
}
)
}
}
Тест:
const asset = mfs.readFileSync(
'/dist/banner.txt',
'utf8'
)
expect(asset).toBe('banner')
Каждый тест должен получать собственный экземпляр
MemoryFS.
Плохой вариант:
const mfs = new MemoryFS()
на уровне файла.
Правильный вариант:
beforeEach(() => {
mfs = new MemoryFS()
})
Причина — сохранение состояния между тестами.
Webpack требует абсолютный output.path.
Неправильно:
output: {
path: './dist'
}
Правильно:
const path = require('path')
output: {
path: path.resolve('/dist')
}
Для memory-fs путь остаётся виртуальным, но Webpack всё
равно валидирует конфигурацию.
Пример проверки chunk splitting:
const files = mfs.readdirSync('/dist')
expect(files).toContain('main.js')
expect(files).toContain('vendors.js')
Часто используется совместно с Jest snapshot.
Пример:
expect(bundle).toMatchSnapshot()
Это позволяет:
webpack-dev-middlewarewebpack-dev-middleware тоже использует виртуальную
файловую систему.
Внутри middleware:
compiler.outputFileSystem = memfs
Благодаря этому dev-server не записывает файлы на диск при каждой пересборке.
memory-fs от
memfsИсторически Webpack использовал memory-fs, однако позже
популярность получил пакет memfs.
Причины:
unionfs.Пример:
const { Volume } = require('memfs')
const vol = new Volume()
Во многих современных проектах:
compiler.outputFileSystem = createFsFromVolume(vol)
Webpack поддерживает непрерывную сборку:
compiler.watch({}, callback)
В тестах это позволяет проверять:
Пример:
const watching = compiler.watch({}, () => {
const bundle = mfs.readFileSync(
'/dist/bundle.js',
'utf8'
)
expect(bundle).toContain('updated')
})
Watch-режим зависит от отслеживания изменений файлов.
Если входные файлы тоже находятся в памяти, могут возникать сложности:
Поэтому часто комбинируют:
inputFileSystem = fs
outputFileSystem = memory-fs
Webpack-компилятор полностью асинхронен.
Даже при использовании синхронных API внутри memory-fs
сама компиляция остаётся асинхронной.
Типичная ошибка:
compiler.run()
const result = mfs.readFileSync(...)
На момент чтения файл ещё не существует.
Корректный вариант:
compiler.run((err, stats) => {
const result = mfs.readFileSync(...)
})
Webpack watch-mode создаёт открытые дескрипторы.
После тестов необходимо:
watching.close(() => {})
Иначе Jest может зависнуть.
Для современных тестов удобнее использовать Promise.
Пример:
function compile(config) {
return new Promise((resolve, reject) => {
const compiler = webpack(config)
const mfs = new MemoryFS()
compiler.outputFileSystem = mfs
compiler.run((err, stats) => {
if (err) {
reject(err)
return
}
resolve({
stats,
fs: mfs
})
})
})
}
Использование:
const result = await compile(config)
const bundle = result.fs.readFileSync(
'/dist/bundle.js',
'utf8'
)
Webpack хранит список сгенерированных assets внутри compilation.
Пример проверки:
const assets = stats.compilation.assets
expect(assets['bundle.js'])
.toBeDefined()
Тестовый режим позволяет проверять:
Без реальной записи файлов такие тесты выполняются значительно быстрее.
enhanced-resolveWebpack использует отдельный resolver.
При тестировании виртуальной FS иногда требуется:
compiler.inputFileSystem = mfs
Однако это усложняет разрешение зависимостей из
node_modules.
Часто используют гибридную схему:
inputFileSystem = real fs
outputFileSystem = memory fs
memory-fsНесмотря на удобство, библиотека имеет ограничения.
Некоторые API реализованы частично.
Проект обновляется значительно реже современных альтернатив.
Сложные монорепозитории могут работать нестабильно.
Некоторые инструменты ожидают реальные filesystem events.
В крупных проектах создаётся helper:
const webpack = require('webpack')
const MemoryFS = require('memory-fs')
module.exports = function compile(config) {
return new Promise((resolve, reject) => {
const compiler = webpack(config)
const mfs = new MemoryFS()
compiler.outputFileSystem = mfs
compiler.run((err, stats) => {
if (err) {
reject(err)
return
}
resolve({
fs: mfs,
stats,
compiler
})
})
})
}
После этого любой тест становится компактным:
const result = await compile(config)
const output = result.fs.readFileSync(
'/dist/main.js',
'utf8'
)
Пример:
const map = mfs.readFileSync(
'/dist/main.js.map',
'utf8'
)
const json = JSON.parse(map)
expect(json.sources)
.toContain('src/index.js')
В production-сборке:
expect(bundle)
.not.toContain('unusedFunction')
Такие тесты помогают контролировать регрессии оптимизации.
memory-fs особенно полезен в CI-средах:
На больших проектах экономия времени может составлять десятки минут.
В Webpack 5 внутренняя архитектура работы с FS изменилась:
Поэтому современные инструменты всё чаще используют:
memfs;unionfs;memfs/lib/fs;Тем не менее концепция тестового компилятора остаётся прежней: