Разработчикам на нативном JS история про различия систем модулей CommonJS и ECMAScript знакома на собственном опыте. Сейчас идёт активное внедрение ECMAScript на уровень языка, а в Node.js новых версий «из коробки» она уже работает нативно. ECMAScript-модули принесли за собой некоторые другие явления:
- Необходимость указывать
"type": "module"
в package.json; - Временный костыль для запуска
jest
с ключами--experimental-vm-modules
и, опционально,--no-warnings
; - По умолчанию, отсутствуют глобальные константы
__filename
и__dirname
;
Почему последний пункт важен? Дело в том, что работая с путями, можно построить две рабочих системы: надёжную и ненадёжную. Использование одной из этих констант делает решение по умолчанию надёжнее.
Сначала ломаем
Имеется следующая структура каталогов:
project
├── __fixtures__
│ ├── file1.json
│ ├── file2.json
│ └── expected_file.json
├── __tests__
│ └── index.test.js
└── index.js
Файл index.test.js содержит такой код:
const getFixturePath = (filename) => path.join(__dirname, '..', '__fixtures__', filename);
const readFile = (filename) => fs.readFileSync(getFixturePath(filename), 'utf-8');
// где-то в тестах:
readFile('expected_file.json');
Для начала тесты запускаются в корневом каталоге:
project$ npx -n --experimental-vm-modules jest
● read fixtures test
ReferenceError: __dirname is not defined
> 25 | const getFixturePath = (filename) => path.join(__dirname, '..', '__fixtures__', filename);
| ^
Как видно, __dirname
отсутствует в глобальной области видимости, самое простое решение — убрать её.
const getFixturePath = (filename) => path.join('..', '__fixtures__', filename);
project$ npx -n --experimental-vm-modules jest
● read fixtures test
ENOENT: no such file or directory, open '../__fixtures__/expected_file.json'
25 | const getFixturePath = (filename) => path.join('..', '__fixtures__', filename);
> 26 | const readFile = (filename) => fs.readFileSync(getFixturePath(filename), 'utf-8');
| ^
Тесты всё ещё падают, но почему? Из текста ошибки так сразу и не скажешь, но теперь поиск файла фикстур происходит на один каталог выше, как будто из корня было набрано cat ../__fixtures__/expected_file.json
:
const getFixturePath = (filename) => path.join('__fixtures__', filename);
project$ npx -n --experimental-vm-modules jest
PASS __tests__/index.test.js
✓ read fixtures test (6 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.326 s, estimated 1 s
Ran all test suites.
Тесты пройдены, но надёжность системы нарушена, потому что этими действиями обеспечено недетерминированное поведение функции построения путей:
project$ cd __tests__
__tests__/project$ npx -n --experimental-vm-modules jest
● read fixtures test
ENOENT: no such file or directory, open '__fixtures__/expected_file.json'
25 | const getFixturePath = (filename) => path.join('__fixtures__', filename);
> 26 | const readFile = (filename) => fs.readFileSync(getFixturePath(filename), 'utf-8');
| ^
Тесты снова упали, потому что теперь path.join
собирает путь относительно места запуска.
Так вот, глобальная константа __filename
содержит абсолютный путь к файлу, в котором она используется, а __dirname
, соответственно, к каталогу. Зная, что необходимый файл лежит относительно текущего всегда на N каталогов выше/ниже, используя данные константы, можно обеспечить детерминированное поведение при запуске кода из любого каталога.
Теперь строим
Официальная документация Node.js предлагает, может быть, не самое красивое решение, но рабочее:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Уже зная, как работают эти константы, остаётся вернуть первоначальное решение и опробовать его:
const getFixturePath = (filename) => path.join(__dirname, '..', '__fixtures__', filename);
__tests__/project$ npx -n --experimental-vm-modules jest
PASS __tests__/index.test.js
✓ read fixtures test (12 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.592 s, estimated 1 s
Ran all test suites.
__tests__/project$ cd ..
project$ npx -n --experimental-vm-modules jest
PASS __tests__/index.test.js
✓ read fixtures test (29 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.69 s, estimated 1 s
Ran all test suites.
Отличная работа! Теперь пути чётко описывают свой контекст, а код корректно отрабатывает, независимо от места запуска.
Нужно также учесть
Отличие path.join и path.resolve
При выборе между path.join
и path.resolve
нужно ориентироваться на ожидаемое поведение:
— path.join('/a', '/b', 'c') // вернёт /a/b/c
сборка происходит слева направо, абсолютный путь строится от первого
аргумента с полным путём;
— path.resolve('/a', '/b', 'c') // вернёт /b/c
абсолютный путь строится от последнего аргумента с абсолютным путём,
можно считать что сборка происходит справа налево.
Тесты могут врать
Команда npm test
под капотом игнорирует место запуска в рамках проекта и производит запуск от корня. То есть тесты всегда будут проходить вне зависимости от наличия __dirname
. С учётом не самого информативного вывода тестов, при ошибке (если оно вообще когда-то вскроется) это позволяет получить гейзенбаг.
Настройки линтера
При использовании eslint
, в данном случае с конфигом airbnb, возникнет две ошибки:
1 Parsing error: Unexpected token import
На строке с import.meta.url
. Решается добавлением в .eslintrc указания использовать последнюю версию ecma в парсере:
# Включает поддержку конструкции import.meta.url
parserOptions:
ecmaVersion: 2020
2 Unexpected dangling '_' in '__filename'.(no-underscore-dangle)
На константах __filename
и __dirname
. Решается отключением данного правила для каждой строки или для всего файла: /* eslint-disable no-underscore-dangle */
Чтобы столкнуться с данной задачей прямо сейчас, достаточно начать прохождение второго проекта на Хекслете.