Webpack на практике: с нуля до создания автотестов

Обложка поста

Перевод статьи Webpack: From 0 to automated testing

Язык JavaScript повсеместно используется для создания больших веб-сервисов. Для таких проектов приходится импортировать много стороннего кода (Lodash, React, Angular и др.). Из-за этого код усложняется, и в нём гораздо чаще возникают ошибки. Чем больше в вашем коде будет зависимостей, тем большей головной болью станет подключение тегов <script> в правильном порядке.

Webpack создаёт граф зависимостей для JavaScript, CSS и прочих, выдавая однофайловые сборки кода так, чтобы вы могли импортировать все необходимые ресурсы JavaScript всего одним тегом <script>.

Это руководство поможет создать сборку для небольшого веб-приложения, а затем научит вас использовать Webpack для настройки автоматических тестов.

Создание приложения

В качестве тестового приложения мы сделаем карту для ленивцев, которая поможет найти магазины в Кембридже, где продают травяной чай из гибискуса. Потому что каждый ленивец в кембриджском заповеднике Fresh Pond знает, что чай из гибискуса — лучший чай, чтобы умерить свой горячий темперамент!

sloths

Прим.: на самом деле в заповеднике Fresh Pond не обитают ленивцы, но они правда любят вкусные цветы гибискуса после долгого дня, проведённого на деревьях.

Создайте каталог с именем webpack-mocha-tutorial, в него добавьте другой каталог app/src и запустите пакетный менеджер npm init или yarn init. Исходный код приложения находится здесь. Также в статье будут ссылки на коммиты, чтобы прослеживать изменения кода по ходу чтения.

Основная структура приложения будет выглядеть так:

  • У вас есть файл app/src/distance.js, экспортирующий функцию, которая запускает формулу вычисления расстояния (на самом деле нужно использовать ортодому) и функцию, которая сообщает, какая точка из массива точек ближе всего находится.
// Функция distance() принимает 2 значения, представленных
// числами x и y, и возвращает расстояние между ними
//
// [TODO] Используйте ортодому
function distance(p2, p1) {
 let yDist = p2.y - p1.y;
 let xDist = p2.x - p1.x;
 return Math.sqrt(Math.pow(yDist, 2) + Math.pow(xDist, 2));
}
// sortByDistance принимает ваше местоположение и массив точек
// и возвращает отсортированный массив точек
function sortByDistance(myPt, points) {
 return points.sort(
   (pt1, pt2) => distance(pt1, myPt) - distance(pt2, myPt));
}
  • Также у вас есть файл app/src/page.js, который использует код из distance.js, чтобы вывести ближайший магазин из списка, а затем отобразить его на странице.
let stores = [
 {name: "Cambridge Naturals",     x: -71.1189, y: 42.3895},
 {name: "Sarah's Market",         x: -71.1311, y: 42.3823},
 {name: "Whole Foods Fresh Pond", x: -71.1420, y: 42.3904},
];
let here = {name: "You are here",  x: -71.1470, y: 42.3834};
let nearest = sortByDistance(here, stores)[0];
document.getElementById("nearest-store").innerHTML = nearest.name;
  • И, наконец, у вас есть страница index.html.
<!DOCTYPE html>
<html>
 <head>
   <title>Ближайший магазин чая из гибискуса</title>
 </head>
 <body>
   <p>Nearest store is <span id="nearest-store"></span></p>
   <script src="app/src/distance.js"></script>
   <script src="app/src/page.js"></script>
 </body>
</html>

Общая структура каталогов такова:

dependencies tree
Таким образом, файл distance.js определяет функции расстояния, затем файл page.js запускает их, помещая результат функции sortByDistance() в дерево документов (DOM). Но если посмотреть на зависимость между файлами, то видно, что файл page.js зависит от файла distance.js, а не наоборот (Commit 2).

Добавляем Webpack

Для работы с Webpack необходимо его установить с помощью npm или yarn:

$ yarn add --dev webpack webpack-cli

Теперь у вас подключён Webpack и доступна его командная строка. Но прежде, чем можно будет запустить сборку, файл page.js должен импортировать код из distance.js. А distance.js экспортирует свои функции с помощью строки:

module.exports = {distance, sortByDistance};

И чтобы page.js мог использовать экспортированную функцию sortByDistance(),  добавляем строку:

import {sortByDistance} from "./distance";

Отлично, теперь все зависимости JavaScript связаны. Используем Webpack для создания приложения. Выполним следующую команду:

$ npx webpack app/src/page.js

Сейчас вы должны увидеть новый файл dist/main.js, который содержит весь ваш код из page.js и distance.js. Далее получаем index.html с импортом dist/main.js вместо всех скриптов из app/src, изменив код страницы следующим образом:

<!DOCTYPE html>
<html>
 <head>
   <title>Ближайший магазин чая из гибискуса</title>
 </head>
 <body>
   <p>Ближайший магазин: <span id="nearest-store"></span></p>
   <script src="dist/main.js"></script>
 </body>
</html>

Теперь можете открыть страницу в браузере, код по-прежнему должен работать. Поскольку файл main.js содержит весь код из distance.js и page.js, можно импортировать всё из одного файла.

Это работает так: с помощью команды $ npx webpack app/src/page.js вы указываете, что отправной точкой (в терминологии Webpack — точкой входа в ваш код JavaScript) является page.js. Webpack читает файл page.js и видит в нём строку import {sortByDistance} from ./distance. Теперь он знает, что distance.js является зависимостью к page.js. И из всех зависимостей в вашем коде Webpack строит граф и использует его для построения пакетного JavaScript-файла dist/main.js (Commit 3).

webpack

Webpack строит граф зависимостей из точки входа, page.js

Между прочим, это также работает, когда ваш код импортирует сторонние зависимости в каталог node_modules

Прим. перев.: все модули, используемые в npm, по умолчанию подключаются из директории node_modules.

Давайте попробуем выполнить некоторые манипуляции с графом зависимостей с помощью jQuery вместо document.getElementById(). Сначала установим jQuery:

$ yarn add --dev jquery

Затем обновим page.js, чтобы включить в него jQuery:

import {sortByDistance} from "./distance";
import $ from "jQuery";
let stores = [
 {name: "Cambridge Naturals",     x: -71.1189, y: 42.3895},
 {name: "Sarah's Market",         x: -71.1311, y: 42.3823},
 {name: "Whole Foods Fresh Pond", x: -71.1420, y: 42.3904},
];
let here = {name: "You are here",  x: -71.1470, y: 42.3834};
let nearest = sortByDistance(here, stores)[0];
$("#nearest-store").html(nearest.name);

Теперь граф зависимостей выглядит так:

граф зависимостей, dependencies graph

Ваш новый граф зависимостей, где page.js включает jQuery

И если выполнить $ npx webpack app/src/page.js и перезагрузить index.html (несмотря на то, что размер файла dist/main.js намного больше из-за кода jQuery) приложение по-прежнему работает.

Прежде чем продолжить, перейдите в файл package.json и добавьте эти три строчки:

"scripts": {
 "build": "webpack app/src/page.js"
}

Теперь, чтобы запустить сборку пакета, можно просто выполнить $ yarn build вместо $ npx webpack app/src/page.js. Если команда для сборки изменится, будет удобнее просто обновить её в файле package.json с помощью новой команды сборки и вы по-прежнему можете выполнять сборку с помощью $ yarn build, вместо того чтобы привыкать к запуску новой команды (Commit 4).

Настройка Webpack с помощью файла webpack.config.js

То, что можно сделать с помощью команды $ npx webpack app/src/page.js, является стандартным режимом работы Webpack. Если выполнить $ webpack [entry-file.js], то он создаст граф зависимостей из входного файла и выдаст пакетный файл dist/main.js. Но задать расположение точек входа и выхода программы можно, настроив файл конфигурации. Поместите следующий код в файл с именем webpack.config.js:

module.exports = {
 entry: __dirname + "/app/src/page.js",
 output: {
   path: __dirname + "/dist/",
 }
}

Теперь можно выполнить $ npx webpack или сделать ту же сборку, что и раньше, без указания точки входа в программу в аргументах командной строки, т. к. теперь всё это есть в webpack.config.js. Также это значит, что нужно обновить скрипт файла package.json следующим образом:

"build": "webpack",

Если в файле конфигурации изменить путь вывода на что-то вроде __dirname + "/somewhere_else", то при повторном выполнении команды $ yarn build пакетный файл будет помещён в somewhere_else/main.js (Commit 5).

Файл конфигурации предназначен не только для настройки расположения входных и выходных файлов. Также можно настроить что именно Webpack делает, когда встречает файлы разных типов, используя специальные загрузчики, которые на самом деле являются JavaScript-программами, преобразующими ваш код. Например, в файле конфигурации может быть правило, определяющее, что, если Webpack встречает файл TypeScript в своём графе зависимостей, этот файл отправляется в загрузчик, который преобразует его из TypeScript в обычный JavaScript.

Загрузчик, который будет использоваться далее, — это Babel. Если вы не использовали его раньше, Babel — это инструмент, который берёт код JS, использующий современные функции, и преобразует его в эквивалент, совместимый со старыми версиями JavaScript. Это позволяет вашему приложению работать в старых браузерах или в браузерах, которые ещё не поддерживают некоторые новые функции JavaScript. В конце концов, некоторые ленивцы не обновляли свои браузеры с 2009 года. Некоторая часть написанного кода не будет работать в браузере 2009 года:

return points.sort((pt1, pt2) =>
 distance(pt1, myPt) — distance(pt2, myPt));

Здесь используется стрелочная функция и старые браузеры её не воспринимают. Поэтому используем загрузчик и отправим эту функцию в прошлое. Для начала выполним следующее:

$ yarn add --dev babel-core babel-loader@7.1.5 babel-preset-env

Затем в файле webpack.config.js добавим следующий код в module.exports:

module: {
 rules: [
   {
     test: /\.js$/,
     exclude: ["/node_modules/"],
     use: [
       {
         loader: "babel-loader",
         options: {
           presets: ["env"],
         },
       },
     ],
   },
 ],
},

Этот код добавит новое правило в ваш Webpack. Если в дереве зависимостей Webpack встречает файл, который заканчивается на .js (например, distance.js), и этот файл отсутствует в папке node_modules (например, jQuery), то к этому файлу применяется данное правило.

Любой файл, который соответствует этому правилу, проходит через все загрузчики в блоке use (в данном случае только через загрузчик babel). Таким образом, файлы distance.js и page.js пройдут через загрузчик, что приведёт к удалению стрелочной функции в distance.js, а затем Webpack продолжит свой путь сборки. Тем временем, как только Webpack встречает jQuery, он просто загружает этот код как он есть без загрузчиков, поскольку jQuery находится в каталоге node_modules.

загрузчик Babel, loader Babel

Теперь, если выполнить $ yarn build и посмотреть исходный код в dist/main.js, фрагмент, соответствующий вашей функции сортировки, использует ключевое слово function, а не стрелочную функцию (Commit 6).

до использования загрузчика

после использования загрузчика

Подсвеченный код на верхнем изображении — это функция sortByDistance() из dist/main.js до использования загрузчика, а подсвеченный код внизу — та же функция после добавления загрузчика. Обратите внимание на то, как выше мы используем стрелочную функцию, а ниже присутствует уже знакомое браузеру 2009 года ключевое слово function.

Теперь приложение готово к работе на устаревших браузерах. Чтобы этот код был поддерживаемым, следует написать несколько тестов для него.

Добавление тестовых сценариев в сборку

Добавим несколько тестовых сценариев в файл distance.js. Для этого будем использовать Mocha, пакетный инструмент для написания тестов, и Chai в качестве нашей библиотеки установок. Выполните следующую команду:

$ yarn add --dev mocha chai

Затем создайте новый каталог app/test и новый файл app/test/distance.test.js, содержащий следующий фрагмент:

import {expect} from "chai";
import {distance, sortByDistance} from "../src/distance";
describe("distance", function() {
 it("calculates distance with the good ol' Pythagorean Theorem", function() {
   let origin = {x: 0.0, y: 0.0};
   let point = {x: 3.0, y: 4.0};
   expect(distance(point, origin)).to.equal(5);
 });
});
describe("sortByDistance", function() {
 it("sortsByDistance", function() {
   let places = [
     {name: "Far away", x: 100, y: 50},
     {name: "Nearby", x: 20, y: 10},
   ];
   let origin = {name: "Origin", x: 0, y: 0};
   let sorted = sortByDistance(origin, places);
     expect(sorted[0].name).to.equal("Nearby");
     expect(sorted[1].name).to.equal("Far away");
   });
});

Теперь у вас есть тестовые сценарии для функций distance() и sortByDistance(), устанавливающие, что distance() вычисляет формулу расстояния, а sortByDistance() сортирует массивы координат с помощью формулы расстояния, используя наборы тестов Mocha и установки Chai. Довольно стандартная тестовая настройка.

Однако, если выполнить $ mocha app/test/distance.test.js, будет ошибка “Код JavaScript недопустим”, потому что он содержит ключевое слово import, которое Node в данный момент не поддерживает. Но что если обойти это ограничение, используя Webpack для управления зависимостями тестового кода?

Прим.: это можно легко исправить, просто используя require вместо import в тестовых файлах. Но тестовый код также будет проходить через процесс сборки, если вы тестируете JavaScript-код типа Flow, который использует аннотации, или веб-приложения Vue.js, которые используют файлы .vue. Все они должны быть преобразованы в обычный JavaScript.

Список тестовых инструкций:

  1. Webpack строит граф зависимостей, начинающийся с тестовых файлов, а не с файлов приложения.
  2. Webpack создаёт файл JavaScript, содержащий весь тестовый код и его зависимости без ключевого слова import.
  3. Выполняются тесты, запуская Mocha для этого JavaScript-файла.

Всё это будет выглядеть следующим образом:

dependencies, builds

Как можно увидеть, будут происходить две отдельные сборки. Одна из которых содержит код приложения в качестве точки входа и папку dist в качестве выходной директории, а другая — тестовые файлы в качестве точки входа и папку test-dist в качестве выходного каталога. Итак, давайте обновим конфигурационный файл, чтобы Webpack поддерживал вторую сборку:

let glob = require("glob");
let entry = __dirname + "/app/src/page.js";
let outputPath = __dirname + "/dist/";
if (process.env.TESTBUILD) {
 entry = glob.sync(__dirname + "/app/test/**/*.test.js");
 outputPath = __dirname + "/test-dist/";
}
module.exports = {
 entry: entry,
 output: {
   path: outputPath,
 },
 // остальная часть config-файла остаётся прежней

Давайте разберёмся, что этот код делает. В строке 4 есть оператор if, который  выполняется, если системная переменная TESTBUILD имеет непустое значение. Так что, если вы выполните $ TESTBUILD=true webpack, то вам придётся вводить оператор if, но это не потребуется, если просто выполнить $ npx webpack.

Внутри оператора if происходит выбор, какой JS-файл является точкой входа. Вместо уже установленного выходного каталога dist будет папка test-dist. А вместо точки входа app/src/path.js — массив файлов, соответствующих глобальному выражению app/test/**/*.test.js. Другими словами, это все файлы, которые находятся в каталоге app/test и имеют путь, заканчивающийся на .test.js.

Новая точка входа и выходной путь передаются в объект module.exports, затем запускается Webpack для создания тестовой сборки. Конфигурация Webpack представляет собой обычный код JavaScript, поэтому можно использовать стандартную библиотеку Node и операторы if для её настройки. Если выполнить $ TESTBUILD=true npx webpack, то можно увидеть каталог test-dist. А если запустить $ npx mocha test-dist/main.js, то можно увидеть, как выполняются ваши тесты.

successful testing

Наконец, в разделе scripts вашего package.json добавьте следующую строку:

"test": "TESTBUILD=true webpack && mocha test-dist/main.js && rm -rf test-dist"

Это означает, что когда вы выполняете $ yarn test, создаётся каталог test-dist (каталог сборки тестирования) с помощью Webpack, затем запускается Mocha для этой сборки, и, наконец, код $ rm -rf test-dist удаляет каталог test-dist, поскольку он больше не используется (Commit 7).

Маппинг исходных файлов тестового кода

Тестовая сборка готова, но есть один нюанс, касающийся тестирования кода. Если запустить Mocha для файла test-dist/main.js и один из этих тестов не пройдёт, как это будет выглядеть? Давайте сделаем тест формулы расстояния в app/test/distance.test.js ошибочным:

describe("distance", function() {
 it("calculates distance with the good ol' Pythagorean Theorem", function() {
   let origin = {x: 0.0, y: 0.0};
   let point = {x: 3.0, y: 4.0};
   expect(distance(point, origin)).to.equal(2071);
 });
});

Выполните $ yarn test и вот что получится:

Assertion error

Тест провален, но нельзя узнать, в какой строке исходного кода находится ошибка. Если таких тестов для веб-приложения у вас много, то поиски строки с ошибкой затянутся надолго.

Код с ошибкой находится в строке 8 в файле app/test/distance.test.js, но Mocha запускается для файла test-dist/main.js, поэтому, с точки зрения Mocha, ошибка находится в строке 116. К счастью, Webpack поддерживает source maps, которые покажут, какая строка кода соответствует ошибке. Source maps (или карты кода) — это файлы исходного кода, которые показывают точное соответствие элементов готового рабочего кода проекта и вашего кода разработки. Выполняется своего рода проход декодером по пакетному файлу main.js, чтобы получить исходные строки кода, которые соответствуют связному коду. Давайте обновим оператор if в файле webpack.config.js:

let entry = __dirname + "/app/src/path.js";
let outputPath = __dirname + "/dist/";
let devtool = "";
if (process.env.TESTBUILD) {
 entry = glob.sync(__dirname + "/app/test/**/*.test.js");
 outputPath = __dirname + "/test-dist/";
 devtool = "source-map";
}

Затем в объект module.exports добавим строку:

devtool: devtool,

Теперь в тестировочных сборках каталог test-dist будет содержать файл типа source maps. Выполните $ npx webpack TESTBUILD=true и каталог test-dist будет содержать файл main.js.map в дополнение к пакету main.js.

devtool: sourcemap

Чтобы Mocha мог использовать эту source map при запуске тестов, необходимо установить ещё один пакет:

$ yarn add --dev source-map-support

Теперь, чтобы его использовать, нужно обновить скрипт Mocha в разделе scripts.test файла package.json:

TESTBUILD=true webpack && mocha test-dist/main.js --require source-map-support/register && rm -rf test-dist

Флаг --require source-map-support/register требует пакет source-map-support, это означает, что Mocha будет использовать source map, если она доступна. Теперь, если вы выполните $ yarn test и получите ошибку, вы увидите, в какой строке она находится, и сможете исправить код (Commit 8).

ошибочный сценарий тестирования

Итак, теперь у вас есть пример настройки обычных и тестовых сборок с использованием маппинга. Существует также множество других способов, которыми вы можете воспользоваться в своих сборках, например, объединение нескольких загрузчиков JavaScript для обработки вашего кода как на конвейере или же запуск Webpack как отладочного сервера, чтобы моментально видеть, как изменения в коде влияют на окончательную сборку Webpack. Продолжайте пробовать различные пакеты для компоновки файла webpack.config.js!

Для более точной настройки Webpack вам может быть полезен следующий материал:

Не смешно? А здесь смешно: @ithumor