Руководство Node.js по фактическому выполнению интеграционных тестов
Опубликовано: 2022-03-11Интеграционные тесты — это не то, чего следует бояться. Они являются неотъемлемой частью полного тестирования вашего приложения.
Говоря о тестировании, мы обычно думаем о модульных тестах, когда мы тестируем небольшой фрагмент кода изолированно. Однако ваше приложение больше, чем этот небольшой фрагмент кода, и почти никакая часть вашего приложения не работает изолированно. Именно здесь интеграционные тесты доказывают свою важность. Интеграционные тесты помогают там, где модульные тесты терпят неудачу, и устраняют разрыв между модульными тестами и сквозными тестами.
В этой статье вы узнаете, как писать читаемые и компонуемые интеграционные тесты с примерами в приложениях на основе API.
Хотя мы будем использовать JavaScript/Node.js для всех примеров кода в этой статье, большинство обсуждаемых идей можно легко адаптировать для интеграционных тестов на любой платформе.
Модульные тесты против интеграционных тестов: вам нужны оба
Модульные тесты фокусируются на одной конкретной единице кода. Часто это конкретный метод или функция более крупного компонента.
Эти тесты выполняются изолированно, когда все внешние зависимости обычно заглушаются или имитируются.
Другими словами, зависимости заменяются заранее запрограммированным поведением, гарантируя, что результат теста будет определяться только правильностью тестируемого модуля.
Вы можете узнать больше о модульных тестах здесь.
Модульные тесты используются для поддержания высокого качества кода с хорошим дизайном. Они также позволяют нам легко покрывать угловые случаи.
Недостатком, однако, является то, что модульные тесты не могут охватывать взаимодействие между компонентами. Вот где интеграционные тесты становятся полезными.
Интеграционные тесты
Если модульные тесты определяются изолированным тестированием мельчайших единиц кода, то интеграционные тесты — прямо противоположное.
Интеграционные тесты используются для проверки взаимодействия нескольких более крупных модулей (компонентов) и иногда даже могут охватывать несколько систем.
Целью интеграционных тестов является поиск ошибок в связях и зависимостях между различными компонентами, такими как:
- Передача недопустимых или неправильно упорядоченных аргументов
- Сломанная схема базы данных
- Неверная интеграция кэша
- Недостатки в бизнес-логике или ошибки в потоке данных (поскольку тестирование теперь проводится с более широкой точки зрения).
Если компоненты, которые мы тестируем, не имеют сложной логики (например, компоненты с минимальной цикломатической сложностью), интеграционные тесты будут гораздо важнее модульных тестов.
В этом случае модульные тесты будут использоваться в первую очередь для обеспечения хорошего дизайна кода.
В то время как модульные тесты помогают убедиться, что функции написаны правильно, интеграционные тесты помогают убедиться, что система работает правильно в целом. Таким образом, как модульные тесты, так и интеграционные тесты служат своей собственной взаимодополняющей цели, и оба необходимы для комплексного подхода к тестированию.
Модульные тесты и интеграционные тесты — это две стороны одной медали. Монета недействительна без обоих.
Таким образом, тестирование не будет завершено, пока вы не выполните как интеграционные, так и модульные тесты.
Настройте набор для интеграционных тестов
Хотя настроить набор тестов для модульных тестов довольно просто, настроить набор тестов для интеграционных тестов часто бывает сложнее.
Например, компоненты в интеграционных тестах могут иметь зависимости, находящиеся за пределами проекта, такие как базы данных, файловые системы, поставщики электронной почты, внешние платежные сервисы и т. д.
Иногда интеграционные тесты должны использовать эти внешние службы и компоненты, и иногда они могут быть заглушены.
Когда они необходимы, это может привести к нескольким проблемам.
- Ненадежное выполнение теста: внешние службы могут быть недоступны, возвращать недопустимый ответ или находиться в недопустимом состоянии. В некоторых случаях это может привести к ложноположительному результату, в других случаях к ложноотрицательному результату.
- Медленное выполнение: подготовка и подключение к внешним службам могут быть медленными. Обычно тесты запускаются на внешнем сервере в рамках CI.
- Комплексная настройка тестирования: внешние службы должны находиться в желаемом состоянии для тестирования. Например, в базу данных должны быть предварительно загружены необходимые тестовые данные и т. д.
Направления, которым нужно следовать при написании интеграционных тестов
Интеграционные тесты не имеют строгих правил, как модульные тесты. Несмотря на это, есть несколько общих указаний, которым нужно следовать при написании интеграционных тестов.
Повторяемые тесты
Порядок тестирования или зависимости не должны изменять результат теста. Запуск одного и того же теста несколько раз должен всегда возвращать один и тот же результат. Этого может быть трудно достичь, если тест использует Интернет для подключения к сторонним службам. Однако эту проблему можно обойти с помощью заглушки и насмешек.
Для внешних зависимостей, над которыми у вас больше контроля, настройка шагов до и после интеграционного теста поможет гарантировать, что тест всегда будет выполняться, начиная с идентичного состояния.
Тестирование соответствующих действий
Для проверки всех возможных случаев гораздо лучше использовать модульные тесты.
Интеграционные тесты в большей степени ориентированы на связь между модулями, поэтому тестирование удачных сценариев обычно является подходящим способом, поскольку оно охватывает важные связи между модулями.
Понятный тест и утверждение
Один быстрый просмотр теста должен сообщить читателю, что тестируется, как настроена среда, что заглушено, когда тест выполняется и что утверждается. Утверждения должны быть простыми и использовать помощников для лучшего сравнения и регистрации.
Простая настройка теста
Приведение теста в начальное состояние должно быть максимально простым и понятным.
Избегайте тестирования стороннего кода
Хотя в тестах могут использоваться сторонние сервисы, в их тестировании нет необходимости. И если вы не доверяете им, вам, вероятно, не следует их использовать.
Оставьте производственный код свободным от тестового кода
Рабочий код должен быть чистым и понятным. Смешивание тестового кода с рабочим кодом приведет к соединению двух несоединяемых доменов.
Соответствующее ведение журнала
Неудачные тесты не очень ценны без хорошей регистрации.
Когда тесты пройдены, дополнительная регистрация не требуется. Но когда они терпят неудачу, обширное ведение журнала жизненно важно.
Логирование должно содержать все запросы к базе данных, запросы и ответы API, а также полное сравнение того, что утверждается. Это может значительно облегчить отладку.
Хорошие тесты выглядят чистыми и понятными
Простой тест, который следует приведенным здесь рекомендациям, может выглядеть следующим образом:
const co = require('co'); const test = require('blue-tape'); const factory = require('factory'); const superTest = require('../utils/super_test'); const testEnvironment = require('../utils/test_environment_preparer'); const path = '/v1/admin/recipes'; test(`API GET ${path}`, co.wrap(function* (t) { yield testEnvironment.prepare(); const recipe1 = yield factory.create('recipe'); const recipe2 = yield factory.create('recipe'); const serverResponse = yield superTest.get(path); t.deepEqual(serverResponse.body, [recipe1, recipe2]); }));
Приведенный выше код тестирует API ( GET /v1/admin/recipes
), который ожидает, что он вернет массив сохраненных рецептов в качестве ответа.
Вы можете видеть, что тест, каким бы простым он ни был, опирается на множество утилит. Это характерно для любого хорошего набора интеграционных тестов.
Вспомогательные компоненты упрощают написание понятных интеграционных тестов.
Давайте рассмотрим, какие компоненты необходимы для интеграционного тестирования.
Вспомогательные компоненты
Комплексный набор для тестирования включает в себя несколько основных компонентов, в том числе: управление потоком, среду тестирования, обработчик базы данных и способ подключения к серверным API.
Управление потоком
Одной из самых больших проблем при тестировании JavaScript является асинхронный поток.
Обратные вызовы могут нанести ущерб коду, а промисов недостаточно. Вот где помощники потока становятся полезными.
В ожидании полной поддержки async/await можно использовать библиотеки с аналогичным поведением. Цель состоит в том, чтобы написать читаемый, выразительный и надежный код с возможностью асинхронного потока.
Co позволяет писать код красиво, сохраняя его неблокирующим. Это делается путем определения функции совместного генератора и последующего получения результатов.
Другое решение — использовать Bluebird. Bluebird — это библиотека обещаний, которая имеет очень полезные функции, такие как обработка массивов, ошибок, времени и т. д.
Сопрограммы Co и Bluebird ведут себя аналогично async/await в ES7 (ожидание разрешения перед продолжением), с той лишь разницей, что они всегда будут возвращать обещание, что полезно для обработки ошибок.
Платформа тестирования
Выбор тестовой среды зависит только от личных предпочтений. Я предпочитаю фреймворк, который прост в использовании, не имеет побочных эффектов и вывод которого легко читается и передается по конвейеру.

В JavaScript существует множество фреймворков для тестирования. В наших примерах мы используем ленту. Лента, на мой взгляд, не только соответствует этим требованиям, но и чище и проще других тестовых фреймворков вроде Mocha или Jasmin.
Лента основана на протоколе Test Anything Protocol (TAP).
TAP имеет вариации для большинства языков программирования.
Лента принимает тесты в качестве входных данных, запускает их, а затем выводит результаты в виде TAP. Затем результат TAP может быть передан генератору отчетов о тестировании или может быть выведен на консоль в необработанном формате. Лента запускается из командной строки.
Лента имеет некоторые полезные функции, такие как определение модуля для загрузки перед запуском всего набора тестов, предоставление небольшой и простой библиотеки утверждений и определение количества утверждений, которые должны вызываться в тесте. Использование модуля для предварительной загрузки может упростить подготовку тестовой среды и удалить весь ненужный код.
Заводская библиотека
Фабричная библиотека позволяет вам заменить файлы статических фикстур гораздо более гибким способом генерации данных для теста. Такая библиотека позволяет вам определять модели и создавать сущности для этих моделей без написания беспорядочного сложного кода.
Для этого в JavaScript есть factory_girl — библиотека, вдохновленная драгоценным камнем с похожим названием, который изначально был разработан для Ruby on Rails.
const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, { username: 'Bob', number_of_recipes: 50 }); const user = factory.build('user');
Для начала необходимо определить новую модель в factory_girl.
Он указывается с именем, моделью из вашего проекта и объектом, из которого создается новый экземпляр.
В качестве альтернативы вместо определения объекта, из которого генерируется новый экземпляр, можно предоставить функцию, которая будет возвращать объект или обещание.
При создании нового экземпляра модели мы можем:
- Переопределить любое значение во вновь сгенерированном экземпляре
- Передайте дополнительные значения в параметр функции сборки
Давайте посмотрим пример.
const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, (buildOptions) => { return { name: 'Mike', surname: 'Dow', email: buildOptions.email || '[email protected]' } }); const user1 = factory.build('user'); // {"name": "Mike", "surname": "Dow", "email": "[email protected]"} const user2 = factory.build('user', {name: 'John'}, {email: '[email protected]'}); // {"name": "John", "surname": "Dow", "email": "[email protected]"}
Подключение к API
Запуск полнофункционального HTTP-сервера и выполнение фактического HTTP-запроса только для того, чтобы отключить его через несколько секунд, особенно при проведении нескольких тестов, совершенно неэффективно и может привести к тому, что интеграционные тесты займут значительно больше времени, чем необходимо.
SuperTest — это библиотека JavaScript для вызова API без создания нового активного сервера. Он основан на SuperAgent, библиотеке для создания TCP-запросов. С этой библиотекой нет необходимости создавать новые TCP-соединения. API вызываются почти мгновенно.
SuperTest с поддержкой промисов — это супертестирование как обещанное. Когда такой запрос возвращает промис, он позволяет избежать нескольких вложенных функций обратного вызова, что значительно упрощает обработку потока.
const express = require('express') const request = require('supertest-as-promised'); const app = express(); request(app).get("/recipes").then(res => assert(....));
SuperTest был сделан для фреймворка Express.js, но с небольшими изменениями его можно использовать и с другими фреймворками.
Другие утилиты
В некоторых случаях необходимо имитировать некоторые зависимости в нашем коде, тестировать логику вокруг функций с помощью шпионов или использовать заглушки в определенных местах. Вот где могут пригодиться некоторые из этих служебных пакетов.
SinonJS — отличная библиотека, которая поддерживает шпионов, заглушки и макеты для тестов. Он также поддерживает другие полезные функции тестирования, такие как время изгиба, тестовая песочница и расширенное утверждение, а также поддельные серверы и запросы.
В некоторых случаях необходимо имитировать некоторые зависимости в нашем коде. Ссылки на сервисы, которые мы хотели бы имитировать, используются другими частями системы.
Чтобы решить эту проблему, мы можем использовать внедрение зависимостей или, если это невозможно, мы можем использовать службу имитации, такую как Mockery.
Насмешка помогает имитировать код, который имеет внешние зависимости. Чтобы использовать его правильно, Mockery следует вызывать перед загрузкой тестов или кода.
const mockery = require('mockery'); mockery.enable({ warnOnReplace: false, warnOnUnregistered: false }); const mockingStripe = require('lib/services/internal/stripe'); mockery.registerMock('lib/services/internal/stripe', mockingStripe);
С этой новой ссылкой (в этом примере mockingStripe
) будет проще создавать макеты сервисов позже в наших тестах.
const stubStripeTransfer = sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null));
С помощью библиотеки Sinon это легко имитировать. Единственная проблема здесь в том, что эта заглушка будет распространяться на другие тесты. Для песочницы можно использовать песочницу sinon. С его помощью более поздние тесты могут вернуть систему в исходное состояние.
const sandbox = require('sinon').sandbox.create(); const stubStripeTransfer = sandbox.sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null)); // after the test, or better when starting a new test sandbox.restore();
Для таких функций, как:
- Очистка базы данных (можно выполнить с помощью одного запроса на предварительную сборку иерархии)
- Установка его в рабочее состояние (sequelize-fixtures)
- Имитация TCP-запросов к сторонним службам (nock)
- Использование расширенных утверждений (chai)
- Сохраненные ответы от третьих лиц (легко исправить)
Не очень простые тесты
Абстракция и расширяемость — ключевые элементы построения эффективного набора интеграционных тестов. Все, что отвлекает внимание от ядра теста (подготовка его данных, действие и утверждение), должно быть сгруппировано и абстрагировано в служебные функции.
Хотя здесь нет правильного или неправильного пути, поскольку все зависит от проекта и его потребностей, некоторые ключевые качества по-прежнему являются общими для любого хорошего набора интеграционных тестов.
В следующем коде показано, как протестировать API, который создает рецепт и отправляет электронное письмо в качестве побочного эффекта.
Он заглушает внешний поставщик электронной почты, чтобы вы могли проверить, было ли отправлено электронное письмо, фактически не отправляя его. Тест также проверяет, ответил ли API соответствующим кодом состояния.
const co = require('co'); const factory = require('factory'); const superTest = require('../utils/super_test'); const basicEnv = require('../utils/basic_test_enivornment'); const path = '/v1/admin/recipes'; basicEnv.test(`API POST ${path}`, co.wrap(function* (t, assert, sandbox) { const chef = yield factory.create('chef'); const body = { chef_id: chef.id, recipe_name: 'cake', Ingredients: ['carrot', 'chocolate', 'biscuit'] }; const stub = sandbox.stub(mockery.emailProvider, 'sendNewEmail').returnsPromise(null); const serverResponse = yield superTest.get(path, body); assert.spies(stub).called(1); assert.statusCode(serverResponse, 201); }));
Приведенный выше тест можно повторять, поскольку каждый раз он начинается с чистой среды.
Он имеет простой процесс настройки, в котором все, что связано с настройкой, объединено внутри функции basicEnv.test
.
Он тестирует только одно действие — один API. И он четко формулирует ожидания теста с помощью простых утверждений. Кроме того, в тесте не используется сторонний код путем заглушки/насмешки.
Начать писать интеграционные тесты
При запуске нового кода разработчики (и все остальные участники проекта) хотят быть уверены, что новые функции будут работать, а старые не сломаются.
Этого очень трудно добиться без тестирования, и если все сделано плохо, это может привести к разочарованию, усталости от проекта и, в конечном итоге, к провалу проекта.
Интеграционные тесты в сочетании с модульными тестами являются первой линией защиты.
Использование только одного из двух недостаточно и оставит много места для непокрытых ошибок. Постоянное использование обоих сделает новые коммиты надежными, а также обеспечит уверенность и вызовет доверие у всех участников проекта.