Эта статья является частью серии о написании программ CLI для Node.js и, в частности, о их тестировании путем написания тестов E2E / Integration, а не написания обычного модульного модульного теста. Если вы хотите перейти к окончательному коду реализации, отметьте здесь. Ссылки на другие части перечислены ниже:
- Часть 1: Почему и как?
- Часть 2: Тестирование взаимодействия / Пользовательский ввод
- Часть 3. Обмен данными между процессами
- Часть 4: Mocking Services
Если вы какое-то время работали над тестированием интерфейса командной строки или следили за этой серией статей, вы должны знать об уникальном рассоле, в котором мы находимся: как тестировать процесс из другого процесса? Мы предоставили несколько решений для работы с вводом / выводом и успешно загрузили данные в процесс, чтобы обеспечить и протестировать ожидаемые результаты. Однако в реальном сценарии интерфейсы командной строки предназначены для выполнения процессов, большинство из которых являются асинхронными, такими как выборка данных с сервера, хранение данных, отправка операций обновления (CRUD) в конечную точку API, выполнение вычислений, загрузку код и многое другое. Некоторыми примерами этого являются такие CLI, как AWS-CLI, Heroku Toolbelt (теперь Heroku CLI), Git, Serverless, сам npm.
Разрабатывая проект CLI, над которым я работал, я довольно рано начал думать о стратегии тестирования. Таким образом, большинство тестов - и весь подход, о котором я писал в предыдущих статьях, - были написаны для одной конкретной функции CLI, которая не использовала никакую службу (она выполняла ввод-вывод, но через локальные файлы). Каково же было мое удивление, когда я собирался применить все извлеченные уроки к остальным функциям… а они не работали. Ну, они это сделали, но они проверяли связь с настоящими службами. Вероятно, не лучший подход для нашей настройки непрерывной интеграции. Или, может быть? Я все равно писал тесты E2E. Дело в том, что я не хотел пинговать наши настоящие сервисы.
Помня об этом, я начал изучать, как имитировать службы. Некоторые методы включали исправление модуля обезьяны и переменные среды, но эти методы не давали мне достаточной гибкости для независимой передачи пользовательских данных для каждого теста, по крайней мере, без особого беспорядка в исходной кодовой базе - что в любом случае не было идеальным . Итак, я начал искать способы связи с родительским процессом (средством выполнения тестов), чтобы он отправлял информацию дочернему процессу. Оказывается, Node.js позволяет вам делать именно это. В child_process.spawn
API есть опция Межпроцессное взаимодействие (IPC). Когда процесс Node.js запускается с этим параметром, в дочернем процессе создаются глобальные методы process.send
и process.on
, что позволяет использовать эффективный шаблон pub / sub.
Спроси своих родителей
Чтобы включить IPC, нам нужно добавить два небольших изменения в наш предыдущий код. Первый - в executeWithInput
методе:
const concat = require('concat-stream'); function executeWithInput( processPath, args = [], inputs = [], opts = {} ) { // ...omitted for brevity... const childProcess = createProcess(processPath, args, env); // ...more omissions... const promise = new Promise((resolve, reject) => { // ...almost there... childProcess.stdout.pipe( concat(result => { resolve(result.toString()); }) ); }); // Appending the process to the promise, in order to // add additional parameters or behavior // such as IPC communication promise.attachedProcess = childProcess; return promise; } module.exports = { execute: executeWithInput };
Второе изменение - в методе createProcess
:
const spawn = require('child_process').spawn; function createProcess(processPath, args = [], env = null) { args = [processPath].concat(args); return spawn('node', args, { env: Object.assign( { NODE_ENV: 'test' }, env ), // Enable IPC in child process. This syntax is explained in // Node.js documentation: https://bit.ly/2zAA6vq stdio: [null, null, null, 'ipc'] }); }
Только когда я так говорю
Я посвящу последнюю часть этой серии подробному объяснению стратегии имитации сервисов. Но для того, чтобы эта статья была сосредоточена на IPC, я опишу еще одну проблему, с которой я столкнулся, и то, как я решил, используя этот подход. Когда я создаю дочерний процесс в средстве выполнения тестов, дочерний процесс выполняет все, что должно, прямо тогда, когда вы его вызываете. Итак, теоретически это работает:
// my_process.js (async () => { const response = await callSomeAsyncService(); console.log('Response is:', response); })(); // test_runner.js const cmd = require('./cmd'); // From our last entries describe('Test my process', () => { it('should print the correct output', async () => { const response = await cmd.execute('my_process.js'); expect(response).to.equal( 'Response is: response from async service' ); }); });
Но что, если вы хотите высмеять callSomeAsyncService()
из набора тестов? Наивный подход выглядел бы так:
// my_process.js process.on('mock', data => { callSomeAsyncService = Promise.resolve(data); }); (async () => { const response = await callSomeAsyncService(); console.log('Response is:', response); })(); // test_runner.js const cmd = require('./cmd'); const mockResponse = 'response from mock service'; describe('Test my process', () => { it('should print the correct output', async () => { const promise = cmd.execute('my_process.js'); const childProcess = promise.attachedProcess; childProcess.send('mock', mockResponse); const response = await promise; // Spoiler alert: This will fail expect(response).to.equal( 'Response is: response from mock service' ); }); });
Проблема в том, что в тот момент, когда вы вызываете cmd.execute
, чтобы получить обещание, процесс уже начал выполняться. Это означает, что к тому времени, когда средство выполнения тестов выполнит childProcess.send
, процесс уже вызвал callSomeAsyncService
, что не дает нам возможности имитировать ответ. Как это решить?
Сообщая процессу, когда начинать
И снова на помощь приходит IPC. Если вы можете общаться с дочерним процессом, вы, конечно, можете указать ему, когда начинать. В этом случае может оказаться полезной переменная среды, чтобы избежать прерывания обычного потока процесса. Итак, небольшой рефакторинг до my_process
:
// my_process.js process.on('mock', data => { callSomeAsyncService = Promise.resolve(data); }); async function init() { const response = await callSomeAsyncService(); console.log('Response is:', response); }); process.on('start', init); // Execute the process immediately if not in test env if (process.env.NODE_ENV !== 'test') { init(); };
Изменить тестовый раннер довольно просто, держу пари, вы уже догадались:
// test_runner.js const cmd = require('./cmd'); const mockResponse = 'response from mock service'; describe('Test my process', () => { it('should print the correct output', async () => { const promise = cmd.execute('my_process.js'); const childProcess = promise.attachedProcess; childProcess.send('mock', mockResponse); // Once we know the mock is in place, // kick off the process childProcess.send('start'); const response = await promise; expect(response).to.equal( 'Response is: response from mock service' ); }); });
Вот и все для этой записи! Node.js IPC - действительно мощный шаблон, и это лишь одно из множества его применений. Я рекомендую эту отличную статью, которая объясняет и глубже погружается в IPC с другой - на мой взгляд, более традиционной - точки зрения.
В части 4 я попытаюсь изложить заключительные мысли о фиктивных сервисах и о том, как я использовал все собранные уроки для реализации решения для тестирования E2E, которое мы используем для проверки целостности проекта CLI, инструмента, который был источником вдохновения для эта серия. На этот раз я попробую опубликовать его без рецензента, в основном потому, что для написания этой записи потребовалось достаточно времени, и я знаю, что некоторые из вас очень долго ждали ее (Извините! 😅). Не стесняйтесь комментировать, если считаете, что есть место для улучшения, в том числе с точки зрения редакции. Спасибо за прочтение!