Skip to content

Instantly share code, notes, and snippets.

@smackesey
Created January 5, 2014 06:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save smackesey/8265099 to your computer and use it in GitHub Desktop.
Save smackesey/8265099 to your computer and use it in GitHub Desktop.
Code for automated generation of a basic test suite for a yeoman generator. generator-ember is used as an example.
// SPEC OF GENERATOR EMBER
// this object includes information about generator dependencies, expected
// files, and options. This data is needed for the dynamic generation of tests.
// This spec is only provided here for convenience; normally it would reside in
// a separate file from the logic used for test generation. All code after the spec
// constitutes a module whose functions should be run in the context of the spec.
var data = {
scriptsDir: 'app/scripts',
templatesDir: 'app/templates',
generators: [
'app',
'controller',
'view',
'model',
'router'
],
firstOrderDependencies: {
app: [],
controller: ['view', 'router'],
view: [],
model: ['controller'],
router: []
},
firstOrderExpectedFiles: {
app: {
misc: [
'.gitignore',
'.gitattributes',
'.bowerrc',
'bower.json',
'package.json',
'.jshintrc',
'.editorconfig',
'Gruntfile.js',
'app/index.html'
],
scripts: [
'app/scripts/app',
'app/scripts/store',
'app/scripts/routes/application_route',
],
templates: [
'app/templates/application',
'app/templates/index',
]
},
view: {
scripts: [
'views/user_view',
'views/user_edit_view',
'views/users_view',
],
templates: [
'user',
'user/edit',
'users'
]
},
model: {
scripts: [
'models/user_model.js'
]
},
controller: {
scripts: [
'controllers/users_controller',
'controllers/user_edit_controller',
'routes/user_route',
'routes/user_edit_route',
'routes/users_route'
]
},
router: {
scripts: [
'router'
]
}
},
possibleOptions: {
app: {
coffee: [false, true],
emberscript: [false, true],
emblem: [false, true],
compassBootstrap: [false, true],
'skip-install': [true]
},
view: {
coffee: [false, true],
emberscript: [false, true],
emblem: [false, true],
},
model: {
coffee: [false, true],
emberscript: [false, true],
emblem: [false, true],
},
controller: {
coffee: [false, true],
emberscript: [false, true],
emblem: [false, true],
},
router: {
coffee: [false, true],
emberscript: [false, true]
},
},
optionDependencies: {
coffee: {
npmModules: ['grunt-contrib-coffee'],
gruntTasks: ['coffee', 'coffee-test']
},
compass: {
npmModules: ['grunt-contrib-compass'],
gruntTasks: ['compass']
},
emblem: {
npmModules: ['emblem', 'grunt-emblem'],
gruntTasks: ['emblem']
},
emberscript: {
npmModules: ['grunt-ember-script'],
gruntTasks: ['emberscript']
}
},
promptOnlyOptions: ['compassBootstrap']
}
// CREATING EXPECTED FILES
//
// The current implementation of checking for expected files at generator-ember
// is not good. There are two issues:
//
// - file extensions change when one uses the coffeescript option as opposed to
// JS. This problem only gets larger when other JS and templating alternatives
// are supported.
// - some of the ember sub-generators have other generators as
// dependencies. Currently, these dependencies need to be specified in multiple
// places-- to bulid the expected file list for a generator with dependencies,
// you have to manually grab the expected files of those dependencies.
//
// The below solution can reads the generator spec to dynamically generate a
// list of expected files for any configuration.
exports.getDependencies = function (generator) {
var deps = [generator];
if (this.firstOrderDependencies[generator].length !== 0) {
var firstOrderOtherDeps = this.firstOrderDependencies[generator];
var allOtherDeps = _.chain(firstOrderOtherDeps)
.map(getDependencies.bind(this))
.reduce(function (allDeps, newDeps) {
return allDeps.concat(newDeps);
}, [])
.uniq()
.value();
deps = deps.concat(allOtherDeps);
}
return deps;
};
function resolvePath(filepath, prefix, extension) {
return path.join(prefix, filepath + '.' + extension);
}
function getScriptExtension(options) {
if (options.coffee) { return 'coffee'; } else {
if (options.emberscript) { return 'em'; } else {
return 'js';
}
}
}
function getTemplateExtension(options) {
if (options.emblem) { return 'emblem'; } else {
return 'js';
}
}
exports.getExpectedFilesBare = function (generator) {
var allGenerators = this.getDependencies(generator);
return _.reduce(allGenerators, function (fileTable, gen) {
var genFileTable = this.firstOrderExpectedFiles[gen];
for (var fileType in genFileTable) {
fileTable[fileType] = (fileTable[fileType] || []).concat(genFileTable[fileType]);
}
return fileTable;
}, {}, this);
};
exports.getExpectedFiles = function (generator, options) {
var fileTable = this.getExpectedFilesBare(generator);
fileTable.scripts = _.map(fileTable.scripts, function (file) {
return resolvePath(file, this.scriptsDir, getScriptExtension(options));
}, this);
fileTable.templates = _.map(fileTable.templates, function (file) {
return resolvePath(file, this.templatesDir, getTemplateExtension(options));
}, this);
return _.reduce(_.values(fileTable), function (old, curr) {
return old.concat(curr);
}, []);
};
// CHECKING OPTION CONFIGURATION AND DEPENDENCIES
// This is surely a need common to many generators. When the coffee option is
// set, grunt-contrib-coffee needs to be put in the package.json and a
// configuration for it set in the Gruntfile. The same holds for
// compassBootstrap etc. One problem with testing this is that there's not a
// very obvious place to put the tests-- you need to run the full generator to
// check, but most of the generator configuration is actually irrelevant to
// these tests.
//
// I implemented a solution to this problem along the lines of my solution for
// the expected files. The dependencies for each option are specified in the
// generator spec. Tests for them can then be automatically generated by the
// code below.
function checkNpmDependency(depName, negate) {
if (negate) {
return it.bind(this, 'does not add ' + depName + ' as a dependency', function () {
helpers.assertNoFileContent('package.json', new RegExp(depName));
});
} else {
return it.bind(this, 'adds ' + depName + ' as a dependency', function () {
helpers.assertFileContent('package.json', new RegExp(depName));
});
}
}
function checkGruntConfig(taskName, negate) {
var desc;
if (negate) {
desc = 'does not add grunt configuration for ' + taskName;
return it.bind(this, desc, function () {
helpers.assertNoFileContent('Gruntfile.js', new RegExp(taskName + ':'));
});
} else {
desc = 'add grunt configuration for ' + taskName;
return it.bind(this, desc, function () {
helpers.assertFileContent('Gruntfile.js', new RegExp(taskName + ':'));
});
}
}
exports.createOptionDependencyChecks = function () {
var optionTests = {};
_.pairs(this.optionDeps).forEach(function (pair) {
var key = pair[1], val = pair[2];
optionTests[key] = {true: [], false: []};
val.npmModules.forEach(function (mod) {
optionTests[key][true].push(checkNpmDependency(mod));
optionTests[key][false].push(checkNpmDependency(mod, true));
});
val.gruntTasks.forEach(function (task) {
optionTests[key][true].push(checkGruntConfig(task));
optionTests[key][false].push(checkGruntConfig(task, true));
});
});
return optionTests;
};
exports.getOptionTests = function (generator) {
if (generator !== 'app') {
return this.createOptionDependencyChecks();
} else {
return {};
}
};
/* GENERATING THE TEST SUITE
* ---
* The possible configurations of each generator are specified in
* the generator spec. This is an object having each key set to
* the name of a generator G. Each value is an object having keys that are the
* possible options for G, and values that are arrays of the possible values
* for that option. The set of all possible combinations of choices of option
* values is the set of possible configurations for G. The below example shows
* `possibleOptions` for just the `app` generator.
*
possibleOptions: {
app: {
coffee: [false, true],
emberscript: [false, true],
emblem: [false, true],
compassBootstrap: [false, true],
'skip-install': [true]
},
...
}
*
* Because testing every possible combination of options is likely to be slow,
* the strategy taken here is to assume that the options are independent of one
* another. Each value for each option is tested at least once, but not in each
* of the possible contexts given by settings of the other options.
*
* To implement this, a 'base' configuration is formed by taking the first
* value from each option. For the `app` generator above, the base
* configuration would be:
*
* {
* coffee: false, emberscript: false, emblem: false,
* compassBootstrap: false, 'skip-install': true
* }
*
* A set of configurations to test is created by iterating over all possible
* values of one option at a time, with the rest of the options set to their
* values in the base. Thus, for the `app` generator above, 5 configurations
* are tested: the base configuration, and then the base configuration modified
* with a `true` value for each of the `coffee`, `emberscript`, `emblem`, and
* `compassBootstrap` options.
*
* A group of tests is then associated with each configuration. All
* configurations are tested for generation of expected files. Tests of options
* are assigned to the first configuration that fits the option value being
* tested.
*/
function checkRequire(generator) {
return it.bind(this, 'can be required', function () {
require('../' + generator);
});
}
exports.getOptionSets = function (generator) {
var baseConfig = _.mapValues(this.possibleOptions[generator], function (val, key) {
return _.first(val);
});
var alts = _.mapValues(this.possibleOptions[generator], function (val, key) {
return _.drop(val, 1);
});
var configs = [baseConfig];
_.pairs(alts).forEach(function (pair) {
var key = pair[0], possibleValues = pair[1];
var altConfigs = _.map(possibleValues, function (val) {
var config = _.clone(baseConfig);
config[key] = val;
return config;
});
configs = configs.concat(altConfigs);
});
return configs;
};
exports.createGenerator = function (generator, options, args) {
var deps = _.map(this.getDependencies(generator), function (dep) { return '../../' + dep; });
var fullName = 'ember:' + generator;
var gen = helpers.createGenerator(fullName, deps, args, options);
for (var opt in options) {
if (this.promptOnlyOptions.indexOf(opt) === -1) {
gen.options[opt] = options[opt];
} else {
var promptOpts = {};
promptOpts[opt] = options[opt];
helpers.mockPrompt(gen, promptOpts);
}
}
return gen;
};
exports.createTestGroups = function (generator) {
var optionSets = this.getOptionSets(generator);
var optionTests = this.getOptionTests(generator);
var testGroups = _.map(optionSets, function (optSet) {
return { generator: generator, options: optSet, tests: [] };
});
for (var group in testGroups) {
group.tests.push(this.checkExpectedFiles(generator, group.options));
}
for (var option in _.keys(optionTests)) {
for (var value in optionTests[option]) {
group = testGroups.find(function (grp) {
return grp.options[option] === value;
});
group.tests = group.tests.concat(optionTests[option][value]);
}
}
return testGroups;
};
exports.testGenerator = function (generator) {
var testGroups = this.createTestGroups(generator);
describe(generator + " generator", function () {
checkRequire(generator)();
testGroups.forEach(function (group) {
before(function () {
var gen = this.createGenerator(generator, group.options);
gen.run();
});
group.tests.forEach(function (test) { test(); });
});
});
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment