Repository: vsiakka/gherkin-lint
Pull request #86 (2017-10-04 19:46:49)

Reduce boilerplate in unit tests
I've done one or two PRs in this repo and felt that the unit tests had a bit of boilerplate.  Seeing #7 as well, I hoped you wouldn't mind a PR to reduce it a bit.

I have:
* Pulled the error messages inside the test for most tests.
* De-duplicated code which appears in multiple tests for rules into a rule-test-base file.
* Split the config parser into a parser and a verifier, making it less effort to unit test the verifier (we do lose a small amount of coverage in the parser component by doing this, but would be happy to add in a new test).

There are no functional changes here, and only minimal changes to the tests themselves, it's mostly moving stuff around.



Patch:
diff --git a/src/config-parser.js b/src/config-parser.js
index be8f74a..351c07f 100644
--- a/src/config-parser.js
+++ b/src/config-parser.js
@@ -1,11 +1,9 @@
 var fs = require('fs');
-var rules = require('./rules.js');
+var verifyConfig = require('./config-verifier.js');
 
 var defaultConfigFileName = '.gherkin-lintrc';
-var errors;
 
 function getConfiguration(configPath, additionalRulesDirs) {
-  errors = [];
   if (configPath) {
     if (!fs.existsSync(configPath)) {
       throw new Error('Could not find specified config file "' + configPath + '"');
@@ -19,7 +17,7 @@ function getConfiguration(configPath, additionalRulesDirs) {
   }
   var config = JSON.parse(fs.readFileSync(configPath));
 
-  verifyConfigurationFile(config, additionalRulesDirs);
+  var errors = verifyConfig(config, additionalRulesDirs);
 
   if (errors.length > 0) {
     console.error('\x1b[31m\x1b[1mError(s) in configuration file:\x1b[0m');  // eslint-disable-line no-console
@@ -32,59 +30,6 @@ function getConfiguration(configPath, additionalRulesDirs) {
   return config;
 }
 
-function verifyConfigurationFile(config, additionalRulesDirs) {
-  for (var rule in config) {
-    if (!rules.doesRuleExist(rule, additionalRulesDirs)) {
-      errors.push('Rule "' + rule + '" does not exist');
-    } else {
-      verifyRuleConfiguration(rule, config[rule]);
-    }
-  }
-}
-
-function verifyRuleConfiguration(rule, ruleConfig) {
-  var enablingSettings = ['on', 'off'];
-  var genericErrorMsg = 'Invalid rule configuration for "' + rule + '" - ';
-
-  if (Array.isArray(ruleConfig)) {
-    if (enablingSettings.indexOf(ruleConfig[0]) === -1) {
-      errors.push(genericErrorMsg + 'The first part of the config should be "on" or "off"');
-    }
-
-    if (ruleConfig.length != 2 ) {
-      errors.push(genericErrorMsg + ' The config should only have 2 parts.');
-    }
-
-    var ruleObj = rules.getRule(rule);
-    var isValidSubConfig;
-
-    if (typeof(ruleConfig[1]) === 'string') {
-      isValidSubConfig = function(availableConfigs, subConfig) {
-        return ruleObj.availableConfigs.indexOf(subConfig) > -1;
-      };
-      testSubconfig(genericErrorMsg, rule, ruleConfig[1], isValidSubConfig);
-    } else {
-      isValidSubConfig = function(availableConfigs, subConfig) {
-        return ruleObj.availableConfigs[subConfig] !== undefined;
-      };
-      for (var subConfig in ruleConfig[1]) {
-        testSubconfig(genericErrorMsg, rule, subConfig, isValidSubConfig);
-      }
-    }
-  } else {
-    if (enablingSettings.indexOf(ruleConfig) == -1) {
-      errors.push(genericErrorMsg + 'The the config should be "on" or "off"');
-    }
-  }
-}
-
-function testSubconfig(genericErrorMsg, rule, subConfig, isValidSubConfig) {
-  var ruleObj = rules.getRule(rule);
-  if (!isValidSubConfig(ruleObj.availableConfigs, subConfig)) {
-    errors.push(genericErrorMsg + ' The rule does not have the specified configuration option "' + subConfig + '"');
-  }
-}
-
 module.exports = {
   getConfiguration: getConfiguration,
   defaultConfigFileName: defaultConfigFileName
diff --git a/src/config-verifier.js b/src/config-verifier.js
new file mode 100644
index 0000000..7f4d052
--- /dev/null
+++ b/src/config-verifier.js
@@ -0,0 +1,58 @@
+var rules = require('./rules.js');
+
+function verifyConfigurationFile(config, additionalRulesDirs) {
+  var errors = [];
+  for (var rule in config) {
+    if (!rules.doesRuleExist(rule, additionalRulesDirs)) {
+      errors.push('Rule "' + rule + '" does not exist');
+    } else {
+      verifyRuleConfiguration(rule, config[rule], errors);
+    }
+  }
+  return errors;
+}
+
+function verifyRuleConfiguration(rule, ruleConfig, errors) {
+  var enablingSettings = ['on', 'off'];
+  var genericErrorMsg = 'Invalid rule configuration for "' + rule + '" - ';
+
+  if (Array.isArray(ruleConfig)) {
+    if (enablingSettings.indexOf(ruleConfig[0]) === -1) {
+      errors.push(genericErrorMsg + 'The first part of the config should be "on" or "off"');
+    }
+
+    if (ruleConfig.length != 2 ) {
+      errors.push(genericErrorMsg + ' The config should only have 2 parts.');
+    }
+
+    var ruleObj = rules.getRule(rule);
+    var isValidSubConfig;
+
+    if (typeof(ruleConfig[1]) === 'string') {
+      isValidSubConfig = function(availableConfigs, subConfig) {
+        return ruleObj.availableConfigs.indexOf(subConfig) > -1;
+      };
+      testSubconfig(genericErrorMsg, rule, ruleConfig[1], isValidSubConfig, errors);
+    } else {
+      isValidSubConfig = function(availableConfigs, subConfig) {
+        return ruleObj.availableConfigs[subConfig] !== undefined;
+      };
+      for (var subConfig in ruleConfig[1]) {
+        testSubconfig(genericErrorMsg, rule, subConfig, isValidSubConfig, errors);
+      }
+    }
+  } else {
+    if (enablingSettings.indexOf(ruleConfig) == -1) {
+      errors.push(genericErrorMsg + 'The the config should be "on" or "off"');
+    }
+  }
+}
+
+function testSubconfig(genericErrorMsg, rule, subConfig, isValidSubConfig, errors) {
+  var ruleObj = rules.getRule(rule);
+  if (!isValidSubConfig(ruleObj.availableConfigs, subConfig)) {
+    errors.push(genericErrorMsg + ' The rule does not have the specified configuration option "' + subConfig + '"');
+  }
+}
+
+module.exports = verifyConfigurationFile;


Test patch:
diff --git a/test/config-verifier.js b/test/config-verifier.js
new file mode 100644
index 0000000..cfdaa82
--- /dev/null
+++ b/test/config-verifier.js
@@ -0,0 +1,48 @@
+var assert = require('chai').assert;
+
+var verifyConfig = require('../src/config-verifier.js');
+
+describe('Config Verifier', function() {
+  describe('Verification is successful when', function() {
+    it('rules are set to "on" or "off"', function() {
+      assert.deepEqual(verifyConfig({
+        'no-files-without-scenarios' : 'on',
+        'no-unnamed-features': 'on',
+        'no-unnamed-scenarios': 'on',
+        'no-dupe-scenario-names': 'on',
+        'no-dupe-feature-names': 'on',
+        'no-partially-commented-tag-lines': 'on',
+        'indentation': 'on',
+        'no-trailing-spaces': 'on',
+        'no-multiple-empty-lines': 'off'
+      }), []);
+    });
+
+    it('a rule config is an array of size 2, with an "on/off" state and another config value', function() {
+      assert.deepEqual(verifyConfig({'new-line-at-eof': ['on', 'yes']}), []);
+    });
+
+    it('a rule config is an array of size 2, with an "on/off" state and a keyworded array', function() {
+      assert.deepEqual(verifyConfig({
+        'indentation': ['on', { 'Feature': 1, 'Background': 1, 'Scenario': 1, 'Step': 1, 'given': 1, 'and': 1}]
+      }), []);
+    });
+  });
+
+  describe('Verification fails when', function() {
+    it('a non existing rule', function() {
+      assert.deepEqual(verifyConfig({'fake-rule': 'on'}), ['Rule "fake-rule" does not exist']);
+    });
+
+    it('a non existing rule sub-config', function() {
+      assert.deepEqual(verifyConfig({
+        'indentation': ['on', { 'featur': 0}],
+        'new-line-at-eof': ['on', 'y']
+      }), [
+        'Invalid rule configuration for "indentation" -  The rule does not have the specified configuration option "featur"',
+        'Invalid rule configuration for "new-line-at-eof" -  The rule does not have the specified configuration option "y"'
+      ]);
+    });
+  });
+});
+
diff --git a/test/configuration-parser/test-data/config1 b/test/configuration-parser/test-data/config1
deleted file mode 100644
index 89261f4..0000000
--- a/test/configuration-parser/test-data/config1
+++ /dev/null
@@ -1,11 +0,0 @@
-{
-  "no-files-without-scenarios" : "on",
-  "no-unnamed-features": "on",
-  "no-unnamed-scenarios": "on",
-  "no-dupe-scenario-names": "on",
-  "no-dupe-feature-names": "on",
-  "no-partially-commented-tag-lines": "on",
-  "indentation": "on",
-  "no-trailing-spaces": "on",
-  "no-multiple-empty-lines": "off"
-}
diff --git a/test/configuration-parser/test-data/config2 b/test/configuration-parser/test-data/config2
deleted file mode 100644
index 39a2ef3..0000000
--- a/test/configuration-parser/test-data/config2
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "new-line-at-eof": ["on", "yes"]
-}
diff --git a/test/configuration-parser/test-data/config3 b/test/configuration-parser/test-data/config3
deleted file mode 100644
index a4cbd14..0000000
--- a/test/configuration-parser/test-data/config3
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "indentation": ["on", { "Feature": 1, "Background": 1, "Scenario": 1, "Step": 1, "given": 1, "and": 1}]
-}
diff --git a/test/configuration-parser/test-data/config4 b/test/configuration-parser/test-data/config4
deleted file mode 100644
index 1982c18..0000000
--- a/test/configuration-parser/test-data/config4
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "fake-rule": "on"
-}
diff --git a/test/configuration-parser/test-data/config5 b/test/configuration-parser/test-data/config5
deleted file mode 100644
index 775c2e7..0000000
--- a/test/configuration-parser/test-data/config5
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-  "indentation": ["on", { "featur": 0}],
-  "new-line-at-eof": ["on", "y"]
-}
diff --git a/test/configuration-parser/test-results/results.js b/test/configuration-parser/test-results/results.js
deleted file mode 100644
index 9b0f7a7..0000000
--- a/test/configuration-parser/test-results/results.js
+++ /dev/null
@@ -1,35 +0,0 @@
-module.exports =
-{
-  'config1': {
-    'no-files-without-scenarios': 'on',
-    'no-unnamed-features': 'on',
-    'no-unnamed-scenarios': 'on',
-    'no-dupe-scenario-names': 'on',
-    'no-dupe-feature-names': 'on',
-    'no-partially-commented-tag-lines': 'on',
-    'indentation': 'on',
-    'no-trailing-spaces': 'on',
-    'no-multiple-empty-lines': 'off'
-  },
-  'config2': {
-    'new-line-at-eof': [ 'on', 'yes' ]
-  },
-  'config3': {
-    'indentation': ['on', { 'Feature': 1, 'Background': 1, 'Scenario': 1, 'Step': 1, 'given': 1, 'and': 1}]
-  },
-  'config4': {
-    'consoleErrors': [
-      '\x1b[31m\x1b[1mError(s) in configuration file:\x1b[0m',
-      '\x1b[31m- Rule "fake-rule" does not exist\x1b[0m'
-    ],
-    'assertionMessage': 'Configuration error(s)'
-  },
-  'config5': {
-    'consoleErrors': [
-      '\x1b[31m\x1b[1mError(s) in configuration file:\x1b[0m',
-      '\x1b[31m- Invalid rule configuration for "indentation" -  The rule does not have the specified configuration option "featur"\x1b[0m',
-      '\x1b[31m- Invalid rule configuration for "new-line-at-eof" -  The rule does not have the specified configuration option "y"\x1b[0m'
-    ],
-    'assertionMessage': 'Configuration error(s)'
-  }
-};
diff --git a/test/configuration-parser/test.js b/test/configuration-parser/test.js
deleted file mode 100644
index 79b6244..0000000
--- a/test/configuration-parser/test.js
+++ /dev/null
@@ -1,83 +0,0 @@
-var assert = require('chai').assert;
-var expect = require('chai').expect;
-var configParser = require('../../src/config-parser.js');
-var expectedResults = require('./test-results/results.js');
-
-require('mocha-sinon')();
-
-describe('Configuration file', function() {
-  describe('parsing/verification is successful when', function() {
-    it('rules are set to "on" or "off"', function() {
-      var actual = configParser.getConfiguration('./test/configuration-parser/test-data/config1');
-      var expected = expectedResults.config1;
-      assert.deepEqual(actual, expected);
-    });
-
-    it('a rule config is an array of size 2, with an "on/off" state and another config value', function() {
-      var actual = configParser.getConfiguration('./test/configuration-parser/test-data/config2');
-      var expected  = expectedResults.config2;
-      assert.deepEqual(actual, expected);
-    });
-
-    it('a rule config is an array of size 2, with an "on/off" state and a keyworded array', function() {
-      var actual = configParser.getConfiguration('./test/configuration-parser/test-data/config3');
-      var expected  = expectedResults.config3;
-      assert.deepEqual(actual, expected);
-    });
-  });
-
-  describe('parsing/verification throws an error when the config contains', function() {
-    var stub;
-    beforeEach(function() {
-      stub = this.sinon.stub(console, 'error');
-    });
-
-    afterEach(function() {
-      stub.restore();
-    });
-
-    it('a non existing rule', function() {
-      var actualAssertion;
-      try {
-        configParser.getConfiguration('./test/configuration-parser/test-data/config4');
-      } catch (e) {
-        actualAssertion = e.message;
-      }
-
-      var expected = expectedResults.config4;
-
-      // verify the assertion message
-      var expectedAssertion  = expected.assertionMessage;
-      assert.equal(actualAssertion, expectedAssertion);
-
-      // verify the console logs
-      var consoleErrorArgs = console.error.args.map(function (args) { // eslint-disable-line no-console
-        return args[0];
-      });
-      expect(consoleErrorArgs).to.be.deep.equal(expected.consoleErrors);
-    });
-
-    it('a non existing rule sub-config', function() {
-      var actualAssertion;
-      try {
-        configParser.getConfiguration('./test/configuration-parser/test-data/config5');
-      } catch (e) {
-        actualAssertion = e.message;
-      }
-
-      var expected  = expectedResults.config5;
-
-      // verify the assertion message
-      var expectedAssertion  = expected.assertionMessage;
-      assert.equal(actualAssertion, expectedAssertion);
-
-      // verify the console logs
-      var consoleErrorArgs = console.error.args.map(function (args) { // eslint-disable-line no-console
-        return args[0];
-      });
-      expect(consoleErrorArgs).to.be.deep.equal(expected.consoleErrors);
-
-    });
-
-  });
-});
diff --git a/test/feature-finder/test.js b/test/feature-finder/feature-finder.js
similarity index 100%
rename from test/feature-finder/test.js
rename to test/feature-finder/feature-finder.js
diff --git a/test/rules/indentation/test-data/CorrectIndentationSpaces.feature b/test/rules/indentation/CorrectIndentationSpaces.feature
similarity index 100%
rename from test/rules/indentation/test-data/CorrectIndentationSpaces.feature
rename to test/rules/indentation/CorrectIndentationSpaces.feature
diff --git a/test/rules/indentation/test-data/CorrectIndentationTabs.feature b/test/rules/indentation/CorrectIndentationTabs.feature
similarity index 100%
rename from test/rules/indentation/test-data/CorrectIndentationTabs.feature
rename to test/rules/indentation/CorrectIndentationTabs.feature
diff --git a/test/rules/indentation/test-data/WrongIndentationDifferentLanguage.feature b/test/rules/indentation/WrongIndentationDifferentLanguage.feature
similarity index 100%
rename from test/rules/indentation/test-data/WrongIndentationDifferentLanguage.feature
rename to test/rules/indentation/WrongIndentationDifferentLanguage.feature
diff --git a/test/rules/indentation/test-data/WrongIndentationSpaces.feature b/test/rules/indentation/WrongIndentationSpaces.feature
similarity index 100%
rename from test/rules/indentation/test-data/WrongIndentationSpaces.feature
rename to test/rules/indentation/WrongIndentationSpaces.feature
diff --git a/test/rules/indentation/test-data/WrongIndentationTabs.feature b/test/rules/indentation/WrongIndentationTabs.feature
similarity index 100%
rename from test/rules/indentation/test-data/WrongIndentationTabs.feature
rename to test/rules/indentation/WrongIndentationTabs.feature
diff --git a/test/rules/indentation/indentation.js b/test/rules/indentation/indentation.js
new file mode 100644
index 0000000..5321bf4
--- /dev/null
+++ b/test/rules/indentation/indentation.js
@@ -0,0 +1,72 @@
+var ruleTestBase = require('../rule-test-base');
+var rule = require('../../../src/rules/indentation.js');
+var runTest = ruleTestBase.createRuleTest(rule,
+  'Wrong indentation for "<%= element %>", expected indentation level of <%= expected %>, but got <%= actual %>');
+
+var wrongIndenatationErrors = [{
+  messageElements: {element: 'Feature', expected: 0, actual: 1},
+  line: 1
+},{
+  messageElements: {element: 'Background', expected: 0, actual: 4},
+  line: 3
+},{
+  messageElements: {element: 'Step', expected: 2, actual: 0},
+  line: 4
+},{
+  messageElements: {element: 'Scenario', expected: 0, actual: 1},
+  line: 6
+},{
+  messageElements: {element: 'Step', expected: 2, actual: 3},
+  line: 7
+},{
+  messageElements: {element: 'Scenario', expected: 0, actual: 3},
+  line: 9
+},{
+  messageElements: {element: 'Step', expected: 2, actual: 3},
+  line: 10
+}];
+
+describe('Indentation rule', function() {
+  it('doesn\'t raise errors when the default conifguration is used and there are no indentation violations (spaces)', function() {
+    runTest('indentation/CorrectIndentationSpaces.feature', {}, []);
+  });
+
+  it('doesn\'t raise errors when the default conifguration is used are and there no indentation violations (tabs)', function() {
+    runTest('indentation/CorrectIndentationTabs.feature', {}, []);
+  });
+
+  it('detects errors for features, backgrounds, scenarios, scenario outlines and steps (spaces)', function() {
+    runTest('indentation/WrongIndentationSpaces.feature', {}, wrongIndenatationErrors);
+  });
+
+  it('detects errors for features, backgrounds, scenarios, scenario outlines and steps (tabs)', function() {
+    runTest('indentation/WrongIndentationTabs.feature', {}, wrongIndenatationErrors);
+  });
+
+  it('detects errors for features, backgrounds, scenarios, scenario outlines and steps in other languages', function() {
+    runTest('indentation/WrongIndentationDifferentLanguage.feature', {}, [{
+      messageElements: {element: 'Feature', expected: 0, actual: 4},
+      line: 2
+    },{
+      messageElements: {element: 'Background', expected: 0, actual: 4},
+      line: 4
+    },{
+      messageElements: {element: 'Step', expected: 2, actual: 0},
+      line: 5
+    },{
+      messageElements: {element: 'Scenario', expected: 0, actual: 4},
+      line: 7
+    },{
+      messageElements: {element: 'Step', expected: 2, actual: 12},
+      line: 8
+    },{
+      messageElements: {element: 'Scenario', expected: 0, actual: 12},
+      line: 10
+    },{
+      messageElements: {element: 'Step', expected: 2, actual: 11},
+      line: 11
+    }]);
+  });
+
+  // TODO: add tests for partial configurations and fallbacks (eg rule for Step is used for Given, Then etc is rule for Given, Then, etc has not been specified)
+});
diff --git a/test/rules/indentation/test-results/results.js b/test/rules/indentation/test-results/results.js
deleted file mode 100644
index 89f781a..0000000
--- a/test/rules/indentation/test-results/results.js
+++ /dev/null
@@ -1,62 +0,0 @@
-module.exports = {
-  'wrongIndentationEnglish':[
-    { 'message': 'Wrong indentation for "Feature", expected indentation level of 0, but got 1',
-      'rule': 'indentation',
-      'line': 1
-    },
-    { 'message': 'Wrong indentation for "Background", expected indentation level of 0, but got 4',
-      'rule': 'indentation',
-      'line': 3
-    },
-    { 'message': 'Wrong indentation for "Step", expected indentation level of 2, but got 0',
-      'rule': 'indentation',
-      'line': 4
-    },
-    { 'message': 'Wrong indentation for "Scenario", expected indentation level of 0, but got 1',
-      'rule': 'indentation',
-      'line': 6
-    },
-    { 'message': 'Wrong indentation for "Step", expected indentation level of 2, but got 3',
-      'rule': 'indentation',
-      'line': 7
-    },
-    { 'message': 'Wrong indentation for "Scenario", expected indentation level of 0, but got 3',
-      'rule': 'indentation',
-      'line': 9
-    },
-    { 'message': 'Wrong indentation for "Step", expected indentation level of 2, but got 3',
-      'rule': 'indentation',
-      'line': 10
-    }
-  ],
-  'wrongIndentationGerman': [
-    { 'message': 'Wrong indentation for "Feature", expected indentation level of 0, but got 4',
-      'rule': 'indentation',
-      'line': 2
-    },
-    { 'message': 'Wrong indentation for "Background", expected indentation level of 0, but got 4',
-      'rule': 'indentation',
-      'line': 4
-    },
-    { 'message': 'Wrong indentation for "Step", expected indentation level of 2, but got 0',
-      'rule': 'indentation',
-      'line': 5
-    },
-    { 'message': 'Wrong indentation for "Scenario", expected indentation level of 0, but got 4',
-      'rule': 'indentation',
-      'line': 7
-    },
-    { 'message': 'Wrong indentation for "Step", expected indentation level of 2, but got 12',
-      'rule': 'indentation',
-      'line': 8
-    },
-    { 'message': 'Wrong indentation for "Scenario", expected indentation level of 0, but got 12',
-      'rule': 'indentation',
-      'line': 10
-    },
-    { 'message': 'Wrong indentation for "Step", expected indentation level of 2, but got 11',
-      'rule': 'indentation',
-      'line': 11
-    }
-  ]
-};
diff --git a/test/rules/indentation/test.js b/test/rules/indentation/test.js
deleted file mode 100644
index 2afafeb..0000000
--- a/test/rules/indentation/test.js
+++ /dev/null
@@ -1,56 +0,0 @@
-var assert = require('chai').assert;
-var expect = require('chai').expect;
-var indentation = require('../../../src/rules/indentation.js');
-var Gherkin = require('gherkin');
-var parser = new Gherkin.Parser();
-var fs = require('fs');
-
-var expectedResults = require('./test-results/results.js');
-
-require('mocha-sinon');
-
-function getErrors(fileName, configuration) {
-  var file = fs.readFileSync('test/rules/indentation/test-data/' + fileName, 'utf8');
-  var parsedFile = parser.parse(file).feature;
-  return indentation.run(parsedFile, undefined, configuration);
-}
-
-
-describe('Indentation rule', function() {
-  it('doesn\'t raise errors when the default conifguration is used and there are no indentation violations (spaces)', function() {
-    var configuration = ['on'];
-    var fileName = 'CorrectIndentationSpaces.feature';
-    var errors = getErrors(fileName, configuration);
-    expect(errors).to.be.empty;
-  });
-
-  it('doesn\'t raise errors when the default conifguration is used are and there no indentation violations (tabs)', function() {
-    var configuration = ['on'];
-    var fileName = 'CorrectIndentationTabs.feature';
-    var errors = getErrors(fileName, configuration);
-    expect(errors).to.be.empty;
-  });
-
-  it('detects errors for features, backgrounds, scenarios, scenario outlines and steps (spaces)', function() {
-    var configuration = ['on'];
-    var fileName = 'WrongIndentationSpaces.feature';
-    var errors = getErrors(fileName, configuration);
-    assert.sameDeepMembers(errors, expectedResults.wrongIndentationEnglish);
-  });
-
-  it('detects errors for features, backgrounds, scenarios, scenario outlines and steps (tabs)', function() {
-    var configuration = ['on'];
-    var fileName = 'WrongIndentationTabs.feature';
-    var errors = getErrors(fileName, configuration);
-    assert.sameDeepMembers(errors, expectedResults.wrongIndentationEnglish);
-  });
-
-  it('detects errors for features, backgrounds, scenarios, scenario outlines and steps in other languages', function() {
-    var configuration = ['on'];
-    var fileName = 'WrongIndentationDifferentLanguage.feature';
-    var errors = getErrors(fileName, configuration);
-    assert.sameDeepMembers(errors, expectedResults.wrongIndentationGerman);
-  });
-
-  // TODO: add tests for partial configurations and fallbacks (eg rule for Step is used for Given, Then etc is rule for Given, Then, etc has not been specified)
-});
diff --git a/test/rules/name-length/test-data/CorrectLength.feature b/test/rules/name-length/CorrectLength.feature
similarity index 100%
rename from test/rules/name-length/test-data/CorrectLength.feature
rename to test/rules/name-length/CorrectLength.feature
diff --git a/test/rules/name-length/test-data/WrongLength.feature b/test/rules/name-length/WrongLength.feature
similarity index 100%
rename from test/rules/name-length/test-data/WrongLength.feature
rename to test/rules/name-length/WrongLength.feature
diff --git a/test/rules/name-length/name-length.js b/test/rules/name-length/name-length.js
new file mode 100644
index 0000000..fe05ddd
--- /dev/null
+++ b/test/rules/name-length/name-length.js
@@ -0,0 +1,32 @@
+var ruleTestBase = require('../rule-test-base');
+var rule = require('../../../src/rules/name-length.js');
+var runTest = ruleTestBase.createRuleTest(rule,
+  '<%= element %> name is too long. Length of <%= length %> is longer than the maximum allowed: 70');
+
+describe('Name length rule', function() {
+  it('doesn\'t raise errors when the default configuration is used and there are no length violations', function() {
+    runTest('name-length/CorrectLength.feature', {}, []);
+  });
+
+  it('detects errors for features, scenarios, scenario outlines and steps', function() {
+    runTest('name-length/WrongLength.feature', {}, [{
+      messageElements: {element: 'Feature', length: 89},
+      line: 1
+    },{
+      messageElements: {element: 'Step', length: 94},
+      line: 4
+    },{
+      messageElements: {element: 'Scenario', length: 90},
+      line: 6
+    },{
+      messageElements: {element: 'Step', length: 101},
+      line: 7
+    },{
+      messageElements: {element: 'Scenario', length: 98},
+      line: 9
+    },{
+      messageElements: {element: 'Step', length: 101},
+      line: 10
+    }]);
+  });
+});
diff --git a/test/rules/name-length/test-results/results.js b/test/rules/name-length/test-results/results.js
deleted file mode 100644
index a0ec31b..0000000
--- a/test/rules/name-length/test-results/results.js
+++ /dev/null
@@ -1,28 +0,0 @@
-module.exports = {
-  'wrongLength':[ 
-    { message: 'Feature name is too long. Length of 89 is longer than the maximum allowed: 70',
-      rule: 'name-length',
-      line: 1 
-    },
-    { message: 'Step name is too long. Length of 94 is longer than the maximum allowed: 70',
-      rule: 'name-length',
-      line: 4 
-    },
-    { message: 'Scenario name is too long. Length of 90 is longer than the maximum allowed: 70',
-      rule: 'name-length',
-      line: 6 
-    },
-    { message: 'Step name is too long. Length of 101 is longer than the maximum allowed: 70',
-      rule: 'name-length',
-      line: 7 
-    },
-    { message: 'Scenario name is too long. Length of 98 is longer than the maximum allowed: 70',
-      rule: 'name-length',
-      line: 9 
-    },
-    { message: 'Step name is too long. Length of 101 is longer than the maximum allowed: 70',
-      rule: 'name-length',
-      line: 10 
-    } 
-  ]
-};
diff --git a/test/rules/name-length/test.js b/test/rules/name-length/test.js
deleted file mode 100644
index c5200a4..0000000
--- a/test/rules/name-length/test.js
+++ /dev/null
@@ -1,34 +0,0 @@
-var assert = require('chai').assert;
-var expect = require('chai').expect;
-var nameLength = require('../../../src/rules/name-length.js');
-var Gherkin = require('gherkin');
-var parser = new Gherkin.Parser();
-var fs = require('fs');
-
-var expectedResults = require('./test-results/results.js');
-
-require('mocha-sinon');
-
-function getErrors(fileName, configuration) {
-  var file = fs.readFileSync('test/rules/name-length/test-data/' + fileName, 'utf8');
-  var parsedFile = parser.parse(file).feature;
-  return nameLength.run(parsedFile, undefined, configuration);
-}
-
-
-describe('Name length rule', function() {
-  it('doesn\'t raise errors when the default configuration is used and there are no length violations', function() {
-    var configuration = ['on'];
-    var fileName = 'CorrectLength.feature';
-    var errors = getErrors(fileName, configuration);
-    expect(errors).to.be.empty;
-  });
-
-  it('detects errors for features, scenarios, scenario outlines and steps', function() {
-    var configuration = ['on'];
-    var fileName = 'WrongLength.feature';
-    var errors = getErrors(fileName, configuration);
-    assert.sameDeepMembers(errors, expectedResults.wrongLength);
-  });
-
-});
diff --git a/test/rules/no-duplicate-tags/test-data/NoViolations.feature b/test/rules/no-duplicate-tags/NoViolations.feature
similarity index 100%
rename from test/rules/no-duplicate-tags/test-data/NoViolations.feature
rename to test/rules/no-duplicate-tags/NoViolations.feature
diff --git a/test/rules/no-duplicate-tags/test-data/Violations.feature b/test/rules/no-duplicate-tags/Violations.feature
similarity index 100%
rename from test/rules/no-duplicate-tags/test-data/Violations.feature
rename to test/rules/no-duplicate-tags/Violations.feature
diff --git a/test/rules/no-duplicate-tags/no-duplicate-tags.js b/test/rules/no-duplicate-tags/no-duplicate-tags.js
new file mode 100644
index 0000000..bd1f885
--- /dev/null
+++ b/test/rules/no-duplicate-tags/no-duplicate-tags.js
@@ -0,0 +1,24 @@
+var ruleTestBase = require('../rule-test-base');
+var rule = require('../../../src/rules/no-duplicate-tags.js');
+var runTest = ruleTestBase.createRuleTest(rule, 'Duplicate tags are not allowed: <%= tags %>');
+
+describe('No Duplicate Tags Rule', function() {
+  it('doesn\'t raise errors when there are no violations', function() {
+    runTest('no-duplicate-tags/NoViolations.feature', {}, []);
+  });
+
+  it('detects errors for features, scenarios, and scenario outlines', function() {
+    runTest('no-duplicate-tags/Violations.feature', {}, [{
+      messageElements: {tags: '@featuretag'},
+      line: 1
+    },
+    {
+      messageElements: {tags: '@scenariotag'},
+      line: 7
+    },
+    {
+      messageElements: {tags: '@scenariotag'},
+      line: 11
+    }]);
+  });
+});
diff --git a/test/rules/no-duplicate-tags/test-results/results.js b/test/rules/no-duplicate-tags/test-results/results.js
deleted file mode 100644
index 3ab792b..0000000
--- a/test/rules/no-duplicate-tags/test-results/results.js
+++ /dev/null
@@ -1,16 +0,0 @@
-module.exports = {
-  'wrongLength':[
-    { message: 'Duplicate tags are not allowed: @featuretag',
-      rule: 'no-duplicate-tags',
-      line: 1
-    },
-    { message: 'Duplicate tags are not allowed: @scenariotag',
-      rule: 'no-duplicate-tags',
-      line: 7
-    },
-    { message: 'Duplicate tags are not allowed: @scenariotag',
-      rule: 'no-duplicate-tags',
-      line: 11
-    }
-  ]
-};
diff --git a/test/rules/no-duplicate-tags/test.js b/test/rules/no-duplicate-tags/test.js
deleted file mode 100644
index 1f9dc81..0000000
--- a/test/rules/no-duplicate-tags/test.js
+++ /dev/null
@@ -1,33 +0,0 @@
-var assert = require('chai').assert;
-var expect = require('chai').expect;
-var rule = require('../../../src/rules/no-duplicate-tags.js');
-var Gherkin = require('gherkin');
-var parser = new Gherkin.Parser();
-var fs = require('fs');
-
-var expectedResults = require('./test-results/results.js');
-
-require('mocha-sinon');
-
-function getErrors(fileName, configuration) {
-  var file = fs.readFileSync('test/rules/no-duplicate-tags/test-data/' + fileName, 'utf8');
-  var parsedFile = parser.parse(file).feature;
-  return rule.run(parsedFile, undefined, configuration);
-}
-
-describe('No Duplicate Tags Rule', function() {
-  it('doesn\'t raise errors when there are no violations', function() {
-    var configuration = ['on'];
-    var fileName = 'NoViolations.feature';
-    var errors = getErrors(fileName, configuration);
-    expect(errors).to.be.empty;
-  });
-
-  it('detects errors for features, scenarios, and scenario outlines', function() {
-    var configuration = ['on'];
-    var fileName = 'Violations.feature';
-    var errors = getErrors(fileName, configuration);
-    assert.sameDeepMembers(errors, expectedResults.wrongLength);
-  });
-
-});
diff --git a/test/rules/no-homogenous-tags/test-data/NoViolations.feature b/test/rules/no-homogenous-tags/NoViolations.feature
similarity index 100%
rename from test/rules/no-homogenous-tags/test-data/NoViolations.feature
rename to test/rules/no-homogenous-tags/NoViolations.feature
diff --git a/test/rules/no-homogenous-tags/test-data/Violations.feature b/test/rules/no-homogenous-tags/Violations.feature
similarity index 100%
rename from test/rules/no-homogenous-tags/test-data/Violations.feature
rename to test/rules/no-homogenous-tags/Violations.feature
diff --git a/test/rules/no-homogenous-tags/no-homogenous-tags.js b/test/rules/no-homogenous-tags/no-homogenous-tags.js
new file mode 100644
index 0000000..c0e0a7f
--- /dev/null
+++ b/test/rules/no-homogenous-tags/no-homogenous-tags.js
@@ -0,0 +1,17 @@
+var ruleTestBase = require('../rule-test-base');
+var rule = require('../../../src/rules/no-homogenous-tags.js');
+var runTest = ruleTestBase.createRuleTest(rule,
+  'All Scenarios on this Feature have the same tag(s), they should be defined on the Feature instead: <%= tags %>');
+
+describe('No Homogenous Tags Rule', function() {
+  it('doesn\'t raise errors when there are no violations', function() {
+    runTest('no-homogenous-tags/NoViolations.feature', {}, []);
+  });
+
+  it('detects errors for scenarios, and scenario outlines', function() {
+    runTest('no-homogenous-tags/Violations.feature', {}, [ {
+      line: 1,
+      messageElements: {tags: '@tag1, @tag2'}
+    }]);
+  });
+});
diff --git a/test/rules/no-homogenous-tags/test-results/results.js b/test/rules/no-homogenous-tags/test-results/results.js
deleted file mode 100644
index 903947d..0000000
--- a/test/rules/no-homogenous-tags/test-results/results.js
+++ /dev/null
@@ -1,8 +0,0 @@
-module.exports = {
-  'noHomogenousTags':[
-    { line: 1,
-      message: 'All Scenarios on this Feature have the same tag(s), they should be defined on the Feature instead: @tag1, @tag2',
-      rule: 'no-homogenous-tags'
-    }
-  ]
-};
diff --git a/test/rules/no-homogenous-tags/test.js b/test/rules/no-homogenous-tags/test.js
deleted file mode 100644
index 04c9984..0000000
--- a/test/rules/no-homogenous-tags/test.js
+++ /dev/null
@@ -1,33 +0,0 @@
-var assert = require('chai').assert;
-var expect = require('chai').expect;
-var rule = require('../../../src/rules/no-homogenous-tags.js');
-var Gherkin = require('gherkin');
-var parser = new Gherkin.Parser();
-var fs = require('fs');
-
-var expectedResults = require('./test-results/results.js');
-
-require('mocha-sinon');
-
-function getErrors(fileName, configuration) {
-  var file = fs.readFileSync('test/rules/no-homogenous-tags/test-data/' + fileName, 'utf8');
-  var parsedFile = parser.parse(file).feature;
-  return rule.run(parsedFile, undefined, configuration);
-}
-
-describe('No Homogenous Tags Rule', function() {
-  it('doesn\'t raise errors when there are no violations', function() {
-    var configuration = ['on'];
-    var fileName = 'NoViolations.feature';
-    var errors = getErrors(fileName, configuration);
-    expect(errors).to.be.empty;
-  });
-
-  it('detects errors for scenarios, and scenario outlines', function() {
-    var configuration = ['on'];
-    var fileName = 'Violations.feature';
-    var errors = getErrors(fileName, configuration);
-    assert.sameDeepMembers(errors, expectedResults.noHomogenousTags);
-  });
-
-});
diff --git a/test/rules/no-superfluous-tags/test-data/NoViolations.feature b/test/rules/no-superfluous-tags/NoViolations.feature
similarity index 100%
rename from test/rules/no-superfluous-tags/test-data/NoViolations.feature
rename to test/rules/no-superfluous-tags/NoViolations.feature
diff --git a/test/rules/no-superfluous-tags/test-data/Violations.feature b/test/rules/no-superfluous-tags/Violations.feature
similarity index 100%
rename from test/rules/no-superfluous-tags/test-data/Violations.feature
rename to test/rules/no-superfluous-tags/Violations.feature
diff --git a/test/rules/no-superfluous-tags/no-superfluous-tags.js b/test/rules/no-superfluous-tags/no-superfluous-tags.js
new file mode 100644
index 0000000..182741f
--- /dev/null
+++ b/test/rules/no-superfluous-tags/no-superfluous-tags.js
@@ -0,0 +1,20 @@
+var ruleTestBase = require('../rule-test-base');
+var rule = require('../../../src/rules/no-superfluous-tags.js');
+var runTest = ruleTestBase.createRuleTest(rule,
+  'Tag(s) duplicated on a Feature and a Scenario in that Feature: <%= tags %>');
+
+describe('No Superfluous Tags Rule', function() {
+  it('doesn\'t raise errors when there are no violations', function() {
+    runTest('no-superfluous-tags/NoViolations.feature', {}, []);
+  });
+
+  it('detects errors for scenarios, and scenario outlines', function() {
+    runTest('no-superfluous-tags/Violations.feature', {}, [{
+      line: 7,
+      messageElements: {tags: '@superfluoustag1'}
+    }, {
+      line: 11,
+      messageElements: {tags: '@superfluoustag1, @superfluoustag2'}
+    }]);
+  });
+});
diff --git a/test/rules/no-superfluous-tags/test-results/results.js b/test/rules/no-superfluous-tags/test-results/results.js
deleted file mode 100644
index e73cdb1..0000000
--- a/test/rules/no-superfluous-tags/test-results/results.js
+++ /dev/null
@@ -1,12 +0,0 @@
-module.exports = {
-  'noSuperfluousTags':[
-    { line: 7,
-      message: 'Tag(s) duplicated on a Feature and a Scenario in that Feature: @superfluoustag1',
-      rule: 'no-superfluous-tags'
-    },
-    { line: 11,
-      message: 'Tag(s) duplicated on a Feature and a Scenario in that Feature: @superfluoustag1, @superfluoustag2',
-      rule: 'no-superfluous-tags'
-    }
-  ]
-};
diff --git a/test/rules/no-superfluous-tags/test.js b/test/rules/no-superfluous-tags/test.js
deleted file mode 100644
index ecea2b0..0000000
--- a/test/rules/no-superfluous-tags/test.js
+++ /dev/null
@@ -1,33 +0,0 @@
-var assert = require('chai').assert;
-var expect = require('chai').expect;
-var rule = require('../../../src/rules/no-superfluous-tags.js');
-var Gherkin = require('gherkin');
-var parser = new Gherkin.Parser();
-var fs = require('fs');
-
-var expectedResults = require('./test-results/results.js');
-
-require('mocha-sinon');
-
-function getErrors(fileName, configuration) {
-  var file = fs.readFileSync('test/rules/no-superfluous-tags/test-data/' + fileName, 'utf8');
-  var parsedFile = parser.parse(file).feature;
-  return rule.run(parsedFile, undefined, configuration);
-}
-
-describe('No Superfluous Tags Rule', function() {
-  it('doesn\'t raise errors when there are no violations', function() {
-    var configuration = ['on'];
-    var fileName = 'NoViolations.feature';
-    var errors = getErrors(fileName, configuration);
-    expect(errors).to.be.empty;
-  });
-
-  it('detects errors for scenarios, and scenario outlines', function() {
-    var configuration = ['on'];
-    var fileName = 'Violations.feature';
-    var errors = getErrors(fileName, configuration);
-    assert.sameDeepMembers(errors, expectedResults.noSuperfluousTags);
-  });
-
-});
diff --git a/test/rules/rule-test-base.js b/test/rules/rule-test-base.js
new file mode 100644
index 0000000..cbf589d
--- /dev/null
+++ b/test/rules/rule-test-base.js
@@ -0,0 +1,25 @@
+var assert = require('chai').assert;
+var Gherkin = require('gherkin');
+var fs = require('fs');
+var _ = require('lodash');
+require('mocha-sinon');
+
+var parser = new Gherkin.Parser();
+
+function createRuleTest(rule, messageTemplate) {
+  return function runTest(featureFile, configuration, expected) {
+    var expectedErrors = _.map(expected, function(error) {
+      return {
+        rule: rule.name,
+        message: _.template(messageTemplate)(error.messageElements),
+        line: error.line
+      };
+    });
+    var parsedFile = parser.parse(fs.readFileSync('test/rules/' + featureFile, 'utf8')).feature;
+    assert.sameDeepMembers(rule.run(parsedFile, undefined, configuration), expectedErrors);
+  };
+}
+
+module.exports = {
+  createRuleTest: createRuleTest
+};