Эта статья является частью серии о написании программ CLI для Node.js и, в частности, о их тестировании путем написания тестов E2E / Integration, а не написания обычного модульного модульного теста. Если вы хотите перейти к окончательному коду реализации, отметьте здесь. Ссылки на другие части перечислены ниже:

Если вы какое-то время работали над тестированием интерфейса командной строки или следили за этой серией статей, вы должны знать об уникальном рассоле, в котором мы находимся: как тестировать процесс из другого процесса? Мы предоставили несколько решений для работы с вводом / выводом и успешно загрузили данные в процесс, чтобы обеспечить и протестировать ожидаемые результаты. Однако в реальном сценарии интерфейсы командной строки предназначены для выполнения процессов, большинство из которых являются асинхронными, такими как выборка данных с сервера, хранение данных, отправка операций обновления (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, инструмента, который был источником вдохновения для эта серия. На этот раз я попробую опубликовать его без рецензента, в основном потому, что для написания этой записи потребовалось достаточно времени, и я знаю, что некоторые из вас очень долго ждали ее (Извините! 😅). Не стесняйтесь комментировать, если считаете, что есть место для улучшения, в том числе с точки зрения редакции. Спасибо за прочтение!