Tutorial

Тестирование модуля Node.js с использованием Mocha и Assert

Node.jsDevelopmentJavaScript

Автор выбрал фонд Open Internet/Free Speech для получения пожертвования в рамках программы Write for DOnations.

Введение

Тестирование является неотъемлемой частью разработки программного обеспечения. Обычно программисты запускают код, который тестирует разработанные ими приложения, при внесении каких-либо изменений, чтобы убедиться, что все работает, как надо. При правильных тестовых настройках этот процесс можно автоматизировать, что значительно позволит сэкономить время. Запуск тестов непосредственно после написания нового кода гарантирует сохранность ранее существовавших функций. Таким образом разработчик может быть уверенным в базе кода, особенно когда она внедряется в производственную среду, чтобы пользователи могли взаимодействовать с ней.

Мы создаем примеры тестирования с помощью структур тестовых фреймворков. Mocha — это популярный тестовый фреймворк JavaScript, используемый для организации и запуска тестовых файлов. Однако Mocha не подтверждает поведение нашего кода. Для сравнения значений в тесте мы можем использовать модуль Node.js assert​​​​​​.

В этой статье вы узнаете, как написать тесты для списка дел (TODO) для модуля Node.js. Для создания тестов будет настроен и использован фреймворк Mocha. Также будет использован модуль Node.js assert для создания самих тестов. В этом смысле вы будете использовать Mocha в качестве планировщика, а assert​​​ для реализации плана.

Предварительные требования

  • Node.js, установленный на вашем компьютере для разработки. В этом обучающем руководстве используется версия Node.js 10.16.0. Чтобы установить его в macOS или Ubuntu 18.04, следуйте указаниям руководства Установка Node.js и создание локальной среды разработки в macOS или раздела Установка с помощью PPA руководства Установка Node.js в Ubuntu 18.04.
  • Базовые знания JavaScript, которые можно получить из нашей серии статей Программирование на JavaScript.

Шаг 1 — Создание модуля Node

Давайте начнем с написания модуля Node.js, который мы будем тестировать. Этот модуль будет управлять списком элементов TODO. Используя этот модуль, мы сможем перечислить все элементы списка TODO, которые нужно отследить, добавить новые элементы и отметить некоторые как выполненные. Также мы сможем экспортировать список элементов TODO в файл CSV. Если вам нужно вспомнить, как писать модули Node.js, прочтите нашу статью Создание модуля Node.js.

Для начала необходимо настроить среду программирования. Создайте папку с именем проекта в своем терминале. В данном обучающем руководстве будет использоваться имя todos:

  • mkdir todos

Затем откройте эту папку:

  • cd todos

Теперь инициализируйте npm, поскольку позже мы будем использовать его функцию командной строки для запуска тестирования:

  • npm init -y

У нас есть только одна зависимость, Mocha, которую мы будем использовать для организации и запуска тестов. Для загрузки и установки Mocha воспользуйтесь следующей командой:

  • npm i request --save-dev mocha

Мы установим Mocha как зависимость dev, поскольку это не требуется модулем в производственных настройках. Если вы хотите узнать больше о пакетах Node.js или npm, ознакомьтесь с руководством Использование модулей Node.js с npm и package.json.

Наконец, создадим файл, который будет содержать код нашего модуля:

  • touch index.js

Теперь мы готовы создать наш модуль. Откройте index.js​​​ в текстовом редакторе, например nano:

  • nano index.js

Давайте начнем с определения класса Todos. Этот класс содержит все функции, необходимые для управления нашим списком TODO. Добавьте следующие строки кода в index.js:

todos/index.js
class Todos {
    constructor() {
        this.todos = [];
    }
}

module.exports = Todos;

Начнем с создания класса Todos. Его функция constructor() не принимает аргументов, поэтому нам не нужно предоставлять значения для создания объекта для данного класса. Все, что мы делаем, когда инициализируем объект Todos, — это создаем свойство todos, которое является пустым массивом.

Линия модулей позволяет другим модулям Node.js требовать наш класс Todos. Без прямого экспорта класса тестовый файл, который мы создадим позже, не сможет использовать его.

Давайте добавим функцию для возврата сохраненного массива todos. Запишите следующие выделенные строки:

todos/index.js
class Todos {
    constructor() {
        this.todos = [];
    }

    list() {
        return [...this.todos];
    }
}

module.exports = Todos;

Функция list() возвращает копию массива, используемого классом. Она делает копию массива, используя деструктурирующий синтаксис JavaScript. Мы создаем копию массива, чтобы изменения, которые пользователь вносит в массив, возвращенный функцией list(), не влияли на массив, используемый объектом Todos.

Примечание. Массивы JavaScript — это справочные файлы. Это значит, что для любого присваивания переменной для массива или вызова функции с массивом в качестве параметра JavaScript обращается к оригинальному созданному массиву. Например, если у нас есть массив с тремя элементами с именем x и мы создаем новую переменную y, так что y = x, y и x относятся к одному и тому же. Все изменения, выполняемые для массива с y, влияют на переменную x и наоборот.

Теперь создадим функцию add(), которая добавляет новый элемент TODO:

todos/index.js
class Todos {
    constructor() {
        this.todos = [];
    }

    list() {
        return [...this.todos];
    }

    add(title) {
        let todo = {
            title: title,
            completed: false,
        }

        this.todos.push(todo);
    }
}

module.exports = Todos;

Наша функция add() берет строку и помещает ее в свойство title нового объекта JavaScript. Новый объект также имеет свойство completed, которое по умолчанию устанавливается на false. Затем мы добавляем этот новый объект к нашему массиву TODO.

Важной функцией в менеджере TODO является отметка элементов как завершенные. Для выполнения этой задачи мы пройдем в цикле по нашему массиву todos, чтобы найти элемент TODO, который ищет пользователь. Если элемент найден, отметим его как завершенный. Если ничего не найдено, выдадим ошибку.

Добавьте функцию complete()​​​ следующим образом:

todos/index.js
class Todos {
    constructor() {
        this.todos = [];
    }

    list() {
        return [...this.todos];
    }

    add(title) {
        let todo = {
            title: title,
            completed: false,
        }

        this.todos.push(todo);
    }

    complete(title) {
        let todoFound = false;
        this.todos.forEach((todo) => {
            if (todo.title === title) {
                todo.completed = true;
                todoFound = true;
                return;
            }
        });

        if (!todoFound) {
            throw new Error(`No TODO was found with the title: "${title}"`);
        }
    }
}

module.exports = Todos;

Сохраните файл и выйдите из текстового редактора.

Теперь у нас есть базовый менеджер TODO, с которым можно экспериментировать. Далее проверим код вручную, чтобы убедиться в работе приложения.

Шаг 2 — Ручное тестирование кода

В этом шаге мы запустим функции нашего кода и посмотрим на вывод, чтобы убедиться, что он соответствует ожиданиям. Это называется тестированием вручную. Оно выполняется аналогично наиболее распространенным методам тестирования, используемым программистами. Хотя позже мы автоматизируем тестирование с помощью Mocha, сначала протестируем наш код вручную, чтобы иметь лучшее представление о том, как тестирование вручную отличается от тестовых фреймворков.

Добавим в наше приложение два элемента TODO и отметим один из них как завершенный. Запустите Node.js REPL в той же папке, что и файл index.js:

  • node

Вы увидите командную строку > в REPL, которая указывает, что мы можем ввести код JavaScript. Введите в командную строку следующее:

  • const Todos = require('./index');

С помощью require() мы загружаем модуль TODO в переменную Todos. Помните, что наш модуль возвращает класс Todos по умолчанию.

Теперь инстанцируем объект для этого класса. В REPL добавьте следующую строку кода:

  • const todos = new Todos();

Мы можем использовать объект todos для проверки работы реализации. Добавим первый элемент TODO:

  • todos.add("run code");

До сих пор мы не видели никаких выводов в нашем терминале. Давайте убедимся, что мы сохранили элемент TODO run code, получив список всех наших TODO:

  • todos.list();

Вы увидите следующий вывод в вашем REPL:

Output
[ { title: 'run code', completed: false } ]

Это ожидаемый результат: у нас есть один элемент TODO в нашем массиве TODO, и он не завершен по умолчанию.

Добавим другой элемент TODO:

  • todos.add("test everything");

Отметим первый элемент TODO как завершенный:

  • todos.complete("run code");

Теперь наш объект todos будет управлять двумя элементами: run code и test everything. TODO run code также будет завершен. Подтвердим это, вызвав list()​​​ еще раз:

  • todos.list();

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

Output
[ { title: 'run code', completed: true }, { title: 'test everything', completed: false } ]

Теперь закройте REPL следующим образом:

  • .exit

Мы подтвердили, что наш модуль работает соответствующим образом. Хотя мы не поместили наш код в тестовый файл и не использовали тестовую библиотеку, мы вручную протестировали код. К сожалению, эта форма тестирования займет много времени, если ее использовать при выполнении каждого изменения. Далее попробуем выполнить автоматизированное тестирование в Node.js и посмотрим, возможно ли решить данную проблему с помощью тестового фреймворка Mocha.

Шаг 3 — Создание первого теста с помощью Mocha и Assert

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

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

В этом обучающем руководстве мы используем тестовый фреймворк Mocha с модулем Node.js assert​​​. Давайте на практике посмотрим, как они вместе работают.

Для начала создадим новый файл для хранения кода теста:

  • touch index.test.js

Теперь с помощью предпочтительного текстового редактора откройте файл тестирования. Можно использовать nano, как раньше:

  • nano index.test.js

В первой строке текстового файла мы загрузим модуль TODO аналогично тому, как мы делали в оболочке Node.js. Затем мы загрузим модуль assert​​​, чтобы он был на момент создания тестов. Добавьте следующие строки:

todos/index.test.js
const Todos = require('./index');
const assert = require('assert').strict;

Свойство strict​​​​ модуля assert позволит нам использовать специальные тесты эквивалентности, рекомендуемые Node.js, которые также подходят для проверок в дальнейшем, поскольку отвечают за большее число вариантов использования.

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

describe([String with Test Group Name], function() {
    it([String with Test Name], function() {
        [Test Code]
    });
});

Обратите внимание на две ключевые функции: describe() и it()​​​. Функция describe() используется для группировки аналогичных тестов. Для Mocha не требуется запускать тесты, но их группировка упростит поддержку нашего кода теста. Рекомендуется группировать тесты таким образом, чтобы было проще обновлять аналогичные вместе.

it() содержит наш код теста. Именно здесь мы могли бы взаимодействовать с функциями нашего модуля и использовать библиотеку assert​​. Многие функции it() могут быть определены в функции describe().

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

todos/index.test.js
...
describe("integration test", function() {
});

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

Давайте добавим функцию it(), чтобы начать тестирование нашего кода модуля:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
    });
});

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

Для нашего первого теста мы создадим новый объект Todos и проверим, что в нем нет элементов:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
        let todos = new Todos();
        assert.notStrictEqual(todos.list().length, 1);
    });
});

Первая новая строка кода инстанциировала новый объект Todos, как мы делали в Node.js REPL или другом модуле. Во второй новой строке мы использовали модуль assert​​​.

Из модуля assert мы используем метод notStrictEqual()​​​. Эта функция учитывает два параметра: значение, которое необходимо протестировать (называется фактическое значение), и значение, которое мы ожидаем получить (называется ожидаемое значение). Если эти оба аргумента одинаковы, notStrictEqual()​​​ выдает ошибку о непрохождении теста.

Сохраните и закройте index.test.js.

Базовый сценарий будет истинным, так как длина должна быть 0, что не равно 1. Давайте убедимся в этом, запустив Mocha. Для этого нам потребуется модифицировать наш файл package.json. Откройте файл package.json в своем текстовом редакторе:

  • nano package.json

Теперь в свойстве scripts измените его следующим образом:

todos/package.json
...
"scripts": {
    "test": "mocha index.test.js"
},
...

Мы только что изменили поведение команды test командной строки npm. Когда мы запустим npm test, npm проверит команду, которую мы только что ввели в package.json. Он будет искать библиотеку Mocha в нашей папке node_modules​​​ и запустит команду mocha с нашим файлом тестирования.

Сохраните и закройте package.json.

Давайте посмотрим, что происходит, когда мы запускаем наш тест. В своем терминале введите:

  • npm test

Команда выдаст следующий вывод:

Output
> todos@1.0.0 test your_file_path/todos > mocha index.test.js integrated test ✓ should be able to add and complete TODOs 1 passing (16ms)

Этот вывод сначала покажет нам, какая группа тестов сейчас запустится. Для каждого отдельного теста в группе тестовый сценарий является ступенчатым. Мы видим наше имя теста так, как мы описали его в функции it(). Галочка с левой стороны тестового сценария указывает на то, что тест пройден.

Внизу мы получим резюме всех наших тестов. В нашем случае один тест был выполнен и завершен в течение 16 мс (время зависит от компьютера).

Тестирование началось успешно. Однако текущий тестовый сценарий может допускать ложные позитивные результаты. Ложные позитивные результаты — это тестовый сценарий, когда тест пройден тогда, когда не должен.

Теперь мы проверяем, что длина массива не равна 1. Давайте изменим тест, чтобы это условие было истинным, когда не должно. Добавьте следующие строки в index.test.js​​​:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
        let todos = new Todos();
        todos.add("get up from bed");
        todos.add("make up bed");
        assert.notStrictEqual(todos.list().length, 1);
    });
});

Сохраните и закройте файл.

Мы добавили два элемента TODO. Давайте запустим тест, чтобы увидеть, что произойдет:

  • npm test

В результате вы получите следующий вывод:

Output
... integrated test ✓ should be able to add and complete TODOs 1 passing (8ms)

Он проходит согласно ожиданиям, так как длина больше 1. Однако он не достигает первоначальной цели проведения этого первого теста. Первый тест должен был подтвердить, что мы начинаем с чистого состояния. Более совершенный тест подтвердит это во всех случаях.

Давайте изменим тест таким образом, что его успешное прохождение будет возможным только при полном отсутствии TODO в памяти. Выполните следующие изменения в index.test.js​​:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
        let todos = new Todos();
        todos.add("get up from bed");
        todos.add("make up bed");
        assert.strictEqual(todos.list().length, 0);
    });
});

Вы изменили notStrictEqual()​​​​​​ на strictEqual()​​​, функцию, которая проверяет эквивалентность между фактическим и ожидаемым аргументом. Строгое равенство (Strict equal) завершится неудачей, если наши аргументы не полностью одинаковы.

Сохраните и закройте файл, затем запустите тест, чтобы увидеть, что произойдет:

  • npm test

В этот раз вывод покажет ошибку:

Output
... integration test 1) should be able to add and complete TODOs 0 passing (16ms) 1 failing 1) integration test should be able to add and complete TODOs: AssertionError [ERR_ASSERTION]: Input A expected to strictly equal input B: + expected - actual - 2 + 0 + expected - actual -2 +0 at Context.<anonymous> (index.test.js:9:10) npm ERR! Test failed. See above for more details.

Этот текст пригодится только для отладки причины непрохождения теста. Обратите внимание, что поскольку тест не был пройден, в начале тестового сценария не было галочки.

Резюме теста находится уже не внизу вывода, а сразу после нашего списка отображенных тестовых сценариев:

...
0 passing (29ms)
  1 failing
...

В остальной части вывода предоставлены данные о непройденных тестах. Сначала мы видим, какие тестовые сценарии не пройдены:

...
1) integrated test
       should be able to add and complete TODOs:
...

Затем мы увидим причину непрохождения теста:

...
      AssertionError [ERR_ASSERTION]: Input A expected to strictly equal input B:
+ expected - actual

- 2
+ 0
      + expected - actual

      -2
      +0

      at Context.<anonymous> (index.test.js:9:10)
...

Выдается AssertionError, когда не выполняется strictEqual()​​​. Мы видим, что ожидаемое значение 0 отличается от фактического значения 2.

Затем мы увидим строку в нашем файле тестирования, где код не выполняется. В этом случае это строка 10.

Теперь мы воочию убедились, что наш тест не будет пройден, если мы будем ожидать некорректные результаты. Давайте изменим наш тестовый сценарий обратно на правильное значение. Откройте файл:

  • nano index.test.js

Затем выберите строки todos.add, чтобы ваш код выглядел следующим образом:

todos/index.test.js
...
describe("integration test", function () {
    it("should be able to add and complete TODOs", function () {
        let todos = new Todos();
        assert.strictEqual(todos.list().length, 0);
    });
});

Сохраните и закройте файл.

Запустите его еще раз, чтобы убедиться в прохождении без каких-либо ложных позитивных результатов:

  • npm test

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

Output
... integration test ✓ should be able to add and complete TODOs 1 passing (15ms)

Теперь мы значительно улучшили отказоустойчивость нашего теста. Давайте перейдем к нашему интеграционному тесту. Следующий шаг — добавить новый элемент TODO в index.test.js​​​:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
        let todos = new Todos();
        assert.strictEqual(todos.list().length, 0);

        todos.add("run code");
        assert.strictEqual(todos.list().length, 1);
        assert.deepStrictEqual(todos.list(), [{title: "run code", completed: false}]);
    });
});

После использования функции add() мы подтверждаем, что у нас есть один элемент TODO, управляемый нашим объектом todos, при этом мы будем использовать strictEqual()​​. Наш следующий тест подтверждает данные в todos с помощью deepStrictEqual(). Функция deepStrictEqual() рекурсивно проверяет, имеют ли наши предполагаемые и реальные объекты одни и те же свойства. В этом случае проверяется, содержат ли оба ожидаемых массива объект JavaScript. Затем проверяется, имеют ли эти объекты JavaScript одинаковые свойства, т. е. оба их свойства title — это run code, а оба свойства completedfalse.

Затем выполним оставшиеся тесты, используя эти два теста равенства, добавив следующие выделенные строки:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
        let todos = new Todos();
        assert.strictEqual(todos.list().length, 0);

        todos.add("run code");
        assert.strictEqual(todos.list().length, 1);
        assert.deepStrictEqual(todos.list(), [{title: "run code", completed: false}]);

        todos.add("test everything");
        assert.strictEqual(todos.list().length, 2);
        assert.deepStrictEqual(todos.list(),
            [
                { title: "run code", completed: false },
                { title: "test everything", completed: false }
            ]
        );

        todos.complete("run code");
        assert.deepStrictEqual(todos.list(),
            [
                { title: "run code", completed: true },
                { title: "test everything", completed: false }
            ]
    );
  });
});

Сохраните и закройте файл.

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

Давайте еще раз запустим тест npm test для получения данного вывода:

Output
... integrated test ✓ should be able to add and complete TODOs 1 passing (9ms)

Вы настроили комплексный тест с помощью Mocha и библиотеки assert.

Рассмотрим ситуацию, когда мы разделили наш модуль с другими разработчиками, и теперь они предоставляют нам обратную связь. Большинство пользователей хотели бы, чтобы функция complete() возвращала ошибку, в случае если ни один элемент TODO еще не добавлен. Добавим это свойство в функцию complete().

Откройте index.js в редакторе:

  • nano index.js

Добавьте в функцию следующее:

todos/index.js
...
complete(title) {
    if (this.todos.length === 0) {
        throw new Error("You have no TODOs stored. Why don't you add one first?");
    }

    let todoFound = false
    this.todos.forEach((todo) => {
        if (todo.title === title) {
            todo.completed = true;
            todoFound = true;
            return;
        }
    });

    if (!todoFound) {
        throw new Error(`No TODO was found with the title: "${title}"`);
    }
}
...

Сохраните и закройте файл.

Теперь добавим новый тест для этой новой функции. Нам нужно убедиться, что в случае если мы вызываем команду complete объекту Todos, в котором нет элементов, будет возвращена ошибка.

Вернитесь в index.test.js​​:

  • nano index.test.js

В конце файла добавьте следующий код:

todos/index.test.js
...
describe("complete()", function() {
    it("should fail if there are no TODOs", function() {
        let todos = new Todos();
        const expectedError = new Error("You have no TODOs stored. Why don't you add one first?");

        assert.throws(() => {
            todos.complete("doesn't exist");
        }, expectedError);
    });
});

Снова используем describe() и it()​​. Этот тест начинается с создания нового объекта todos. Затем мы определяем ошибку, которую ожидаем получить при вызове функции complete().

Далее используем функцию throws() модуля assert. Эта функция была создана для проверки ошибок, которые выдаются в коде. Первый аргумент — это функция, содержащая код, который выдает ошибку. Второй аргумент — это ошибка, которую мы ожидаем получить.

Снова запустите тест с помощью npm test​​​ в своем терминале и вы увидите следующий вывод:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs 2 passing (25ms)

Этот вывод подтверждает преимущества автоматизированного тестирования с помощью Mocha и assert. Поскольку наши тесты выполняются скриптами, каждый раз, когда мы запускаем npm test, мы проверяем, что все тесты успешно пройдены. Нам не нужно вручную проверять, работает ли другой код. Мы знаем, что работает, так как наш тест успешно пройден.

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

Шаг 4 — Тестирование асинхронного кода

Одна из функций, описанных в нашем модуле TODO, — это функция экспорта CSV. Она выводит все элементы TODO, а также завершенный статус в файл. Для этого требуется использовать модуль fs — встроенный модуль Node.js для работы с файловой системой.

Запись в файл — это асинхронная операция. В Node.js есть много способов записи в файл. Можно использовать обратные вызовы, обещания или ключевые слова async/await. В этом разделе мы рассмотрим, как записывать тесты для разных методов.

Обратные вызовы

Функция callback — это функция, используемая как аргумент для асинхронной функции. Она вызывается при завершении асинхронной операции.

Добавим функцию в наш класс Todos с именем saveToFile(). Эта функция будет создавать строку, проходя циклом через все элементы TODO и записывая эту строку в файл.

Откройте файл index.js:

  • nano index.js

Добавьте в этот файл следующий выделенный код:

todos/index.js
const fs = require('fs');

class Todos {
    constructor() {
        this.todos = [];
    }

    list() {
        return [...this.todos];
    }

    add(title) {
        let todo = {
            title: title,
            completed: false,
        }
        this.todos.push(todo);
    }

    complete(title) {
        if (this.todos.length === 0) {
            throw new Error("You have no TODOs stored. Why don't you add one first?");
        }

        let todoFound = false
        this.todos.forEach((todo) => {
            if (todo.title === title) {
                todo.completed = true;
                todoFound = true;
                return;
            }
        });

        if (!todoFound) {
            throw new Error(`No TODO was found with the title: "${title}"`);
        }
    }

    saveToFile(callback) {
        let fileContents = 'Title,Completed\n';
        this.todos.forEach((todo) => {
            fileContents += `${todo.title},${todo.completed}\n`
        });

        fs.writeFile('todos.csv', fileContents, callback);
    }
}

module.exports = Todos;

Сначала необходимо импортировать модуль fs в наш файл. Затем добавляем новую функцию saveToFile()​​. Эта функция выполняет функцию обратного вызова, которая активируется сразу после завершения операции записи файла. В этой функции мы создаем переменную fileContents, содержащую всю строку, которую мы хотим сохранить в качестве файла. Она активируется с помощью заголовков CSV. Затем проходим циклом через каждый элемент TODO с помощью метода внутреннего массива forEach(). В процессе итерации добавляем свойства title и completed отдельных объектов todos.

Наконец, используем модуль fs для записи файла с помощью функции writeFile(). Первый аргумент — это имя файла: todos.csv. Второй — это содержимое файла, в этом случае fileContents — это переменная. Последний аргумент — это наша функция обратного вызова, которая обрабатывает любые ошибки записи файла.

Сохраните и закройте файл.

Теперь напишем тест для функции saveToFile(). Этот тест выполняет две функции: в первую очередь подтверждает наличие файла, а затем проверяет, имеет ли файл правильное содержимое.

Откройте файл index.test.js:

  • nano index.test.js

Начнем с загрузки модуля fs в верхней части файла, так как мы будем использовать его для тестирования результатов:

todos/index.test.js
const Todos = require('./index');
const assert = require('assert').strict;
const fs = require('fs');
...

Теперь в конце файла добавим новый тест:

todos/index.test.js
...
describe("saveToFile()", function() {
    it("should save a single TODO", function(done) {
        let todos = new Todos();
        todos.add("save a CSV");
        todos.saveToFile((err) => {
            assert.strictEqual(fs.existsSync('todos.csv'), true);
            let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
            let content = fs.readFileSync("todos.csv").toString();
            assert.strictEqual(content, expectedFileContents);
            done(err);
        });
    });
});

Как и ранее, используем команду describe() для группировки нашего теста отдельно от других, так как он подразумевает новую функцию. Функция it() несколько отличается от других функций. Обычно у используемой нами функции обратного вызова нет аргументов. В этот раз у нас есть done в качестве аргумента. Этот аргумент требуется при тестировании функций с обратными вызовами. Функция обратного вызова done() используется Mocha для информирования о завершении асинхронной функции.

Все функции обратного вызова, протестированные в Mocha, должны вызывать обратный вызов done(). Если нет, Mocha не будет знать, когда функция была завершена, и зависнет в ожидании сигнала.

Далее создаем экземпляр Todos и добавляем в него один элемент. Затем вызываем функцию saveToFile()​​​ с обратным вызовом, который фиксирует ошибку записи файла. Обратите внимание, как тест для этой функции располагается в обратном вызове. Если бы код теста был за пределами обратного вызова, тест бы не прошел, так как код вызывался до завершения записи файла.

В нашей функции обратного вызова мы сначала проверяем наличие нашего файла:

todos/index.test.js
...
assert.strictEqual(fs.existsSync('todos.csv'), true);
...

Функция fs.existsSync() возвращает true, если путь файла в аргументе существует, и false, если нет.

Примечание. Функции модуля fs — асинхронные по умолчанию. Однако для ключевых функций существуют синхронные копии. Этот тест упрощен с помощью синхронных функций, так как нам не нужно встраивать асинхронный код для проверки работы теста. В модуле fs синхронные функции, как правило, имеют Sync ​​​в конце имен.

Затем создаем переменную для хранения ожидаемого значения:

todos/index.test.js
...
let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
...

Используем readFileSync() модуля fs для синхронного чтения файла:

todos/index.test.js
...
let content = fs.readFileSync("todos.csv").toString();
...

Теперь предоставляем readFileSync() правильный путь для файла: todos.csv​​. Поскольку readFileSync() возвращает буферный объект Buffer, который хранит бинарные данные, мы используем метод toString() для сравнения его значения со строкой, которую мы предположительно сохранили.

Как и ранее, используем strictEqual модуля assert для выполнения сравнения:

todos/index.test.js
...
assert.strictEqual(content, expectedFileContents);
...

Заканчиваем тест вызовом обратного вызова done()​​​, чтобы убедиться, что Mocha знает, что нужно остановить тестирование:

todos/index.test.js
...
done(err);
...

Мы указываем объект err в done(), тогда Mocha не пройдет тест в случае возникновения ошибки.

Сохраните и закройте index.test.js.

Запускаем этот тест с помощью npm test, как и ранее. Вы увидите следующий вывод на консоли:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs saveToFile() ✓ should save a single TODO 3 passing (15ms)

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

Обещания

Обещание — это объект JavaScript, который в конечном счете возвращает значение. Когда обещание успешно, оно разрешено. Когда встречается ошибка, оно отклоняется.

Давайте изменим функцию saveToFile()​​ таким образом, чтобы она использовала обещания вместо обратных вызовов. Откройте index.js​​:

  • nano index.js

Сначала нам нужно изменить загрузку модуля fs. В вашем файле index.js измените выражение require() в верхней части файла, чтобы это выглядело следующим образом:

todos/index.js
...
const fs = require('fs').promises;
...

Мы только что импортировали модуль fs, который использует обещания, а не обратные вызовы. Теперь нам нужно внести некоторые изменения в команду saveToFile(), чтобы она работала с обещаниями.

В вашем текстовом редакторе внесите в функцию saveToFile() следующие изменения для удаления обратных вызовов:

todos/index.js
...
saveToFile() {
    let fileContents = 'Title,Completed\n';
    this.todos.forEach((todo) => {
        fileContents += `${todo.title},${todo.completed}\n`
    });

    return fs.writeFile('todos.csv', fileContents);
}
...

Первое отличие — это тот факт, что наша функция больше не принимает никакие аргументы. В случае с обещаниями нам не нужна функция обратного вызова. Второе отличие касается того, как написан файл. Теперь мы возвращаем результат обещания writeFile().

Сохраните и закройте index.js.

Теперь давайте изменим наш тест так, чтобы он работал с обещаниями. Откройте index.test.js​​:

  • nano index.test.js

Замените тест saveToFile()​​​ на следующее:

todos/index.js
...
describe("saveToFile()", function() {
    it("should save a single TODO", function() {
        let todos = new Todos();
        todos.add("save a CSV");
        return todos.saveToFile().then(() => {
            assert.strictEqual(fs.existsSync('todos.csv'), true);
            let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
            let content = fs.readFileSync("todos.csv").toString();
            assert.strictEqual(content, expectedFileContents);
        });
    });
});

Первое, что нужно изменить, — это удалить обратный вызов done() из аргументов. Если Mocha передает аргумент done(), его необходимо вызвать или он выдаст ошибку такого типа:

1) saveToFile()
       should save a single TODO:
     Error: Timeout of 2000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. (/home/ubuntu/todos/index.test.js)
      at listOnTimeout (internal/timers.js:536:17)
      at processTimers (internal/timers.js:480:7)

При тестировании обещаний не включайте обратный вызов done() в it().

Для проверки обещания нам нужно задать код утверждения в функцию then(). Обратите внимание, что мы возвращаем это обещание в тест, и у нас нет функции catch() для перехвата при отклонении обещания.

Мы возвращаем обещание, чтобы любые ошибки, выданные в функции then(), всплыли в функции it(). Если ошибки не всплывают, Mocha не провалит тест. При тестировании обещаний вам нужно использовать return для тестируемого обещания. Если нет, вы рискуете получить ложный позитивный результат.

Также мы пропускаем выражение catch(), так как Mocha может обнаружить, когда обещание отклоняется. При отклонении тест автоматически проваливается.

Теперь, когда у нас есть тест, сохраните и закройте файл, затем запустите Mocha с npm test для подтверждения, что мы получим успешный результат:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs saveToFile() ✓ should save a single TODO 3 passing (18ms)

Мы изменили наш код и тест для использования обещаний, и теперь мы точно знаем, что это работает. Но последние асинхронные модели используют ключевые слова async/await, поэтому нам не нужно создавать множественные функции then() для обработки успешных результатов. Давайте посмотрим, как работает тест с async/await.

async/await

Ключевые слова async/await делают работу с обещаниями менее многословной. Когда мы определяем функцию как асинхронную с ключевым словом async, мы можем получить любые дальнейшие результаты в этой функции с ключевым словом await. Так мы можем использовать обещания без необходимости использования функций then() или catch().

Можно упростить наш тест saveToFile(), который основан на обещании с async/await. В вашем текстовом редакторе создайте эти незначительные изменения к тесту saveToFile() в index.test.js:

todos/index.test.js
...
describe("saveToFile()", function() {
    it("should save a single TODO", async function() {
        let todos = new Todos();
        todos.add("save a CSV");
        await todos.saveToFile();

        assert.strictEqual(fs.existsSync('todos.csv'), true);
        let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
        let content = fs.readFileSync("todos.csv").toString();
        assert.strictEqual(content, expectedFileContents);
    });
});

Первое изменение — это тот факт, что функция, используемая it(), имеет ключевое слово async, когда она определена. Это позволяет использовать ключевое слово await в ее теле.

Второе изменение обнаруживается, когда мы вызываем saveToFile(). Ключевое слово await используется перед вызовом. Теперь Node.js знает, что нужно ждать, пока эта функция не решится перед продолжением теста.

Код функции проще читать, когда мы переместили код, который был в функции then() в тело функции it(). Запуск этого кода с помощью npm test дает следующий вывод:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs saveToFile() ✓ should save a single TODO 3 passing (30ms)

Теперь мы можем тестировать асинхронные функции, используя любые три асинхронные парадигмы надлежащим образом.

Мы охватили много вопросов о тестировании синхронного и асинхронного кода с Mocha. Далее переходим к другой функции, которую Mocha предлагает для улучшения нашего опыта тестирования, в частности в том, что касается использования хуков для изменения тестовых сред.

Шаг 5 — Использование хуков для улучшения тестовых случаев

Хуки — полезный элемент Mocha, который позволяет нам настроить среду до и после теста. Обычно мы добавляем хуки в блок функции describe(), так как они содержат логику установки и сноса, специфичную для некоторых тестовых случаев.

Mocha предоставляет четыре типа хуков, которые используются в тестах:

  • before: этот хук запускается один раз перед началом первого теста.
  • beforeEach: этот хук запускается перед каждым тестовым случаем.
  • after: этот хук запускается один раз после завершения последнего тестового случая.
  • afterEach: этот хук запускается после каждого тестового случая.

Когда мы тестируем функцию или свойство несколько раз, хуки очень помогают, так как они позволяют нам отделять код установки теста (например создание объекта todos) от кода утверждения теста.

Для просмотра значения хуков добавим больше тестов в наш блок теста saveToFile().

Хотя мы подтвердили, что можем сохранить элементы списка TODO в файл, мы сохранили только один элемент. Более того, элемент не был помечен как завершенный. Добавим больше тестов, чтобы убедиться, что различные аспекты нашего модуля работают.

Сначала добавим второй тест для подтверждения того, что наш файл сохранен корректно, после завершения элемента списка TODO. Откройте файл index.test.js в своем текстовом редакторе:

  • nano index.test.js

Измените последний тест на следующее:

todos/index.test.js
...
describe("saveToFile()", function () {
    it("should save a single TODO", async function () {
        let todos = new Todos();
        todos.add("save a CSV");
        await todos.saveToFile();

        assert.strictEqual(fs.existsSync('todos.csv'), true);
        let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
        let content = fs.readFileSync("todos.csv").toString();
        assert.strictEqual(content, expectedFileContents);
    });

    it("should save a single TODO that's completed", async function () {
        let todos = new Todos();
        todos.add("save a CSV");
        todos.complete("save a CSV");
        await todos.saveToFile();

        assert.strictEqual(fs.existsSync('todos.csv'), true);
        let expectedFileContents = "Title,Completed\nsave a CSV,true\n";
        let content = fs.readFileSync("todos.csv").toString();
        assert.strictEqual(content, expectedFileContents);
    });
});

Тест аналогичен тому, что мы делали ранее. Основное отличие в том, что мы вызываем функцию complete() перед вызовом saveToFile() и что ожидаемые элементы файла expectedFileContents​​​ теперь имеют значение true вместо false для столбца completed.

Сохраните и закройте файл.

Запустим наш новый тест, а также все остальные с помощью npm test​​​:

  • npm test

В результате вы получите следующий вывод:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs saveToFile() ✓ should save a single TODO ✓ should save a single TODO that's completed 4 passing (26ms)

Он работает, как и ожидалось. Но возможности для улучшения все еще есть. Они оба должны инстанцировать объект Todos в начале теста. По мере того как мы добавляем больше тестовых случаев, это быстро становится повторяющимся и отнимает память. Также каждый раз, когда мы запускаем тест, создается файл. Кто-то менее знакомый с модулем может ошибочно принять это за реальный вывод. Было бы неплохо очистить наши файлы вывода после тестирования.

Сделаем эти улучшения с помощью тестовых хуков. Мы используем хук beforeEach() для настройки тестовой конфигурации элементов TODO. Тестовая конфигурация — это любое последовательное состояние, используемое в тесте. В нашем случае тестовая конфигурация — это новый объект todos, в который уже добавлен один элемент TODO. Затем мы используем afterEach() для удаления файла, созданного тестом.

В index.test.js внесите следующие изменения в ваш последний тест для saveToFile():

todos/index.test.js
...
describe("saveToFile()", function () {
    beforeEach(function () {
        this.todos = new Todos();
        this.todos.add("save a CSV");
    });

    afterEach(function () {
        if (fs.existsSync("todos.csv")) {
            fs.unlinkSync("todos.csv");
        }
    });

    it("should save a single TODO without error", async function () {
        await this.todos.saveToFile();

        assert.strictEqual(fs.existsSync("todos.csv"), true);
        let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
        let content = fs.readFileSync("todos.csv").toString();
        assert.strictEqual(content, expectedFileContents);
    });

    it("should save a single TODO that's completed", async function () {
        this.todos.complete("save a CSV");
        await this.todos.saveToFile();

        assert.strictEqual(fs.existsSync('todos.csv'), true);
        let expectedFileContents = "Title,Completed\nsave a CSV,true\n";
        let content = fs.readFileSync("todos.csv").toString();
        assert.strictEqual(content, expectedFileContents);
    });
});

Давайте разберем все изменения, которые мы внесли. Мы добавили блок beforeEach() в тестовый блок:

todos/index.test.js
...
beforeEach(function () {
    this.todos = new Todos();
    this.todos.add("save a CSV");
});
...

Эти две строки кода создают новый объект Todos, доступный для каждого нашего теста. С Mocha объект this в beforeEach() относится к такому же объекту this в it(). this одинаков для каждого блока кода внутри блока describe(). Подробнее о this ищите в нашем обучающем руководстве Понимание методов This, Bind, Call и Apply в JavaScript​​​.

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

Затем мы очищаем наш файл CSV в функции afterEach():

todos/index.test.js
...
afterEach(function () {
    if (fs.existsSync("todos.csv")) {
        fs.unlinkSync("todos.csv");
    }
});
...

Если тест не прошел, то он не мог создать файл. Поэтому мы проверяем наличие файла перед использованием функции unlinkSync() для его удаления.

Остальные изменения переключают ссылку с объектов todos, созданных ранее в функции it()​​​, на this.todos, имеющиеся в контексте Mocha. Также мы удалили строки, которые ранее инстанциировали todos в отдельных тестовых случаях.

Теперь запустим этот файл, чтобы убедиться, что тест все еще работает. Введите в терминале npm test​​​, чтобы получить следующее:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs saveToFile() ✓ should save a single TODO without error ✓ should save a single TODO that's completed 4 passing (20ms)

Результаты те же, и к тому же мы немного сократили время установки для новых тестов для функции saveToFile() и нашли решение для остаточного файла CSV.

Заключение

В этом обучающем руководстве мы написали модуль Node.js для управления элементами TODO и протестировали код вручную с помощью Node.js REPL. Затем создали тестовый файл и использовали фреймворк Mocha для запуска автоматизированных тестов. С помощью модуля assert мы смогли проверить корректность работы кода. Также протестировали синхронные и асинхронные функции с Mocha. Наконец, создали хуки с Mocha, которые делают написание связанных тестовых случаев более читабельным и управляемым.

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

Если вы хотите узнать больше о тестовом фреймворке Mocha, ознакомьтесь с официальной документацией Mocha. Если вы хотите продолжить изучение Node.js, вы можете перейти к странице серии Написание кода на Node.js.

0 Comments

Creative Commons License