Repository: semantic-release/semantic-release (js)

Issue:
semantic-release throws on array of analyzeCommits plugins
## Current behavior

semantic-release throws an error if array of `analyzeCommits` plugins are present in config.

## Expected behavior

semantic-release supports multiple `analyzeCommits` or displays error message which clarifies on that only single plugin is supported.

## Environment

- **semantic-release** version: 15.8.1
- **@semantic-release/commit-analyzer** version: 6.0.0
- CI environment: any
- **semantic-release** configuration:
```js
{
  analyzeCommits: [
    { path: '@semantic-release/commit-analyzer' },
  ]
}
```
- CI logs:
```
The analyzeCommits plugin configuration (https://github.com/semantic-release/semantic-release/blob/caribou/docs/usag
e/plugins.md#analyzecommits-plugin) if defined, must be a single or an array of plugins definition. A plugin definition is either a string or an object with a path property.

Your configuration for the `analyzeCommits` plugin is `[ { path: '@semantic-release/commit-analyzer' } ]`
```

---

Hey there 👋

I believe this is a regression since [this change](https://github.com/semantic-release/semantic-release/commit/49f5e704ba0670cebff41853e29a747d93af0874#diff-205c5cf625514dcf92e20a30d112c1cdR16). `configValidator` for `analyzeCommits` doesn't check for an array, like other plugins do. Not sure if error message needs to be tweaked, or logic of config validator itself.


Pull request description:
 fix: clarify `EPLUGINCONF` error message
The message now specify if the step is required and if it allows to configure multiple plugins.

Fix #869

Patch:
diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md
index 3f549a7..ef83982 100644
--- a/docs/usage/configuration.md
+++ b/docs/usage/configuration.md
@@ -57,8 +57,7 @@ $ semantic-release
 
 ### extends
 
-Type: `Array`, `String`
-
+Type: `Array`, `String`<br>
 CLI arguments: `-e`, `--extends`
 
 List of modules or file paths containing a [shareable configuration](shareable-configurations.md). If multiple shareable configurations are set, they will be imported in the order defined with each configuration option taking precedence over the options defined in a previous shareable configuration.
@@ -67,20 +66,16 @@ List of modules or file paths containing a [shareable configuration](shareable-c
 
 ### branch
 
-Type: `String`
-
-Default: `master`
-
+Type: `String`<br>
+Default: `master`<br>
 CLI arguments: `-b`, `--branch`
 
 The branch on which releases should happen.
 
 ### repositoryUrl
 
-Type: `String`
-
-Default: `repository` property in `package.json` or [git origin url](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes)
-
+Type: `String`<br>
+Default: `repository` property in `package.json` or [git origin url](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes)<br>
 CLI arguments: `-r`, `--repository-url`
 
 The git repository URL.
@@ -89,10 +84,8 @@ Any valid git url format is supported (See [Git protocols](https://git-scm.com/b
 
 ### tagFormat
 
-Type: `String`
-
-Default: `v${version}`
-
+Type: `String`<br>
+Default: `v${version}`<br>
 CLI arguments: `-t`, `--tag-format`
 
 The [Git tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging) format used by **semantic-release** to identify releases. The tag name is generated with [Lodash template](https://lodash.com/docs#template) and will be compiled with the `version` variable.
@@ -101,40 +94,32 @@ The [Git tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging) format used by
 
 ### dryRun
 
-Type: `Boolean`
-
-Default: `false` if running in a CI environment, `true` otherwise
-
+Type: `Boolean`<br>
+Default: `false` if running in a CI environment, `true` otherwise<br>
 CLI arguments: `-d`, `--dry-run`
 
 Dry-run mode, skip publishing, print next version and release notes.
 
 ### noCi
 
-Type: `Boolean`
-
-Default: `false`
-
+Type: `Boolean`<br>
+Default: `false`<br>
 CLI arguments: `--no-ci`
 
 Skip Continuous Integration environment verifications. This allows for making releases from a local machine.
 
 ### debug
 
-Type: `Boolean`
-
-Default: `false`
-
+Type: `Boolean`<br>
+Default: `false`<br>
 CLI argument: `--debug`
 
 Output debugging information. It can also be enabled by setting the `DEBUG` environment variable to `semantic-release:*`.
 
 ### verifyConditions
 
-Type: `Array`, `String`, `Object`
-
-Default: `['@semantic-release/npm', '@semantic-release/github']`
-
+Type: `Array`, `String`, `Object`<br>
+Default: `['@semantic-release/npm', '@semantic-release/github']`<br>
 CLI argument: `--verify-conditions`
 
 Define the list of [verify conditions plugins](plugins.md#verifyconditions-plugin). Plugins will run in series, in the order defined in the `Array`.
@@ -143,10 +128,8 @@ See [Plugins configuration](plugins.md#configuration) for more details.
 
 ### analyzeCommits
 
-Type: `String`, `Object`
-
-Default: `'@semantic-release/commit-analyzer'`
-
+Type: `Array`, `String`, `Object`<br>
+Default: `'@semantic-release/commit-analyzer'`<br>
 CLI argument: `--analyze-commits`
 
 Define the [analyze commits plugin](plugins.md#analyzecommits-plugin).
@@ -155,10 +138,8 @@ See [Plugins configuration](plugins.md#configuration) for more details.
 
 ### verifyRelease
 
-Type: `Array`, `String`, `Object`
-
-Default: `[]`
-
+Type: `Array`, `String`, `Object`<br>
+Default: `[]`<br>
 CLI argument: `--verify-release`
 
 Define the list of [verify release plugins](plugins.md#verifyrelease-plugin). Plugins will run in series, in the order defined in the `Array`.
@@ -167,10 +148,8 @@ See [Plugins configuration](plugins.md#configuration) for more details.
 
 ### generateNotes
 
-Type: `Array`, `String`, `Object`
-
-Default: `['@semantic-release/release-notes-generator']`
-
+Type: `Array`, `String`, `Object`<br>
+Default: `['@semantic-release/release-notes-generator']`<br>
 CLI argument: `--generate-notes`
 
 Define the [generate notes plugins](plugins.md#generatenotes-plugin).
@@ -179,10 +158,8 @@ See [Plugins configuration](plugins.md#configuration) for more details.
 
 ### prepare
 
-Type: `Array`, `String`, `Object`
-
-Default: `['@semantic-release/npm']`
-
+Type: `Array`, `String`, `Object`<br>
+Default: `['@semantic-release/npm']`<br>
 CLI argument: `--prepare`
 
 Define the list of [prepare plugins](plugins.md#prepare-plugin). Plugins will run in series, in the order defined in the `Array`.
@@ -191,10 +168,8 @@ See [Plugins configuration](plugins.md#configuration) for more details.
 
 ### publish
 
-Type: `Array`, `String`, `Object`
-
-Default: `['@semantic-release/npm', '@semantic-release/github']`
-
+Type: `Array`, `String`, `Object`<br>
+Default: `['@semantic-release/npm', '@semantic-release/github']`<br>
 CLI argument: `--publish`
 
 Define the list of [publish plugins](plugins.md#publish-plugin). Plugins will run in series, in the order defined in the `Array`.
@@ -203,10 +178,8 @@ See [Plugins configuration](plugins.md#configuration) for more details.
 
 ### success
 
-Type: `Array`, `String`, `Object`
-
-Default: `['@semantic-release/github']`
-
+Type: `Array`, `String`, `Object`<br>
+Default: `['@semantic-release/github']`<br>
 CLI argument: `--success`
 
 Define the list of [success plugins](plugins.md#success-plugin). Plugins will run in series, in the order defined in the `Array`.
@@ -215,10 +188,8 @@ See [Plugins configuration](plugins.md#configuration) for more details.
 
 ### fail
 
-Type: `Array`, `String`, `Object`
-
-Default: `['@semantic-release/github']`
-
+Type: `Array`, `String`, `Object`<br>
+Default: `['@semantic-release/github']`<br>
 CLI argument: `--fail`
 
 Define the list of [fail plugins](plugins.md#fail-plugin). Plugins will run in series, in the order defined in the `Array`.
diff --git a/docs/usage/plugins.md b/docs/usage/plugins.md
index 2b5d0c3..d86632b 100644
--- a/docs/usage/plugins.md
+++ b/docs/usage/plugins.md
@@ -10,25 +10,33 @@ See [plugins list](../extending/plugins-list.md).
 
 Responsible for verifying conditions necessary to proceed with the release: configuration is correct, authentication token are valid, etc...
 
-Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#verifyconditions) and [@semantic-release/github](https://github.com/semantic-release/github#verifyconditions).
+Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#verifyconditions) and [@semantic-release/github](https://github.com/semantic-release/github#verifyconditions).<br>
+Optional.<br>
+Accept multiple plugins.
 
 ### analyzeCommits plugin
 
 Responsible for determining the type of the next release (`major`, `minor` or `patch`).
 
-Default implementation: [@semantic-release/commit-analyzer](https://github.com/semantic-release/commit-analyzer).
+Default implementation: [@semantic-release/commit-analyzer](https://github.com/semantic-release/commit-analyzer).<br>
+Required.<br>
+Accept only one plugin.
 
 ### verifyRelease plugin
 
 Responsible for verifying the parameters (version, type, dist-tag etc...) of the release that is about to be published. For example the [cracks plugin](https://github.com/semantic-release/cracks) is able to verify that if a release contains breaking changes, its type must be `major`.
 
-Default implementation: none.
+Default implementation: none.<br>
+Optional.<br>
+Accept multiple plugins.
 
 ### generateNotes plugin
 
 Responsible for generating release notes. If multiple `generateNotes` plugins are defined, the release notes will be the result of the concatenation of plugin output.
 
-Default implementation: [@semantic-release/release-notes-generator](https://github.com/semantic-release/release-notes-generator).
+Default implementation: [@semantic-release/release-notes-generator](https://github.com/semantic-release/release-notes-generator).<br>
+Optional.<br>
+Accept multiple plugins.
 
 ### prepare plugin
 
@@ -36,25 +44,33 @@ Responsible for preparing the release, including:
 - Creating or updating files such as `package.json`, `CHANGELOG.md`, documentation or compiled assets.
 - Create and push commits
 
-Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#prepare).
+Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#prepare).<br>
+Optional.<br>
+Accept multiple plugins.
 
 ### publish plugin
 
 Responsible for publishing the release.
 
-Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#publish) and [@semantic-release/github](https://github.com/semantic-release/github#publish).
+Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#publish) and [@semantic-release/github](https://github.com/semantic-release/github#publish).<br>
+Optional.<br>
+Accept multiple plugins.
 
 ### success plugin
 
 Responsible for notifying of a new release.
 
-Default implementation: [@semantic-release/github](https://github.com/semantic-release/github#success).
+Default implementation: [@semantic-release/github](https://github.com/semantic-release/github#success).<br>
+Optional.<br>
+Accept multiple plugins.
 
 ### fail plugin
 
 Responsible for notifying of a failed release.
 
-Default implementation: [@semantic-release/github](https://github.com/semantic-release/github#fail).
+Default implementation: [@semantic-release/github](https://github.com/semantic-release/github#fail).<br>
+Optional.<br>
+Accept multiple plugins.
 
 ## Configuration
 
diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js
index f5aa40b..64aa1fa 100644
--- a/lib/definitions/errors.js
+++ b/lib/definitions/errors.js
@@ -55,11 +55,13 @@ Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`
 
 Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`,
   }),
-  EPLUGINCONF: ({type, pluginConf}) => ({
+  EPLUGINCONF: ({type, multiple, required, pluginConf}) => ({
     message: `The \`${type}\` plugin configuration is invalid.`,
-    details: `The [${type} plugin configuration](${linkify(
-      `docs/usage/plugins.md#${toLower(type)}-plugin`
-    )}) if defined, must be a single or an array of plugins definition. A plugin definition is either a string or an object with a \`path\` property.
+    details: `The [${type} plugin configuration](${linkify(`docs/usage/plugins.md#${toLower(type)}-plugin`)}) ${
+      required ? 'is required and ' : ''
+    }must be ${
+      multiple ? 'a single or an array of plugins' : 'a single plugin'
+    } definition. A plugin definition is either a string or an object with a \`path\` property.
 
     Your configuration for the \`${type}\` plugin is \`${stringify(pluginConf)}\`.`,
   }),
diff --git a/lib/definitions/plugins.js b/lib/definitions/plugins.js
index 2c25f1d..bd37c3a 100644
--- a/lib/definitions/plugins.js
+++ b/lib/definitions/plugins.js
@@ -1,18 +1,18 @@
-const {isString, isFunction, isArray, isPlainObject} = require('lodash');
+const {isString, isPlainObject} = require('lodash');
 const {gitHead} = require('../git');
 const {RELEASE_TYPE, RELEASE_NOTES_SEPARATOR} = require('./constants');
 
-const validatePluginConfig = conf => isString(conf) || isString(conf.path) || isFunction(conf);
-
 module.exports = {
   verifyConditions: {
     default: ['@semantic-release/npm', '@semantic-release/github'],
-    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    multiple: true,
+    required: false,
     pipelineConfig: () => ({settleAll: true}),
   },
   analyzeCommits: {
     default: '@semantic-release/commit-analyzer',
-    configValidator: conf => Boolean(conf) && validatePluginConfig(conf),
+    multiple: false,
+    required: true,
     outputValidator: output => !output || RELEASE_TYPE.includes(output),
     preprocess: ({commits, ...inputs}) => ({
       ...inputs,
@@ -22,12 +22,14 @@ module.exports = {
   },
   verifyRelease: {
     default: false,
-    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    multiple: true,
+    required: false,
     pipelineConfig: () => ({settleAll: true}),
   },
   generateNotes: {
     default: ['@semantic-release/release-notes-generator'],
-    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    multiple: true,
+    required: false,
     outputValidator: output => !output || isString(output),
     pipelineConfig: () => ({
       getNextInput: ({nextRelease, ...context}, notes) => ({
@@ -42,7 +44,8 @@ module.exports = {
   },
   prepare: {
     default: ['@semantic-release/npm'],
-    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    multiple: true,
+    required: false,
     pipelineConfig: ({generateNotes}, logger) => ({
       getNextInput: async context => {
         const newGitHead = await gitHead({cwd: context.cwd});
@@ -60,7 +63,8 @@ module.exports = {
   },
   publish: {
     default: ['@semantic-release/npm', '@semantic-release/github'],
-    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    multiple: true,
+    required: false,
     outputValidator: output => !output || isPlainObject(output),
     pipelineConfig: () => ({
       // Add `nextRelease` and plugin properties to published release
@@ -73,12 +77,14 @@ module.exports = {
   },
   success: {
     default: ['@semantic-release/github'],
-    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    multiple: true,
+    required: false,
     pipelineConfig: () => ({settleAll: true}),
   },
   fail: {
     default: ['@semantic-release/github'],
-    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    multiple: true,
+    required: false,
     pipelineConfig: () => ({settleAll: true}),
   },
 };
diff --git a/lib/plugins/index.js b/lib/plugins/index.js
index a1838a9..9abd058 100644
--- a/lib/plugins/index.js
+++ b/lib/plugins/index.js
@@ -2,6 +2,7 @@ const {identity, isPlainObject, omit, castArray, isUndefined} = require('lodash'
 const AggregateError = require('aggregate-error');
 const getError = require('../get-error');
 const PLUGINS_DEFINITIONS = require('../definitions/plugins');
+const {validateConfig} = require('./utils');
 const pipeline = require('./pipeline');
 const normalize = require('./normalize');
 
@@ -11,7 +12,7 @@ module.exports = (context, pluginsPath) => {
   const plugins = Object.entries(PLUGINS_DEFINITIONS).reduce(
     (
       plugins,
-      [type, {configValidator, default: def, pipelineConfig, postprocess = identity, preprocess = identity}]
+      [type, {multiple, required, default: def, pipelineConfig, postprocess = identity, preprocess = identity}]
     ) => {
       let pluginOpts;
 
@@ -23,8 +24,8 @@ module.exports = (context, pluginsPath) => {
         if (isPlainObject(options[type]) && !options[type].path && defaultPaths.length === 1) {
           [options[type].path] = defaultPaths;
         }
-        if (configValidator && !configValidator(options[type])) {
-          errors.push(getError('EPLUGINCONF', {type, pluginConf: options[type]}));
+        if (!validateConfig({multiple, required}, options[type])) {
+          errors.push(getError('EPLUGINCONF', {type, multiple, required, pluginConf: options[type]}));
           return plugins;
         }
         pluginOpts = options[type];
diff --git a/lib/plugins/utils.js b/lib/plugins/utils.js
new file mode 100644
index 0000000..1ece562
--- /dev/null
+++ b/lib/plugins/utils.js
@@ -0,0 +1,18 @@
+const {isString, isFunction, castArray} = require('lodash');
+
+const validateSingleConfig = conf => {
+  conf = castArray(conf);
+  return conf.length === 1 && (isString(conf[0]) || isString(conf[0].path) || isFunction(conf[0]));
+};
+
+const validateMultipleConfig = conf => castArray(conf).every(conf => validateSingleConfig(conf));
+
+const validateConfig = ({multiple, required}, conf) => {
+  conf = castArray(conf).filter(Boolean);
+  if (required) {
+    return Boolean(conf) && conf.length >= 1 && (multiple ? validateMultipleConfig : validateSingleConfig)(conf);
+  }
+  return conf.length === 0 || (multiple ? validateMultipleConfig : validateSingleConfig)(conf);
+};
+
+module.exports = {validateConfig};


Test patch:
diff --git a/test/definitions/plugins.test.js b/test/definitions/plugins.test.js
index eedda95..1ca6b3c 100644
--- a/test/definitions/plugins.test.js
+++ b/test/definitions/plugins.test.js
@@ -2,94 +2,6 @@ import test from 'ava';
 import plugins from '../../lib/definitions/plugins';
 import {RELEASE_NOTES_SEPARATOR} from '../../lib/definitions/constants';
 
-test('The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition', t => {
-  t.false(plugins.verifyConditions.configValidator({}));
-  t.false(plugins.verifyConditions.configValidator({path: null}));
-
-  t.true(plugins.verifyConditions.configValidator({path: 'plugin-path.js'}));
-  t.true(plugins.verifyConditions.configValidator());
-  t.true(plugins.verifyConditions.configValidator('plugin-path.js'));
-  t.true(plugins.verifyConditions.configValidator(() => {}));
-  t.true(plugins.verifyConditions.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
-});
-
-test('The "analyzeCommits" plugin is mandatory, and must be a single plugin definition', t => {
-  t.false(plugins.analyzeCommits.configValidator({}));
-  t.false(plugins.analyzeCommits.configValidator({path: null}));
-  t.false(plugins.analyzeCommits.configValidator([]));
-  t.false(plugins.analyzeCommits.configValidator());
-
-  t.true(plugins.analyzeCommits.configValidator({path: 'plugin-path.js'}));
-  t.true(plugins.analyzeCommits.configValidator('plugin-path.js'));
-  t.true(plugins.analyzeCommits.configValidator(() => {}));
-});
-
-test('The "verifyRelease" plugin, if defined, must be a single or an array of plugins definition', t => {
-  t.false(plugins.verifyRelease.configValidator({}));
-  t.false(plugins.verifyRelease.configValidator({path: null}));
-
-  t.true(plugins.verifyRelease.configValidator({path: 'plugin-path.js'}));
-  t.true(plugins.verifyRelease.configValidator());
-  t.true(plugins.verifyRelease.configValidator('plugin-path.js'));
-  t.true(plugins.verifyRelease.configValidator(() => {}));
-  t.true(plugins.verifyRelease.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
-});
-
-test('The "generateNotes" plugin, if defined, must be a single or an array of plugins definition', t => {
-  t.false(plugins.generateNotes.configValidator({}));
-  t.false(plugins.generateNotes.configValidator({path: null}));
-
-  t.true(plugins.generateNotes.configValidator({path: 'plugin-path.js'}));
-  t.true(plugins.generateNotes.configValidator());
-  t.true(plugins.generateNotes.configValidator('plugin-path.js'));
-  t.true(plugins.generateNotes.configValidator(() => {}));
-  t.true(plugins.generateNotes.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
-});
-
-test('The "prepare" plugin, if defined, must be a single or an array of plugins definition', t => {
-  t.false(plugins.verifyRelease.configValidator({}));
-  t.false(plugins.verifyRelease.configValidator({path: null}));
-
-  t.true(plugins.verifyRelease.configValidator({path: 'plugin-path.js'}));
-  t.true(plugins.verifyRelease.configValidator());
-  t.true(plugins.verifyRelease.configValidator('plugin-path.js'));
-  t.true(plugins.verifyRelease.configValidator(() => {}));
-  t.true(plugins.verifyRelease.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
-});
-
-test('The "publish" plugin is mandatory, and must be a single or an array of plugins definition', t => {
-  t.false(plugins.publish.configValidator({}));
-  t.false(plugins.publish.configValidator({path: null}));
-
-  t.true(plugins.publish.configValidator({path: 'plugin-path.js'}));
-  t.true(plugins.publish.configValidator());
-  t.true(plugins.publish.configValidator('plugin-path.js'));
-  t.true(plugins.publish.configValidator(() => {}));
-  t.true(plugins.publish.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
-});
-
-test('The "success" plugin, if defined, must be a single or an array of plugins definition', t => {
-  t.false(plugins.success.configValidator({}));
-  t.false(plugins.success.configValidator({path: null}));
-
-  t.true(plugins.success.configValidator({path: 'plugin-path.js'}));
-  t.true(plugins.success.configValidator());
-  t.true(plugins.success.configValidator('plugin-path.js'));
-  t.true(plugins.success.configValidator(() => {}));
-  t.true(plugins.success.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
-});
-
-test('The "fail" plugin, if defined, must be a single or an array of plugins definition', t => {
-  t.false(plugins.fail.configValidator({}));
-  t.false(plugins.fail.configValidator({path: null}));
-
-  t.true(plugins.fail.configValidator({path: 'plugin-path.js'}));
-  t.true(plugins.fail.configValidator());
-  t.true(plugins.fail.configValidator('plugin-path.js'));
-  t.true(plugins.fail.configValidator(() => {}));
-  t.true(plugins.fail.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
-});
-
 test('The "analyzeCommits" plugin output must be either undefined or a valid semver release type', t => {
   t.false(plugins.analyzeCommits.outputValidator('invalid'));
   t.false(plugins.analyzeCommits.outputValidator(1));
diff --git a/test/plugins/plugins.test.js b/test/plugins/plugins.test.js
index eac7057..9005964 100644
--- a/test/plugins/plugins.test.js
+++ b/test/plugins/plugins.test.js
@@ -159,18 +159,15 @@ test('Merge global options with plugin options', async t => {
   t.deepEqual(result.pluginConfig, {localOpt: 'local', globalOpt: 'global', otherOpt: 'locally-defined'});
 });
 
-test('Throw an error if plugins configuration are missing a path for plugin pipeline', t => {
-  const errors = [...t.throws(() => getPlugins({cwd, logger: t.context.logger, options: {verifyConditions: {}}}, {}))];
-
-  t.is(errors[0].name, 'SemanticReleaseError');
-  t.is(errors[0].code, 'EPLUGINCONF');
-});
-
-test('Throw an error if an array of plugin configuration is missing a path for plugin pipeline', t => {
+test('Throw an error if plugins configuration are invalid', t => {
   const errors = [
     ...t.throws(() =>
       getPlugins(
-        {cwd, logger: t.context.logger, options: {verifyConditions: [{path: '@semantic-release/npm'}, {}]}},
+        {
+          cwd,
+          logger: t.context.logger,
+          options: {verifyConditions: {}, analyzeCommits: [], verifyRelease: [{}], generateNotes: [{path: null}]},
+        },
         {}
       )
     ),
@@ -178,4 +175,10 @@ test('Throw an error if an array of plugin configuration is missing a path for p
 
   t.is(errors[0].name, 'SemanticReleaseError');
   t.is(errors[0].code, 'EPLUGINCONF');
+  t.is(errors[1].name, 'SemanticReleaseError');
+  t.is(errors[1].code, 'EPLUGINCONF');
+  t.is(errors[2].name, 'SemanticReleaseError');
+  t.is(errors[2].code, 'EPLUGINCONF');
+  t.is(errors[3].name, 'SemanticReleaseError');
+  t.is(errors[3].code, 'EPLUGINCONF');
 });
diff --git a/test/plugins/utils.test.js b/test/plugins/utils.test.js
new file mode 100644
index 0000000..9a1bf35
--- /dev/null
+++ b/test/plugins/utils.test.js
@@ -0,0 +1,58 @@
+import test from 'ava';
+import {validateConfig} from '../../lib/plugins/utils';
+
+test('Validate multiple/optional plugin configuration', t => {
+  const type = {multiple: true, required: false};
+  t.false(validateConfig(type, {}));
+  t.false(validateConfig(type, {path: null}));
+
+  t.true(validateConfig(type, {path: 'plugin-path.js'}));
+  t.true(validateConfig(type));
+  t.true(validateConfig(type, 'plugin-path.js'));
+  t.true(validateConfig(type, ['plugin-path.js']));
+  t.true(validateConfig(type, () => {}));
+  t.true(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
+});
+
+test('Validate multiple/required plugin configuration', t => {
+  const type = {multiple: true, required: true};
+  t.false(validateConfig(type, {}));
+  t.false(validateConfig(type, {path: null}));
+  t.false(validateConfig(type));
+
+  t.true(validateConfig(type, {path: 'plugin-path.js'}));
+  t.true(validateConfig(type, 'plugin-path.js'));
+  t.true(validateConfig(type, ['plugin-path.js']));
+  t.true(validateConfig(type, () => {}));
+  t.true(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
+});
+
+test('Validate single/required plugin configuration', t => {
+  const type = {multiple: false, required: true};
+
+  t.false(validateConfig(type, {}));
+  t.false(validateConfig(type, {path: null}));
+  t.false(validateConfig(type, []));
+  t.false(validateConfig(type));
+  t.false(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
+
+  t.true(validateConfig(type, {path: 'plugin-path.js'}));
+  t.true(validateConfig(type, 'plugin-path.js'));
+  t.true(validateConfig(type, ['plugin-path.js']));
+  t.true(validateConfig(type, () => {}));
+});
+
+test('Validate single/optional plugin configuration', t => {
+  const type = {multiple: false, required: false};
+
+  t.false(validateConfig(type, {}));
+  t.false(validateConfig(type, {path: null}));
+  t.false(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
+
+  t.true(validateConfig(type));
+  t.true(validateConfig(type, []));
+  t.true(validateConfig(type, {path: 'plugin-path.js'}));
+  t.true(validateConfig(type, 'plugin-path.js'));
+  t.true(validateConfig(type, ['plugin-path.js']));
+  t.true(validateConfig(type, () => {}));
+});