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

Большинство инструментов CLI состоят из некоторого командного ввода и ожидаемого вывода, и это нормально для большинства из них. Однако по мере того, как все больше разработчиков используют интерфейс командной строки, они ожидают, что их интерфейсы будут предоставлять более удобные средства связи, не покидая комфортного окна терминала. Возможность выбирать варианты из списка, отвечать на вопросы и предоставлять информацию в контексте - огромная победа при открытии этого инструмента. Когда мы переключились на эту новую - или, я бы сказал, ужасно старую, но снова модную - форму пользовательского интерфейса, мы обнаружили, что некоторые из принципов дизайна, которые мы применяем к продуктам, с которыми мы взаимодействуем ежедневно, таким как веб-приложения и мобильные приложения, такие принципы, как удобство использования и продуманная коммуникация, также являются обязательными для этих инструментов. Такие принципы, конечно, применимы к интерфейсам командной строки, но также и к другим неграфическим интерфейсам, таким как боты или помощники искусственного интеллекта, которые медленно захватывают мир (совершенно не в Скайнет), становясь частью нашей повседневной жизни. Это означает, что мы тратим время и силы на создание качественного продукта, что неизбежно приводит нас к автоматизированному тестированию.

В Части 1 мы исследовали, как, используя Node.js в качестве нашей платформы для создания инструментов CLI, мы можем достичь некоторой формы автоматизации тестирования E2E, которая выходит за рамки обычных (но не менее важных) модульных тестов. В этой записи мы добавим сложности, протестировав вводимые пользователем данные, чтобы предоставить согласованные пути пользовательского интерфейса и чистый UX, предоставляя нашим пользователям средства для более удобного взаимодействия с нашим инструментом. Я имею в виду, что терминал груб, как и для новичков, давайте сделаем так, чтобы они чувствовали себя немного менее запуганными, верно?

Но сначала ответь мне на это

Мы использовали пример командирской пиццы в предыдущем посте, чтобы обеспечить легкую точку входа в логику нашего приложения. Теперь мы воспользуемся не менее замечательным модулем inquirer для обработки пользовательского ввода. Этот модуль предоставляет несколько действительно красивых и чистых интерфейсов, абстрагируя некоторые из наиболее часто используемых форм ввода, которые пользователь может ожидать от других графических интерфейсов. Оказывается, у них тоже есть свой пример пиццы! Снова заявление об отказе от ответственности: мы предполагаем использование Node.js версии ≥ 8, которая включает поддержку async / await. Если вы все еще не знакомы с синтаксисом, ознакомьтесь с этой классной статьей об этом. Закажем пиццу!

inquirer теперь добавляет возможность запрашивать у пользователя ответ и не завершает процесс, пока пользователь не ответит или не принудительно завершит процесс (обычно Ctrl + C). Чтобы написать тест, который подходит для всего случая этой команды и отвечает на все вопросы, нам может потребоваться изменить нашу предыдущую функцию cmd.execute для поддержки возможности передачи входных данных. Тест будет выглядеть примерно так:

// Pizza CLI test: User Input Take 1
const expect = require('chai').expect;
const cmd = require('./cmd');
const { EOL } = require('os');
describe('The pizza CLI', () => {
  it('should print the correct output', async () => {
    const response = await cmd.execute(
      'path/to/process',
      [],
      [
        'y',
        '555-1234123',
        'Large',
        '1',
        'p',
        '2',
        'My Comment'
      ]
    );
    expect(
      response
        .trim()
        .split(EOL)
        .pop() // Get the last line
    ).to.match(/^Order receipt/); // Using chai-match plugin
  });
});

Второй массив предоставит имитацию пользовательского ввода. Но как имитировать входы? Оказывается, мы скармливаем их stdin нашему дочернему процессу. Поэтому мы изменим наш execute метод для поддержки этой функции:

const concat = require('concat-stream');
function executeWithInput(
  processPath,
  args = [],
  inputs = [],
  opts = {}
) {
  // Handle case if user decides not to pass input data
  // A.k.a. backwards compatibility
  if (!Array.isArray(inputs)) {
    opts = inputs;
    inputs = [];
  }

  const { env = null, timeout = 100 } = opts;
  const childProcess = createProcess(processPath, args, env);
  childProcess.stdin.setEncoding('utf-8');
  
  let currentInputTimeout;
  // Creates a loop to feed user inputs to the child process
  // in order to get results from the tool
  // This code is heavily inspired (if not blantantly copied)
  // from inquirer-test package
  const loop = inputs => {
    if (!inputs.length) {
      childProcess.stdin.end();
      return;
    }

    currentInputTimeout = setTimeout(() => {
      childProcess.stdin.write(inputs[0]);
      loop(inputs.slice(1));
    }, timeout);
  };
  const promise = new Promise((resolve, reject) => {
    childProcess.stderr.once('data', err => {
      // If childProcess errors out, stop all
      // the pending inputs if any
      childProcess.stdin.end();

      if (currentInputTimeout) {
        clearTimeout(currentInputTimeout);
        inputs = [];
      }

      reject(err.toString());
    });
    childProcess.on('error', reject);
    // Kick off the process
    loop(inputs);
    childProcess.stdout.pipe(
      concat(result => {
        resolve(result.toString());
      })
    );
  });
  return promise;
}
module.exports = { execute: executeWithInput };

Ookay. Так что здесь происходит?

Накормите меня!

С добавлением inquirer мы добавили модуль, который, как я сказал выше, не завершит процесс, пока пользователь не ответит или не принудительно завершит работу. Это означает, что когда мы запускаем команду execute, она зависает до тех пор, пока не будет выполнено одно из этих условий. Мы пользуемся этим условием и будем кормить входные данные порциями, используя childProcess.stdin.write(). Нам нужно будет дать CLI некоторое пространство для ответа - поэтому мы поместили timeout между каждым вводом - и вот вам нокаут: на этот раз будет по-другому, в зависимости от нескольких условий среды, в которой вы запускаете тесты, например, машины ресурсы (процессор, оперативная память), другие процессы, выполняющиеся одновременно, и, конечно же, если используемая оболочка имеет плагины и другое программное обеспечение, работающее поверх нее. В любом случае, похоже, он ведет себя как настоящая вещь. Но не совсем: если мы попробуем запустить этот код как есть, он зависнет после первого ввода. Почему?

Есть шаги, которые настолько очевидны, что мы часто забываем о них. Обычно, когда вы вводите команду в интерфейсе командной строки, вы думаете:

$ mycommand -> answer questions -> done

Но на самом деле вы делаете следующее:

$ mycommand ENTER
$ answer question 1 ENTER
$ answer question ..n ENTER
$ done

Нам тоже нужно скармливать инструменту эти команды, потому что, в конце концов, это тоже пользовательский ввод, верно? И это включает клавиши со стрелками ВВЕРХ / ВНИЗ, ПРОБЕЛ, ВВОД и любые другие клавиши, задействованные и поддерживаемые inquirer в их интерфейсе. Итак, снова посмотрев на наш тестовый пример, отправляемые нами входные данные должны выглядеть примерно так:

// Pizza CLI test: User Input Take 2
const expect = require('chai').expect;
const cmd, { ENTER } = require('./cmd');
const { EOL } = require('os');
describe('The pizza CLI', () => {
  it('should print the correct output', async () => {
    const response = await cmd.execute(
      'path/to/process',
      [],
      [
        'y',
        ENTER,
        '555-1234123',
        ENTER,
        'Large',
        ENTER,
        '1',
        ENTER,
        'p',
        ENTER,
        '2',
        ENTER,
        'My Comment'
        ENTER,
      ]
    );
expect(
      response
        .trim()
        .split(EOL)
        .pop() // Get the last line
    ).to.match(/^Order receipt/); // Using chai-match plugin
  });
});

Если вы работаете в системах на основе Unix, вам повезло: оказывается, что каждый ключ имеет представление в Юникоде, которое при передаче в stdin будет вести себя так, как если бы пользователь нажал клавишу и взаимодействовал с CLI (unicode персонажи здесь, если вам интересно). Однако, если вы используете Windows, вы не сможете пройти эти тесты. Причина в том, что Windows CMD и PowerShell не имеют строкового представления ключей, о которых мы только что упомянули, и, не имея строкового представления, они не могут быть переданы дочернему процессу. Если вы читаете и знаете, как это сделать в Windows, оставьте комментарий ниже. Я ищу способ заставить его работать!

Что у нас есть на данный момент

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

В части 3 мы поговорим о том, как передавать данные между тестовым процессом (родительским) и дочерним процессом в шаблоне, определяемом как Межпроцессное взаимодействие (IPC). Если вам действительно интересно узнать о финальной реализации, проверьте суть здесь. Я знаю, что мне потребовалось время, чтобы собрать этот пост. В основном я пишу код, поэтому написать сообщение в блоге - довольно сложная задача. Еще раз большое спасибо Дэну за его обзор. Увидимся в следующий раз!