Thin Grunt Manifesto
The Thin Grunt Manifesto is a reaction to the sprawling masses of Grunt task runner code found in many projects, lacking unit tests. Application business logic is written into Grunt task definitions, while Grunt instances find their way into core code, and the whole thing becomes an intractable mess. Following the Thin Grunt Manifesto corrects these issues, resulting in well-formed and easily tested code with separated concerns.
Imprimis: Do not reinvent a command line framework when you can use Grunt.
Grunt makes writing command line tasks easy, and the boilerplate code associated with the framework can be minimal. Use it rather than reinventing your own way of handling tasks.
/** * @fileOverview Gruntfile.js. */ module.exports = function (grunt) { // Always output stack traces. grunt.option('stack', true); // Load the local tasks. grunt.loadTasks('lib/tasks'); };
/** * @fileOverview Task definition in lib/tasks/exampleTask.js. */ var library = require('example-library'); module.exports = function (grunt) { grunt.registerTask( 'example-task', 'Perform a useful action.', function () { var callback = this.async(); // If the task is called as grunt example-task:x:y:z, then invoke this // as library.func('x', 'y', 'z', callback). library.asyncFunc.apply(library, this.args.concat([callback])); } ); };
Secundo: A Grunt task should invoke a library function, log, and exit, nothing more.
The purpose of a Grunt task is at most to deliver options to a library function, log the result to console, and end the process with an appropriate exit code. It should do nothing else, not even validate the options it passes, and needs to do nothing else. Business logic has no place inside a definition context. Putting it there just makes that functionality hard to test and refactor, while adding no benefit.
The simple tools offered by Grunt make passing options and logging errors very compact. Meanwhile, keeping business logic in libraries outside Grunt task definitions allows easy construction of programmatic APIs, maintained in parallel to the Grunt CLI.
/** * @fileOverview Task definition in lib/tasks/exampleTask.js. */ var library = require('example-library'); module.exports = function (grunt) { grunt.registerTask( 'example-task', 'Perform a useful action.', function () { var callback = this.async(); // If the task is called as grunt example-task:x:y:z, then invoke this // as library.func('x', 'y', 'z', callback). // // The library function validates its inputs and will call back with an // error if they are invalid. library.asyncFunc.apply(library, this.args.concat([callback])); } ); };
/** * @fileOverview Programmatic API to library functions. */ var library = require('example-library'); exports.asyncFunc = function (x, y, z, callback) { return library.asyncFunc(x, y, z, callback); });
Tertio: Gruntfile.js defines options and loads tasks, nothing more.
Do not pollute Gruntfile.js with business logic. Its purpose is to define immutable configuration options, load tasks, and perhaps create compound tasks. That is all.
/** * @fileOverview Gruntfile.js. */ module.exports = function (grunt) { grunt.initConfig({ 'another-example-task': { a: 'a', b: 'b', c: 'z' } }); grunt.loadTasks('lib/tasks'); grunt.registerTask('do-both', [ 'example-task', 'another-example-task' ]); };
/** * @fileOverview Task definition in lib/tasks/anotherExampleTask.js. */ var library = require('example-library'); module.exports = function (grunt) { grunt.registerTask( 'another-example-task', 'Perform another useful action.', function () { var callback = this.async(); // Pass in the options for this task defined in Gruntfile.js. library.anotherAsyncFunc(grunt.config(), callback); } ); };
Corollaries
Since Grunt offers many useful functions relating to file handling and other common tasks, developers can be tempted into passing the Grunt instance into deeper code, or loading files and performing other tasks inside Grunt task definitions. This is poor practice and leads to code that is very hard to unit test. Do not use these Grunt functions; there are many standalone NPM modules (such as fs-extra and the like) that provide comparable or better options. Use those modules in your library code.
Write unit tests for your library functions, but only write unit tests for your Grunt task definition code when you have run out of everything else to do. The task definitions should be so simple as to make it hardly worth the effort. Grunt task unit tests are painful to write and manage, and the best solution to that issue is to ensure that such tests are not all that needful.
The asynchronous callback model works well with the Thin Grunt Manifesto. In the case of a library with synchronous functions, however, it is possible to be tempted into adding result parsing to a Grunt task. Do not do this! Instead write a wrapper for the library to perform that parsing and call back with an error as appropriate.
Logging can be a thorny issue. Many tasks should provide incremental logging as they proceed if used via a CLI, but not when used via a programmatic API. There are a number of solutions that will not clutter Grunt task code. The library might accept settings to determine whether or not to log to console, for example, versus returning all messages as a part of the results after execution is complete. The use of packages such as Winston can allow a library to expose more complicated logging settings, and its users to specify all aspects of the logging transport.
In Summary
If a Grunt task file containing a single task definition is more than twenty lines long, including comments, then you are probably setting yourself up for later trouble. Practice development according to the Thin Grunt Manifesto and you will have a much easier time of it.