memory-fs и компилятор в тестовом режиме

При обычной сборке Webpack записывает результат работы на диск: формирует директорию dist, генерирует JavaScript-бандлы, source maps, CSS-файлы и дополнительные ресурсы. Во время тестирования такая схема создаёт ряд проблем:

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

Для решения этих задач используется memory-fs — реализация виртуальной файловой системы, полностью работающая в оперативной памяти.

Webpack умеет взаимодействовать не только с Node.js fs, но и с любым объектом, реализующим совместимый API. Благодаря этому компилятор можно перевести в полностью изолированный тестовый режим.


Архитектура взаимодействия Webpack с файловой системой

Webpack активно использует файловую систему практически на каждом этапе:

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

Внутри компилятора используются два ключевых интерфейса:

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)
})

В этом примере:

  1. Webpack собирает проект.
  2. Результат не записывается на диск.
  3. Бандл существует только в памяти.
  4. Тест получает содержимое напрямую из memory-fs.

Почему тестовый режим работает быстрее

Физическая файловая система медленнее оперативной памяти по нескольким причинам:

  • системные вызовы;
  • работа драйверов;
  • блокировки;
  • кеширование ОС;
  • синхронизация файловых операций.

memory-fs устраняет эти накладные расходы.

Особенно заметен эффект при:

  • CI-сборках;
  • больших монорепозиториях;
  • snapshot-тестировании;
  • массовом запуске Webpack-компиляций;
  • тестировании loader-цепочек.

Работа 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)
})

Можно проверять:

  • ошибки loader;
  • ошибки parser;
  • проблемы resolve;
  • отсутствующие модули;
  • invalid configuration;
  • syntax errors.

Использование 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)

Тестирование loader через 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')

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

Плагины часто взаимодействуют с 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')

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

Часто используется совместно с Jest snapshot.

Пример:

expect(bundle).toMatchSnapshot()

Это позволяет:

  • отслеживать изменение runtime;
  • проверять tree shaking;
  • контролировать output loader;
  • тестировать minimizer.

Совместное использование с webpack-dev-middleware

webpack-dev-middleware тоже использует виртуальную файловую систему.

Внутри middleware:

compiler.outputFileSystem = memfs

Благодаря этому dev-server не записывает файлы на диск при каждой пересборке.


Отличие memory-fs от memfs

Исторически Webpack использовал memory-fs, однако позже популярность получил пакет memfs.

Причины:

  • лучшая совместимость;
  • поддержка современных API;
  • более активная поддержка;
  • интеграция с unionfs.

Пример:

const { Volume } = require('memfs')

const vol = new Volume()

Во многих современных проектах:

compiler.outputFileSystem = createFsFromVolume(vol)

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

Webpack поддерживает непрерывную сборку:

compiler.watch({}, callback)

В тестах это позволяет проверять:

  • invalidation;
  • rebuild;
  • реакцию на изменения;
  • кеширование.

Пример:

const watching = compiler.watch({}, () => {
  const bundle = mfs.readFileSync(
    '/dist/bundle.js',
    'utf8'
  )

  expect(bundle).toContain('updated')
})

Проблемы с watch и виртуальной FS

Watch-режим зависит от отслеживания изменений файлов.

Если входные файлы тоже находятся в памяти, могут возникать сложности:

  • отсутствуют filesystem events;
  • watcher не получает уведомления;
  • rebuild не запускается автоматически.

Поэтому часто комбинируют:

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-обёрток

Для современных тестов удобнее использовать 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'
)

Проверка emitted assets

Webpack хранит список сгенерированных assets внутри compilation.

Пример проверки:

const assets = stats.compilation.assets

expect(assets['bundle.js'])
  .toBeDefined()

Эмуляция сложных сценариев

Тестовый режим позволяет проверять:

  • dynamic import;
  • lazy chunks;
  • asset modules;
  • web workers;
  • css extraction;
  • source maps;
  • HMR runtime;
  • externals;
  • federation.

Без реальной записи файлов такие тесты выполняются значительно быстрее.


Взаимодействие с enhanced-resolve

Webpack использует отдельный resolver.

При тестировании виртуальной FS иногда требуется:

compiler.inputFileSystem = mfs

Однако это усложняет разрешение зависимостей из node_modules.

Часто используют гибридную схему:

inputFileSystem = real fs
outputFileSystem = memory fs

Ограничения memory-fs

Несмотря на удобство, библиотека имеет ограничения.

Отсутствие полной совместимости с Node.js FS

Некоторые API реализованы частично.

Устаревшая поддержка

Проект обновляется значительно реже современных альтернатив.

Сложные монорепозитории могут работать нестабильно.

Ограничения watcher-механизмов

Некоторые инструменты ожидают реальные 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'
)

Проверка source maps

Пример:

const map = mfs.readFileSync(
  '/dist/main.js.map',
  'utf8'
)

const json = JSON.parse(map)

expect(json.sources)
  .toContain('src/index.js')

Проверка tree shaking

В production-сборке:

expect(bundle)
  .not.toContain('unusedFunction')

Такие тесты помогают контролировать регрессии оптимизации.


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

memory-fs особенно полезен в CI-средах:

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

На больших проектах экономия времени может составлять десятки минут.


Переход на Webpack 5

В Webpack 5 внутренняя архитектура работы с FS изменилась:

  • появился persistent cache;
  • улучшены snapshot-механизмы;
  • переработан resolver;
  • активнее используются абстракции FS.

Поэтому современные инструменты всё чаще используют:

  • memfs;
  • unionfs;
  • memfs/lib/fs;
  • встроенные адаптеры виртуальных файловых систем.

Тем не менее концепция тестового компилятора остаётся прежней:

  1. создать compiler;
  2. заменить output filesystem;
  3. выполнить сборку;
  4. считать assets из памяти;
  5. проверить результат тестами.