Статьи

Тестирование Grunt Plugin From Grunt

Написание тестов на плагин grunt оказалось менее простым, чем ожидалось. Мне нужно было запустить несколько конфигураций задач, и я хотел вызвать их все, набрав grunt test в главном каталоге.

Ворчание обычно выходит после первого сбоя задачи. Это делает невозможным хранение нескольких сценариев отказов внутри основного проекта gruntfile. Для их --force потребуется параметр --force , но grunt затем игнорирует все предупреждения, что не является оптимальным.

Более чистое решение состоит в том, чтобы иметь кучу gruntfiles в отдельном каталоге и вызывать их все из основного проекта gruntfile. Этот пост объясняет, как это сделать.

Демо-проект

Демо-проект — это небольшой плагин с одним заданием. Задача либо завершается неудачно с предупреждением, либо выводит сообщение об успешном завершении в консоль в зависимости от значения свойства параметров action .

Задание:

01
02
03
04
05
06
07
08
09
10
11
grunt.registerMultiTask('plugin_tester', 'Demo grunt task.', function() {
  //merge supplied options with default options
  var options = this.options({ action: 'pass', message: 'unknown error'});
 
  //pass or fail - depending on configured options
  if (options.action==='pass') {
    grunt.log.writeln('Plugin worked correctly passed.');
  } else {
    grunt.warn('Plugin failed: ' + options.message);
  }
});

Существует три способа написания модульных тестов плагинов grunt. Каждое решение имеет свой собственный файл nodeunit в test каталоге и объясняется в этом посте:

Все три демонстрационных теста состоят из трех разных конфигураций задач:

1
2
3
4
5
6
// Success scenario
options: { action: 'pass' }
// Fail with "complete failure" message
options: { action: 'fail', message: 'complete failure' }
//Fail with "partial failure" message
options: { action: 'fail', message: 'partial failure' }

Каждая конфигурация хранится в отдельном gruntfile в test директории. Например, сценарий успеха, хранящийся в gruntfile-pass.js выглядит следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
grunt.initConfig({
  // prove that npm plugin works too
  jshint: {
    all: [ 'gruntfile-pass.js' ]
  },
  // Configuration to be run (and then tested).
  plugin_tester: {
    pass: { options: { action: 'pass' } }
  }
});
 
// Load this plugin's task(s).
grunt.loadTasks('./../tasks');
// next line does not work - grunt requires locally installed plugins
grunt.loadNpmTasks('grunt-contrib-jshint');
 
grunt.registerTask('default', ['plugin_tester', 'jshint']);

Все три тестовых grunt-файла выглядят практически одинаково, меняется только объект options цели plugin_tester .

Запуск Gruntfile из подкаталога

Наши тестовые grunt-файлы хранятся в подкаталоге test и grunt плохо справляется с такой ситуацией. Эта глава объясняет, в чем проблема, и показывает два способа ее решения.

Проблема

Чтобы увидеть проблему, перейдите в каталог демонстрационного проекта и выполните следующую команду:

1
grunt --gruntfile test/gruntfile-problem.js

Grunt отвечает следующей ошибкой:

1
2
3
4
Local Npm module "grunt-contrib-jshint" not found. Is it installed?
Warning: Task "jshint" not found. Use --force to continue.
 
Aborted due to warnings.

объяснение

Grunt предполагает, что репозиторий grunfile и node_modules хранятся в одном каталоге. В то время как node.js require функция loadNpmTasks все родительские каталоги для требуемого модуля, loadNpmTasks не делает.

Эта проблема имеет два возможных решения, простое и причудливое:

  • создать локальный репозиторий npm в каталоге тестов ( просто ),
  • выполнять загрузочные задачи из родительских каталогов ( причудливо ).

Хотя первое «простое» решение несколько чище, в демонстрационном проекте используется второе «модное» решение.

Решение 1: дубликат репозитория Npm

Основная идея проста, просто создайте еще один локальный репозиторий npm внутри каталога tests:

  • Скопируйте файл package.json в каталог tests .
  • Добавьте в него только тестовые зависимости.
  • npm install команду npm install каждом запуске тестов.

Это более чистое решение. У него только два недостатка:

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

Решение 2. Загрузите задачи Grunt из родительского каталога

Другое решение — принудительно загружать задачи из репозитория npm, хранящегося в другом каталоге.

Grunt Plugin Загрузка

У Grunt есть два способа загрузки плагинов:

  • loadTasks('directory-name') — загружает все задачи внутри каталога,
  • loadNpmTasks('plugin-name') — загружает все задачи, определенные плагином.

Функция loadNpmTasks предполагает фиксированную структуру каталогов подключаемого модуля и репозитория модулей. Он угадывает имя каталога, в котором должны храниться задачи, а затем вызывает loadTasks('directory-name') .

Локальный репозиторий npm имеет отдельный подкаталог для каждого пакета npm. Предполагается, что все плагины grunt имеют подкаталог tasks а внутри .js файлы содержат задачи. Например, loadNpmTasks('grunt-contrib-jshint') загружает задачи из node_mudules/grunt-contrib-jshint/tasks и эквивалентен:

1
grunt.loadTasks('node_modules/grunt-contrib-jshint/tasks')

Поэтому, если мы хотим загрузить все задачи плагина grunt-contrib-jshint из родительского каталога, мы можем сделать следующее:

1
grunt.loadTasks('../node_modules/grunt-contrib-jshint/tasks')

Loop Родительские каталоги

Более гибкое решение — пролистывать все родительские каталоги, пока мы не найдем ближайший репозиторий node_modules или не достигнем корневого каталога. Это реализовано внутри модуля grunt-hacks.js .

Функция loadParentNpmTasks зацикливает родительские каталоги:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
module.exports = new function() {
 
  this.loadParentNpmTasks = function(grunt, pluginName) {
    var oldDirectory='', climb='', directory, content;
 
    // search for the right directory
    directory = climb+'node_modules/'+ pluginName;
    while (continueClimbing(grunt, oldDirectory, directory)) {
      climb += '../';
      oldDirectory = directory;
      directory = climb+'node_modules/'+ pluginName;
    }
 
    // load tasks or return an error
    if (grunt.file.exists(directory)) {
      grunt.loadTasks(directory+'/tasks');
    } else {
      grunt.fail.warn('Tasks plugin ' + pluginName + ' was not found.');
    }
  }
 
  function continueClimbing(grunt, oldDirectory, directory) {
    return !grunt.file.exists(directory) &&
      !grunt.file.arePathsEquivalent(oldDirectory, directory);
  }
 
}();

Модифицированный Gruntfile

Наконец, нам нужно заменить обычный grunt.loadNpmTasks('grunt-contrib-jshint') в файле gruntfile следующим образом:

1
2
var loader = require("./grunt-hacks.js");
loader.loadParentNpmTasks(grunt, 'grunt-contrib-jshint');

Укороченный gruntfile:

01
02
03
04
05
06
07
08
09
10
11
module.exports = function(grunt) {
  var loader = require("./grunt-hacks.js");
 
  grunt.initConfig({
    jshint: { /* ... */  },
    plugin_tester: { /* ... */ }
  });
 
  grunt.loadTasks('./../tasks');
  loader.loadParentNpmTasks(grunt, 'grunt-contrib-jshint');
};

Недостатки

Это решение имеет два недостатка:

  • Он не имеет дело с коллекционными плагинами.
  • Если grunt когда-либо изменит ожидаемую структуру плагинов grunt, вам придется изменить решение.

Если вам также нужны коллекционные плагины, посмотрите grunts task.js, чтобы узнать, как их поддерживать.

Вызов Gruntfile из Javascript

Второе, что нам нужно сделать, это вызвать gruntfile из javascript. Единственное осложнение состоит в том, что ворчание завершает весь процесс в случае сбоя задачи. Поэтому нам нужно вызвать его из дочернего процесса.

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

  • exec — выполняет команду в командной строке,
  • spawn — по-разному выполняет команду в командной строке,
  • fork — запускает модуль узла в дочернем процессе.

Первый, exec , наиболее прост в использовании и описан в первом подразделе. Во втором подразделе показано, как использовать fork и почему он менее оптимален, чем exec. Третий подраздел о спавне.

Exec

Exec запускает команду командной строки внутри дочернего процесса. Вы можете указать, в каком каталоге его запускать, настроить переменные среды, установить время ожидания, после которого команда будет уничтожена, и так далее. Когда команда завершает свой запуск, exec вызывает функцию обратного вызова и передает ей поток stdout, потоки stderr и ошибку, если команда потерпела крах.

Если не указано иное, команда выполняется в текущем каталоге. Мы хотим, чтобы он выполнялся внутри подкаталога tests , поэтому нам нужно указать свойство cwd объекта options: {cwd: 'tests/'} .

Содержимое потоков stdout и stderr хранится в буфере. Максимальный размер каждого буфера равен 204800, и если команда выдает больше выходных данных, вызов exec завершится сбоем. Этого количества достаточно для нашей маленькой задачи. Если вам нужно больше, вы должны установить maxBuffer параметров maxBuffer .

Call Exec

Следующий фрагмент кода показывает, как запустить gruntfile из exec. Функция асинхронна и после того, как все сделано, вызывает whenDoneCallback :

1
2
3
4
5
6
7
8
var cp = require("child_process");
 
function callGruntfile(filename, whenDoneCallback) {
  var command, options;
  command = "grunt --gruntfile "+filename+" --no-color";
  options = {cwd: 'test/'};
  cp.exec(command, options, whenDoneCallback);
}

Примечание: если вы установили npm в каталог тестов ( простое решение ), вам нужно использовать функцию callNpmInstallAndGruntfile вместо callGruntfile :

1
2
3
4
5
6
7
8
function callNpmInstallAndGruntfile(filename, whenDoneCallback) {
  var command, options;
  command = "npm install";
  options = {cwd: 'test/'};
  cp.exec(command, {}, function(error, stdout, stderr) {
    callGruntfile(filename, whenDoneCallback);
  });
}

Модульные тесты

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

Юнит-тест сценария успеха:

01
02
03
04
05
06
07
08
09
10
11
pass: function(test) {
  test.expect(3);
  callGruntfile('gruntfile-pass.js', function (error, stdout, stderr) {
    test.equal(error, null, "Command should not fail.");
    test.equal(stderr, '', "Standard error stream should be empty.");
 
    var stdoutOk = contains(stdout, 'Plugin worked correctly.');
    test.ok(stdoutOk, "Missing stdout message.");
    test.done();
  });
},

Модульное тестирование второго узла запускает сценарий «полный отказ», а затем проверяет, не завершился ли процесс, как ожидалось. Обратите внимание, что стандартный поток ошибок пуст и предупреждения выводятся на стандартный вывод.

Сбой сценария юнит-теста:

01
02
03
04
05
06
07
08
09
10
11
12
13
fail_1: function(test) {
  test.expect(3);
  var gFile = 'gruntfile-fail-complete.js';
  callGruntfile(gFile, function (error, stdout, stderr) {
    test.equal(error, null, "Command should have failed.");
    test.equal(error.message, 'Command failed: ', "Wrong error message.");
    test.equal(stderr, '', "Non empty stderr.");
 
    var stdoutOk = containsWarning(stdout, 'complete failure');
    test.ok(stdoutOk, "Missing stdout message.");
    test.done();
  });
}

Третий «частичный отказ» модульного теста узла почти такой же, как и предыдущий. Весь файл тестов доступен на github .

Недостатки

Недостаток:

  • Максимальный размер буфера должен быть установлен заранее.

вилка

Fork запускает модуль node.js внутри дочернего процесса и эквивалентен вызову node <module-name> в командной строке. Fork использует обратные вызовы для отправки стандартного вывода и стандартной ошибки вызывающей стороне. Оба обратных вызова могут вызываться много раз, и вызывающая сторона получает выходные данные дочернего процесса по частям.

Использование fork имеет смысл, только если вам нужно обрабатывать stdout и stderr произвольного размера или если вам нужно настроить функциональность grunt. Если вы этого не сделаете, exec проще в использовании.

Эта глава разделена на четыре подраздела:

Позвони Гранту

Грант не должен был называться программно. Он не раскрывает «публичный» API и не документирует его.

Наше решение имитирует действия grunt-cli, поэтому оно относительно безопасно в будущем. Grunt-cli распространяется отдельно от ядра grunt и поэтому с меньшей вероятностью изменится. Однако, если это действительно изменится, это решение должно будет также измениться.

Запуск grunt из javascript требует от нас:

  • отделить имя gruntfile от его пути,
  • изменить активный каталог,
  • вызов функции вызова tasks .

Позвоните ворчать из JavaScript:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
this.runGruntfile = function(filename) {
  var grunt = require('grunt'), path = require('path'), directory, filename;
   
  // split filename into directory and file
  directory = path.dirname(filename);
  filename = path.basename(filename);
 
  //change directory
  process.chdir(directory);
 
  //call grunt
  grunt.tasks(['default'], {gruntfile:filename, color:false}, function() {
    console.log('done');
  });
};

Модуль Аргументы

Модуль будет вызываться из командной строки. Узел хранит аргументы командной строки внутри
массив process.argv :

01
02
03
04
05
06
07
08
09
10
11
module.exports = new function() {
  var filename, directory;
 
  this.runGruntfile = function(filename) {
    /* ... */
  };
 
  //get first command line argument
  filename = process.argv[2];
  this.runGruntfile(filename);
}();

Call Fork

Форк имеет три аргумента: путь к модулю, массив с аргументами командной строки и объект параметров. Вызовите module.js с параметром tests/Gruntfile-1.js :

1
child = cp.fork('./module.js', ['tests/Gruntfile-1.js'], {silent: true})

Параметр silent: true делает доступными stdout и stderr возвращаемого child процесса внутри родительского процесса. Если установлено значение true, возвращаемый объект обеспечивает доступ к потокам stdout и stderr вызывающей стороны.

Вызов on('data', callback) в каждом потоке. Пропущенный обратный вызов будет вызываться каждый раз, когда дочерний процесс отправляет что-либо в поток:

1
2
3
4
5
6
child.stdout.on('data', function (data) {
  console.log('stdout: ' + data); // handle piece of stdout
});
child.stderr.on('data', function (data) {
  console.log('stderr: ' + data); // handle piece of stderr
});

Дочерний процесс может либо аварийно завершиться, либо завершить свою работу:

1
2
3
4
5
6
7
8
child.on('error', function(error){
  // handle child crash
  console.log('error: ' + error);
});
child.on('exit', function (code, signal) {
  // this is called after child process ended
  console.log('child process exited with code ' + code);
});

Демонстрационный проект использует следующую функцию для вызова fork и для привязки обратных вызовов:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * callbacks: onProcessError(error), onProcessExit(code, signal), onStdout(data), onStderr(data)
 */
function callGruntfile(filename, callbacks) {
  var comArg, options, child;
  callbacks = callbacks || {};
 
  child = cp.fork('./test/call-grunt.js', [filename], {silent: true});
 
  if (callbacks.onProcessError) {
    child.on("error", callbacks.onProcessError);
  }
  if (callbacks.onProcessExit) {
    child.on("exit", callbacks.onProcessExit);
  }
  if (callbacks.onStdout) {
    child.stdout.on('data', callbacks.onStdout);
  }
  if (callbacks.onStderr) {
    child.stderr.on('data', callbacks.onStderr);
  }
}

Написать тесты

Каждый модульный тест вызывает функцию callGruntfile . Обратные вызовы выполняют поиск ожидаемого содержимого в стандартном потоке вывода, проверяют, был ли правильный код завершения, сбой при обнаружении чего-либо в потоке ошибок или сбой, если вызов fork возвращает ошибку.

Юнит-тест сценария успеха:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pass: function(test) {
  var wasPassMessage = false, callbacks;
  test.expect(2);
  callbacks = {
    onProcessError: function(error) {
      test.ok(false, "Unexpected error: " + error);
      test.done();
    },
    onProcessExit: function(code, signal) {
      test.equal(code, 0, "Exit code should have been 0");
      test.ok(wasPassMessage, "Pass message was never sent ");
      test.done();
    },
    onStdout: function(data) {
      if (contains(data, 'Plugin worked correctly.')) {
        wasPassMessage = true;
      }
    },
    onStderr: function(data) {
      test.ok(false, "Stderr should have been empty: " + data);
    }
  };
  callGruntfile('test/gruntfile-pass.js', callbacks);
}

Тесты, соответствующие сценарию сбоя, практически одинаковы и могут быть найдены на github .

Недостатки

Недостатки:

  • Используемая функция grunt не относится к официальному API.
  • Выходные потоки дочерних процессов доступны порциями вместо одного большого блока.

Порождать

Spawn — это нечто среднее между форком и exec. Подобно exec, spawn может запускать исполняемый файл и передавать ему аргументы командной строки. Выходные потоки дочерних процессов обрабатываются так же, как в fork. Они отправляются родителям по частям через обратные вызовы. Поэтому, как и в случае с fork, использование spawn имеет смысл, только если вам нужен стандартный размер stdout или stderr.

Проблема

Основная проблема с порождением происходит на окнах. Имя команды для запуска должно быть точно указано. Если вы вызываете spawn с аргументом grunt , spawn ожидает исполняемое имя файла без суффикса. Реальный исполняемый исполняемый файл grunt.cmd не будет найден. В противном случае, spawn игнорирует переменную окружения Windows PATHEXT .

Циклы Суффиксы

Если вы хотите вызвать grunt from spawn , вам нужно будет выполнить одно из следующих действий:

  • использовать другой код для Windows и Linux или
  • читайте PATHEXT из окружения и просматривайте его, пока не найдете правильный суффикс.

Следующая функция проходит через PATHEXT и передает правильное имя файла PATHEXT вызову:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function findGruntFilename(callback) {
  var command = "grunt", options, extensionsStr, extensions, i, child, onErrorFnc, hasRightExtension = false;
 
  onErrorFnc = function(data) {
    if (data.message!=="spawn ENOENT"){
      grunt.warn("Unexpected error on spawn " +extensions[i]+ " error: " + data);
    }
  };
 
  function tryExtension(extension) {
    var child = cp.spawn(command + extension, ['--version']);
    child.on("error", onErrorFnc);
    child.on("exit", function(code, signal) {
      hasRightExtension = true;
      callback(command + extension);
    });
  }
 
  extensionsStr = process.env.PATHEXT || '';
  extensions = [''].concat(extensionsStr.split(';'));
  for (i=0; !hasRightExtension && i<extensions.length;i++) {
    tryExtension(extensions[i]);
  }
}

Написать тесты

Если у вас есть имя команды grunt, вы готовы вызвать spawn . Spawn запускает точно такие же события, что и fork, поэтому
callGruntfile принимает точно такой же объект обратных вызовов и привязывает его свойства к событиям дочернего процесса:

01
02
03
04
05
06
07
08
09
10
11
12
13
function callGruntfile(command, filename, callbacks) {
  var comArg, options, child;
  callbacks = callbacks || {};
 
  comArg = ["--gruntfile", filename, "--no-color"];
  options = {cwd: 'test/'};
  child = cp.spawn(command, comArg, options);
   
  if (callbacks.onProcessError) {
    child.on("error", callbacks.onProcessError);
  }
  /* ... callbacks binding exactly as in fork ...*/
}

Тесты также почти такие же, как в предыдущей главе. Единственное отличие состоит в том, что вы должны найти grunt исполняемое имя файла, прежде чем делать все остальное. Успешный сценарий теста выглядит так:

01
02
03
04
05
06
07
08
09
10
pass: function(test) {
  var wasPassMessage = false;
  test.expect(2);
  findGruntFilename(function(gruntCommand){
    var callbacks = {
      /* ... callbacks look exactly the same way as in fork ... */
    };
    callGruntfile(gruntCommand, 'gruntfile-pass.js', callbacks);
  });
}

Полный тест сценария успеха вместе с обоими тестами сценариев сбоя доступны на github .

Недостатки

Недостатки:

  • Spawn игнорирует суффиксы PATHEXT , необходим специальный код для его обработки.
  • Выходные потоки дочерних процессов доступны порциями вместо одного большого блока.

Вывод

Есть три способа проверить плагин grunt изнутри gruntfile. Если у вас нет очень веской причины не использовать exec .

Ссылка: Тестирование Grunt Plugin From Grunt от нашего партнера JCG Марии Юрковичовой в блоге This Is Stuff .