СТАТЬЯ

Тестирование с помощью Node, Jest и JSDOM

Из статьи Лукаса да Коста Тестирование приложений JavaScript

В этой статье вы узнаете, как использовать Node и Jest для тестирования кода, написанного для запуска в браузере.

Получите скидку 40% на Тестирование приложений JavaScript, введя fccdacosta в поле кода скидки при оформлении заказа на сайте manning.com.

Выпечка на профессиональной кухне сильно отличается от выпечки дома. Дома у вас не всегда будут все уникальные ингредиенты, которые вы найдете на полках шеф-повара. У вас, вероятно, не будет такой же модной техники или такой же безупречной кухни. Тем не менее, это не значит, что вы не можете испечь отличные десерты. Вам остается только адаптироваться.

Точно так же запуск JavaScript в браузере существенно отличается от запуска JavaScript в Node. В зависимости от случая код JavaScript, работающий в браузере, вообще не может работать в Node, и наоборот. Поэтому, чтобы протестировать ваше внешнее приложение, вам придется пройти через несколько дополнительных обручей, но это не значит, что вы не можете этого сделать. С некоторыми адаптациями вы можете использовать Node для запуска JavaScript, написанного для браузера, точно так же, как Луи может печь аппетитные чизкейки дома без причудливой французской посуды, которую он купил в пекарне.

В браузере JavaScript имеет доступ к разным API и, следовательно, имеет разные возможности.

В браузерах JavaScript имеет доступ к глобальной переменной с именем window. С помощью объекта window вы можете изменять содержимое страницы, запускать действия в браузере пользователя и реагировать на такие события, как клики и нажатия клавиш.

С помощью window вы можете, например, прикрепить прослушиватель к кнопке, чтобы каждый раз, когда пользователь щелкает ее, ваше приложение обновляло количество товара в инвентаре пекарни.

Попробуйте создать приложение, которое делает именно это. Напишите HTML-файл, содержащий кнопку, счетчик и загружающий скрипт с именем main.js.

Листинг 1. index.html

<!DOCTYPE html>
 <html lang="en">
 <head>
     <meta charset="UTF-8">
     <title>Inventory Manager</title>
 </head>
 <body>
     <h1>Cheesecakes: <span id="count">0</span></h1>
     <button id="increment-button">Add cheesecake</button>  #1
     <script src="main.js"></script>
 </body>
 </html>

#1 Скрипт, с помощью которого мы сделаем страницу интерактивной.

В main.js найдите кнопку по ее ID и прикрепите к ней слушатель. Всякий раз, когда пользователи нажимают эту кнопку, срабатывает прослушиватель, и приложение увеличивает счетчик чизкейка.

Листинг 2. main.js

let data = { count: 0 };
  
 const incrementCount = () => {
   data.cheesecakes++;
   window.document.getElementById("count")
     .innerHTML = data.cheesecakes;
 };
  
 const incrementButton = window.document.getElementById("increment-button");
 incrementButton.addEventListener("click", incrementCount);

#1. Функция, которая обновляет состояние приложения

#2 Присоединение прослушивателя событий, который будет вызыватьincrementCountпри каждом нажатии кнопки

Чтобы увидеть эту страницу в действии, выполните npx http-server ./ в той же папке, что и ваша index.html, а затем откройте localhost:8080.

Поскольку этот сценарий выполняется в браузере, он имеет доступ к window и, таким образом, может манипулировать браузером и элементами на странице.

В отличие от браузера, Node не может запустить этот скрипт. Попробуйте выполнить его с node main.js, и Node сразу же сообщит вам, что он нашел ReferenceError, потому что «окно не определено».

Эта ошибка возникает из-за того, что в Node нет файла window. Вместо этого, поскольку он был разработан для запуска различных типов приложений, он предоставляет вам доступ к таким API, как process, который содержит информацию о текущем процессе Node.js, и require, который позволяет вам импортировать различные файлы JavaScript.

На данный момент, если вы хотите написать тесты для функции incrementCount, вам придется запускать их в браузере. Поскольку ваш скрипт зависит от DOM API, вы не сможете запустить эти тесты в Node. Если бы вы попытались это сделать, вы бы столкнулись с тем же ReferenceError, что вы видели, когда выполняли node main.js. Учитывая, что Jest зависит от специфичных для Node API и поэтому работает только в Node, вы также не можете использовать Jest.

Чтобы иметь возможность запускать тесты в Jest, вместо запуска тестов в браузере вы можете перенести API браузера в Node с помощью JSDOM. Вы можете думать о JSDOM как о реализации среды браузера, которая может работать в Node. Он реализует веб-стандарты с использованием чистого JavaScript. С помощью JSDOM вы можете эмулировать, например, манипулировать DOM и присоединять прослушиватели событий к элементам.

Что такое JSDOM?

JSDOM — это реализация веб-стандартов, написанная исключительно на JavaScript, которую вы можете использовать в Node.

Чтобы понять, как работает JSDOM, давайте воспользуемся им для создания объекта, который представляет index.html и который мы можем использовать в Node.

Сначала создайте файл package.json с npm init -y, а затем установите JSDOM с npm install jsdom.

Используя fs, вы прочитаете файл index.html и передадите его содержимое в JSDOM, чтобы он мог создать представление этой страницы.

Листинг 3. page.js

const fs = require("fs");
 const { JSDOM } = require("jsdom");
  
 const html = fs.readFileSync("./index.html");
 const page = new JSDOM(html);
  
 module.exports = page;

Представление page содержит свойства, которые вы найдете в браузере, например, window. Поскольку теперь вы имеете дело с чистым JavaScript, вы можете использовать page в Node.

Попробуйте импортировать page в скрипт и взаимодействовать с ним, как в браузере. Вы можете попробовать, например, прикрепить новый абзац к файлу page.

Листинг 4. example.js

const page = require("./page");  #1
  
 console.log("Initial page body:");
 console.log(page.window.document.body.innerHTML);
  
 const paragraph = page.window.document.createElement("p");  #2
 paragraph.innerHTML = "Look, I'm a new paragraph";  #3
 page.window.document.body.appendChild(paragraph);  #4
  
 console.log("Final page body:");
 console.log(page.window.document.body.innerHTML);

#1 Импорт представления JSDOM страницы.

#2 Создание элемента абзаца

#3 Обновление содержания абзаца

#4 Прикрепление абзаца к странице

Чтобы выполнить приведенный выше скрипт в Node, запустите node example.js.

С JSDOM вы можете делать почти все, что вы можете делать в браузере, включая обновление элементов DOM, таких как count.

Листинг 5. example.js

const page = require("./page");
  
 // ...
  
 console.log("Initial contents of the count element:");
 console.log(page.window.document.getElementById("count").innerHTML);
  
 page.window.document.getElementById("count").innerHTML = 1337;
 console.log("Updated contents of the count element:");  #1
 console.log(page.window.document.getElementById("count").innerHTML);
  
 // ...

#1 Обновление содержимого элемента count

Благодаря JSDOM вы можете запускать свои тесты в Jest, которые, как я уже упоминал, могут работать только в Node.

Передав значение "jsdom" в параметр Jest testEnvironment, вы можете настроить глобальный экземпляр JSDOM, который вы можете использовать при запуске своих тестов.

Чтобы настроить среду JSDOM в Jest, начните с создания нового файла конфигурации Jest с именем jest.config.js. В этом файле экспортируйте объект, значение свойства testEnvironment которого равно "jsdom".

Листинг 6. jest.config.js

module.exports = {
   testEnvironment: "jsdom",
 };

ПРИМЕЧАНИЕ. На момент написания текущая версия Jest — 24.9. В этой версииjsdomявляется значением по умолчанию для JesttestEnvironment, поэтому вам не обязательно указывать его.

Если вы не хотите создавать файл jest.config.js вручную, вы можете использовать ./node_modules/.bin/jest --init для автоматизации этого процесса. Затем автоматическая инициализация Jest предложит вам выбрать тестовую среду и предложит вам вариант jsdom.

Теперь попробуйте создать файл main.test.js и импортировать main.js, чтобы посмотреть, что получится.

Листинг 7. main.test.js

require("./main");

Если вы попытаетесь запустить этот тест с помощью Jest, вы все равно получите сообщение об ошибке.

FAIL  ./main.test.js
 ● Test suite failed to run
  
   TypeError: Cannot read property 'addEventListener' of null
  
     10 |
     11 | const incrementButton = window.document.getElementById("increment-button");
   > 12 | incrementButton.addEventListener("click", incrementCount);

Несмотря на то, что window теперь существует благодаря Jest, настроившему JSDOM, его DOM не построен из index.html. Вместо этого он создается из пустого HTML-документа, поэтому increment-button отсутствует. Поскольку кнопки не существует, вы не можете вызвать ее метод addEventListener.

Чтобы использовать index.html в качестве страницы, которую будет использовать экземпляр JSDOM, вам необходимо прочитать index.html и назначить его содержимое window.document.body.innerHTML перед импортом main.js.

Листинг 8. main.test.js

const fs = require("fs");
 window.document.body.innerHTML = fs.readFileSync("./index.html");
  
 require("./main");

#1 Назначение содержимого файлаindex.htmlтелу страницы

Поскольку теперь вы настроили глобальный window для использования содержимого index.html, Jest сможет успешно выполнить main.test.js.

Последний шаг, который вам нужно сделать, чтобы иметь возможность написать тест для incrementCount, — это предоставить его. Поскольку main.js не раскрывает ни incrementCount, ни data, вы не можете выполнить функцию или проверить ее результат. Решите эту проблему, используя module.exports для экспорта data и функцию incrementCount.

Листинг 9. main.js

// ...
  
 module.exports = { incrementCount, data };

Наконец, вы можете продолжить и создать файл main.test.js, который устанавливает начальный счет, выполняет incrementCount и проверяет новый count в пределах data. Здесь мы используем паттерн 3А — организовывать, действовать, утверждать.

Листинг 10. main.test.js

const fs = require("fs");
 window.document.body.innerHTML = fs.readFileSync("./index.html");
  
 const { incrementCount, data } = require("./main");
  
 describe("incrementCount", () => {
   test("incrementing the count", () => {
     data.cheesecakes = 0;  #1
     incrementCount();  #2
     expect(data.cheesecakes).toBe(1);  #3
   });
 });

#1 Расстановка: установите начальное количество чизкейков.

Действие №2: задействуйте функциюincrementCount, которая является тестируемым модулем.

Утверждение #3: проверьте, содержит лиdata.cheesecakesправильное количество чизкейков.

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

Поскольку вы использовали module.exports для предоставления incrementCount и данных, main.js теперь будет выдавать ошибку при запуске в браузере. Чтобы увидеть ошибку, попробуйте снова запустить свое приложение с помощью npx http-server ./ и получить доступ к localhost:8080 с открытыми инструментами разработчика вашего браузера.

Uncaught ReferenceError: module is not defined
     at main.js:14

Ваш браузер выдает эту ошибку, потому что он не имеет глобально доступного module. Опять же, вы столкнулись с проблемой, связанной с различиями между браузерами и Node.

Распространенной стратегией запуска в браузерах файлов, использующих систему модулей Node, является использование инструмента, объединяющего зависимости в один файл, который может выполняться браузером. Одной из основных целей таких инструментов, как webpack и browserify, является такое объединение.

Установите browserify как dev-dependency и запустите ./node_modules/.bin/browserify main.js -o bundle.js, чтобы преобразовать файл main.js в удобный для браузера bundle.js.

ПРИМЕЧАНИЕ. Полную документацию по Browserify можно найти на сайте browserify.org.

После запуска browserify обновите index.html, чтобы использовать bundle.js вместо main.js.

Листинг 11. index.html

<!DOCTYPE html>
 <html lang="en">
   <!-- ... -->
   <body>
     <!-- ... -->
     <script src="bundle.js"></script>
   </body>
 </html>

#1 Файл bundle.js будет сгенерирован из main.js. Это один файл, содержащий все прямые и косвенные зависимости main.js.

СОВЕТ. Вам потребуется перестраиватьbundle.jsкаждый раз при измененииmain.js.

Поскольку вам приходится запускать его часто, было бы разумно создать сценарий NPM, который запускает browserify с правильными аргументами.

Чтобы создать скрипт NPM, который запускает browserify, обновите package.json, чтобы он включал следующие строки.

Листинг 12. package.json

{
   // ...
   "scripts": {
     // ...
     "build": "browserify main.js -o bundle.js"
   },
   // ...
 }

#1 Просматривает дерево зависимостей файла main.js и объединяет все зависимости в один файл bundle.js.

Используя такие инструменты, как browserify или webpack, вы можете преобразовать написанный вами тестируемый код для запуска в Node, чтобы он мог работать в браузере.

Использование упаковщиков позволяет тестировать модули по отдельности и упрощает управление ими в браузерах. Когда вы объединяете свое приложение в один файл, вам не нужно управлять несколькими тегами script на странице HTML.

В этой статье вы узнали, как использовать Node и Jest для тестирования JavaScript, предназначенного для работы в браузере. Вы увидели различия между этими двумя платформами и узнали, как перенести API-интерфейсы браузера в Node с помощью JSDOM.

Вы также видели, как browserify может помочь вам протестировать ваше приложение, позволяя разделить его на отдельные модули, которые вы можете протестировать в Node, а затем объединить для запуска в браузере.

Используя эти инструменты, вы можете протестировать свое браузерное приложение в Node, используя Jest.

Это все на данный момент.

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