Repository: avajs/eslint-plugin-ava
Pull request #250 (2019-05-25 17:15:04)

Rely on AVA to provide a helper for our rules
This impacts no-ignored-test-files and no-import-test-files. These rules
now assume an AVA version is installed that provides a helper. This
helper can classify files according to AVA's configuration.

This removes the need to specify options for these rules.

Note that the tests use a stubbed helper.

---

AVA PR: https://github.com/avajs/ava/pull/2139

novemberborn: Here I linked both PR's from one new project:

```
❯ npx eslint .

/private/var/folders/2_/qczp184x76b2nl034sq5hvxw0000gn/T/tmp.mDR4Y3hUm0/not-test.js
  1:1  error  Test files should not be imported  ava/no-import-test-files

/private/var/folders/2_/qczp184x76b2nl034sq5hvxw0000gn/T/tmp.mDR4Y3hUm0/test/_helper.js
  1:1  error  AVA treats this as a helper file  ava/no-ignored-test-files

✖ 2 problems (2 errors, 0 warnings)
```
novemberborn: For the `no-import-test-files` rule, I think I need to add a `classifyImport(importPath)` method to he helper. If the import has a configured extension, it can see how it's classified, and otherwise we can check it with each of the configured extensions.
novemberborn: > For the `no-import-test-files` rule, I think I need to add a `classifyImport(importPath)` method to he helper. If the import has a configured extension, it can see how it's classified, and otherwise we can check it with each of the configured extensions.

This has been addressed.

Patch:
diff --git a/index.js b/index.js
index 88bb174..8648431 100644
--- a/index.js
+++ b/index.js
@@ -41,6 +41,7 @@ module.exports = {
 				'ava/prefer-power-assert': 'off',
 				'ava/test-ended': 'error',
 				'ava/test-title': 'error',
+				'ava/test-title-format': 'off',
 				'ava/use-t-well': 'error',
 				'ava/use-t': 'error',
 				'ava/use-test': 'error',
diff --git a/package.json b/package.json
index c921f76..fe28d10 100644
--- a/package.json
+++ b/package.json
@@ -29,16 +29,13 @@
 		"mocha"
 	],
 	"dependencies": {
-		"arrify": "^2.0.1",
 		"deep-strict-equal": "^0.2.0",
 		"enhance-visitors": "^1.0.0",
-		"esm": "^3.2.25",
 		"espree": "^5.0.0",
 		"espurify": "^2.0.0",
 		"import-modules": "^1.1.0",
-		"is-plain-object": "^3.0.0",
-		"multimatch": "^4.0.0",
-		"pkg-up": "^3.1.0"
+		"pkg-dir": "^4.2.0",
+		"resolve-from": "^5.0.0"
 	},
 	"devDependencies": {
 		"ava": "^1.0.1",
diff --git a/readme.md b/readme.md
index 5b382da..6505290 100644
--- a/readme.md
+++ b/readme.md
@@ -59,6 +59,7 @@ Configure it in `package.json`.
 			"ava/prefer-power-assert": "off",
 			"ava/test-ended": "error",
 			"ava/test-title": "error",
+			"ava/test-title-format": "off",
 			"ava/use-t": "error",
 			"ava/use-t-well": "error",
 			"ava/use-test": "error",
@@ -94,6 +95,7 @@ The rules will only activate in test files.
 - [prefer-power-assert](docs/rules/prefer-power-assert.md) - Allow only use of the asserts that have no [power-assert](https://github.com/power-assert-js/power-assert) alternative.
 - [test-ended](docs/rules/test-ended.md) - Ensure callback tests are explicitly ended.
 - [test-title](docs/rules/test-title.md) - Ensure tests have a title.
+- [test-title-format](docs/rules/test-title-format.md) - Ensure test titles have a certain format.
 - [use-t](docs/rules/use-t.md) - Ensure test functions use `t` as their parameter.
 - [use-t-well](docs/rules/use-t-well.md) - Prevent the incorrect use of `t`. *(partly fixable)*
 - [use-test](docs/rules/use-test.md) - Ensure that AVA is imported with `test` as the variable name.
diff --git a/util.js b/util.js
index 0bcd27b..3c51886 100644
--- a/util.js
+++ b/util.js
@@ -1,31 +1,29 @@
 'use strict';
-const fs = require('fs');
 const path = require('path');
-const isPlainObject = require('is-plain-object');
-const esmRequire = require('esm')(module, {
-	cjs: false,
-	force: true,
-	mode: 'all'
-});
+const pkgDir = require('pkg-dir');
+const resolveFrom = require('resolve-from');
 const pkg = require('./package');
 
+exports.loadAvaHelper = filename => {
+	const rootDir = pkgDir.sync(filename);
+	if (!rootDir) {
+		return undefined;
+	}
+
+	const avaHelperPath = resolveFrom.silent(rootDir, 'ava/eslint-plugin-helper');
+	if (!avaHelperPath) {
+		return undefined;
+	}
+
+	const avaHelper = require(avaHelperPath);
+	return avaHelper.load(rootDir);
+};
+
 const functionExpressions = [
 	'FunctionExpression',
 	'ArrowFunctionExpression'
 ];
 
-const defaultFiles = [
-	'test.js',
-	'test-*.js',
-	'test/**/*.js',
-	'**/__tests__/**/*.js',
-	'**/*.test.js'
-];
-
-const defaultExtensions = [
-	'js'
-];
-
 exports.getRootNode = node => {
 	if (node.object.type === 'MemberExpression') {
 		return exports.getRootNode(node.object);
@@ -42,59 +40,6 @@ exports.isPropertyUnderContext = node => {
 	return exports.getRootNode(node).property.name === 'context';
 };
 
-const NO_SUCH_FILE = Symbol('no ava.config.js file');
-const MISSING_DEFAULT_EXPORT = Symbol('missing default export');
-
-// Based on https://github.com/avajs/ava/blob/v1.0.0-beta.6/lib/load-config.js,
-// except where AVA would exit with an exception, this function returns an empty
-// config object.
-exports.getAvaConfig = packageFilepath => {
-	const defaultResult = {};
-
-	if (!packageFilepath) {
-		return defaultResult;
-	}
-
-	try {
-		const {ava: packageConf = defaultResult} = JSON.parse(fs.readFileSync(packageFilepath, 'utf8'));
-
-		const projectDir = path.dirname(packageFilepath);
-		let fileConf;
-		try {
-			({default: fileConf = MISSING_DEFAULT_EXPORT} = esmRequire(path.join(projectDir, 'ava.config.js')));
-		} catch (error) {
-			if (error && error.code === 'MODULE_NOT_FOUND') {
-				fileConf = NO_SUCH_FILE;
-			} else {
-				return defaultResult;
-			}
-		}
-
-		if (fileConf === MISSING_DEFAULT_EXPORT) {
-			return defaultResult;
-		}
-
-		if (fileConf === NO_SUCH_FILE) {
-			return packageConf;
-		}
-
-		if (Object.keys(packageConf).length > 0) {
-			return defaultResult;
-		}
-
-		if (typeof fileConf === 'function') {
-			fileConf = fileConf({projectDir});
-		}
-
-		// eslint-disable-next-line promise/prefer-await-to-then
-		return !fileConf || typeof fileConf.then === 'function' || !isPlainObject(fileConf) ?
-			defaultResult :
-			fileConf;
-	} catch (_) {
-		return defaultResult;
-	}
-};
-
 exports.isFunctionExpression = node => node && functionExpressions.includes(node.type);
 
 function getTestModifiers(node) {
@@ -177,7 +122,5 @@ const assertionMethodsNumArguments = new Map([
 const assertionMethodNames = [...assertionMethodsNumArguments.keys()];
 
 exports.assertionMethodsNumArguments = assertionMethodsNumArguments;
-exports.defaultFiles = defaultFiles;
-exports.defaultExtensions = defaultExtensions;
 exports.assertionMethods = new Set(assertionMethodNames);
 exports.executionMethods = new Set(assertionMethodNames.concat(['end', 'plan', 'log']));


Test patch:
diff --git a/docs/rules/no-ignored-test-files.md b/docs/rules/no-ignored-test-files.md
index 4ca07cb..ad6bfb1 100644
--- a/docs/rules/no-ignored-test-files.md
+++ b/docs/rules/no-ignored-test-files.md
@@ -2,43 +2,22 @@
 
 Translations: [Français](https://github.com/avajs/ava-docs/blob/master/fr_FR/related/eslint-plugin-ava/docs/rules/no-ignored-test-files.md)
 
-When searching for tests, AVA ignores files contained in `node_modules` or folders named `fixtures` or `helpers`. By default, it will search in `test.js test-*.js test/**/*.js **/__tests__/**/*.js **/*.test.js`, which you can override by specifying a path when launching AVA or in the [AVA configuration in the `package.json` or `ava.config.js` files](https://github.com/avajs/ava/blob/master/docs/06-configuration.md).
+This rule will verify that files which create tests are treated as test files by AVA. It will consider the root of the project to be the closest folder containing a `package.json` file, and will not do anything if it can't find one. Test files in `node_modules` will not be linted as they are ignored by ESLint.
 
-This rule will verify that files which create tests are in the searched files and not in ignored folders. It will consider the root of the project to be the closest folder containing a `package.json` file, and will not do anything if it can't find one. Test files in `node_modules` will not be linted as they are ignored by ESLint.
-
-Note that this rule will not be able to warn correctly if you use AVA by specifying the files in the command line ( `ava "lib/**/*.test.js"` ). Prefer configuring AVA as described in the link above.
 
 ## Fail
 
 ```js
-// File: test/foo/fixtures/bar.js
-// Invalid because in `fixtures` folder
-import test from 'ava';
-
-test('foo', t => {
-	t.pass();
-});
-
-// File: test/foo/helpers/bar.js
-// Invalid because in `helpers` folder
+// File: test/_helper.js
+// Invalid because a helper.
 import test from 'ava';
 
 test('foo', t => {
 	t.pass();
 });
 
-// File: lib/foo.test.js
-// Invalid because not in the searched files
-import test from 'ava';
-
-test('foo', t => {
-	t.pass();
-});
-
-// File: test.js
-// with { "files": ["lib/**/*.test.js", "utils/**/*.test.js"] }
-// in either `package.json` under 'ava key' or in the rule options
-// Invalid because not in the searched files
+// File: lib/foo.js
+// Invalid because not a test file.
 import test from 'ava';
 
 test('foo', t => {
@@ -50,45 +29,10 @@ test('foo', t => {
 ## Pass
 
 ```js
-// File: test/foo/not-fixtures/bar.js
-import test from 'ava';
-
-test('foo', t => {
-	t.pass();
-});
-
-// File: test/foo/not-helpers/bar.js
+// File: test/foo.js
 import test from 'ava';
 
 test('foo', t => {
 	t.pass();
 });
-
-// File: test.js
-import test from 'ava';
-
-test('foo', t => {
-	t.pass();
-});
-
-// File: lib/foo.test.js
-// with { "files": ["lib/**/*.test.js", "utils/**/*.test.js"] }
-// in either `package.json` under 'ava key' or in the rule options
-import test from 'ava';
-
-test('foo', t => {
-	t.pass();
-});
-```
-
-## Options
-
-This rule supports the following options:
-
-`files`: An array of strings representing the files glob that AVA will use to find test files. Overrides the default and the configuration found in the `package.json` or `ava.config.js` files.
-
-You can set the options like this:
-
-```js
-"ava/no-ignored-test-files": ["error", {"files": ["lib/**/*.test.js", "utils/**/*.test.js"]}]
 ```
diff --git a/docs/rules/no-import-test-files.md b/docs/rules/no-import-test-files.md
index ceef5bc..02598c6 100644
--- a/docs/rules/no-import-test-files.md
+++ b/docs/rules/no-import-test-files.md
@@ -4,8 +4,6 @@ Translations: [Français](https://github.com/avajs/ava-docs/blob/master/fr_FR/re
 
 This rule will verify that you don't import any test files. It will consider the root of the project to be the closest folder containing a `package.json` file, and will not do anything if it can't find one. Test files in `node_modules` will not be linted as they are ignored by ESLint.
 
-Note that this rule will not be able to warn correctly if you use AVA by specifying the files in the command line ( `ava "lib/**/*.test.js"` ). Prefer configuring AVA as described [here](https://github.com/avajs/ava/blob/master/docs/06-configuration.md).
-
 
 ## Fail
 
@@ -25,30 +23,6 @@ test('foo', t => {
 });
 ```
 
-```js
-// File: utils/index.js
-// with `{"files": ["lib/**/*.test.js", "utils/**/*.test.js"]}`
-// in either `package.json` under the `"ava"` key or in the rule options
-// Invalid because the imported file matches `lib/**/*.test.js`
-import tests from '../lib/index.test.js';
-
-test('foo', t => {
-	t.pass();
-});
-```
-
-```js
-// File: utils/index.js
-// with `{"extensions": ["js", "mjs"]}`
-// in either `package.json` under the `"ava"` key or in the rule options
-// Invalid because the imported file extension matches `mjs`
-import tests from 'index.test.mjs';
-
-test('foo', t => {
-	t.pass();
-});
-```
-
 
 ## Pass
 
@@ -62,48 +36,3 @@ import sinon from 'sinon';
 // File: src/index.js
 import utils from './utils';
 ```
-
-```js
-// File: lib/index.js
-// with `{"files": ["lib/**/*.test.js", "utils/**/*.test.js"]}`
-// in either `package.json` under 'ava key' or in the rule options
-import utils from 'test.js';
-```
-
-```js
-// File: lib/index.js
-// `with {"extensions": ["js", "mjs"]}`
-// in either `package.json` under 'ava key' or in the rule options
-import utils from 'test.jsx';
-```
-
-
-## Options
-
-This rule supports the following options:
-
-`files`: An array of strings representing the files glob that AVA will use to find test files. Overrides the default and the configuration found in the `package.json` or `ava.config.js` files.
-
-You can set the options like this:
-
-```json
-"ava/no-ignored-test-files": ["error", {"files": ["lib/**/*.test.js", "utils/**/*.test.js"]}]
-```
-
-`extensions`: An array of strings representing the file extensions that AVA will use to find the test files. It overrides the default and the configuration found in the `package.json` or `ava.config.js` files.
-
-This extension will filter out files from the `files` option.
-
-You can set the options like this:
-
-```json
-"ava/no-ignored-test-files": [
-	"error",
-	{
-		"extensions": [
-			"js",
-			"mjs"
-		]
-	}
-]
-```
diff --git a/docs/rules/test-title-format.md b/docs/rules/test-title-format.md
new file mode 100644
index 0000000..39cef99
--- /dev/null
+++ b/docs/rules/test-title-format.md
@@ -0,0 +1,69 @@
+# Ensure test titles have a certain format
+
+This rule is useful when you want to make sure all test titles match a common pattern to increase readability when tests fail.
+
+For example, titles like `'Should throw when invalid.'`, `'Should fail when called.'` or `'Should pass when using any number.'` could be enforced with the following pattern `'^Should (pass|fail|throw) when [\\w ]+\\.$'` (Note the escaped `\`).
+
+
+## Fail
+
+```js
+/* eslint ava/test-title: ["error", {format: "^Should"}] */
+import test from 'ava';
+
+test('Not starting with `Should`', t => {
+	t.pass();
+});
+```
+
+```js
+/* eslint ava/test-title: ["error", {format: "\\.$"}] */
+import test from 'ava';
+
+test('Doesn\'t end with a dot', t => {
+	t.pass();
+});
+```
+
+
+## Pass
+
+```js
+/* eslint ava/test-title: ["error", {format: "^Should"}] */
+import test from 'ava';
+
+test('Should pass tests', t => {
+	t.pass();
+});
+
+test('Should behave as expected', t => {
+	t.pass();
+});
+```
+
+```js
+/* eslint ava/test-title: ["error", {format: "\\.$"}] */
+import test from 'ava';
+
+test('End with a dot.', t => {
+	t.pass();
+});
+```
+
+
+## Options
+
+This rule supports the following options:
+
+`format`: A regular expression string to match against the test titles.
+
+You can set the options like this:
+
+```json
+"ava/test-title-format": [
+	"error",
+	{
+		"format": "^Should"
+	}
+]
+```
diff --git a/rules/no-ignored-test-files.js b/rules/no-ignored-test-files.js
index 7457c2b..9027ea8 100644
--- a/rules/no-ignored-test-files.js
+++ b/rules/no-ignored-test-files.js
@@ -1,38 +1,8 @@
 'use strict';
-const path = require('path');
-const arrify = require('arrify');
-const pkgUp = require('pkg-up');
-const multimatch = require('multimatch');
 const {visitIf} = require('enhance-visitors');
 const util = require('../util');
 const createAvaRule = require('../create-ava-rule');
 
-const excludedFolders = [
-	'**/fixtures/**',
-	'**/helpers/**'
-];
-
-function isIgnored(rootDir, files, filepath) {
-	const relativeFilePath = path.relative(rootDir, filepath);
-
-	if (multimatch([relativeFilePath], excludedFolders).length !== 0) {
-		return `Test file is ignored because it is in \`${excludedFolders.join(' ')}\`.`;
-	}
-
-	if (multimatch([relativeFilePath], files).length === 0) {
-		return `Test file is ignored because it is not in \`${files.join(' ')}\`.`;
-	}
-}
-
-function getPackageInfo() {
-	const packageFilePath = pkgUp.sync();
-
-	return {
-		rootDir: packageFilePath && path.dirname(packageFilePath),
-		files: util.getAvaConfig(packageFilePath).files
-	};
-}
-
 const create = context => {
 	const filename = context.getFilename();
 
@@ -40,17 +10,9 @@ const create = context => {
 		return {};
 	}
 
-	const ava = createAvaRule();
-	const packageInfo = getPackageInfo();
-	const options = context.options[0] || {};
-	const files = arrify(options.files || packageInfo.files || util.defaultFiles);
 	let hasTestCall = false;
 
-	if (!packageInfo.rootDir) {
-		// Could not find a package.json folder
-		return {};
-	}
-
+	const ava = createAvaRule();
 	return ava.merge({
 		CallExpression: visitIf([
 			ava.isInTestFile,
@@ -63,13 +25,21 @@ const create = context => {
 				return;
 			}
 
-			const ignoredReason = isIgnored(packageInfo.rootDir, files, filename);
+			const avaHelper = util.loadAvaHelper(filename);
+			if (!avaHelper) {
+				return {};
+			}
 
-			if (ignoredReason) {
-				context.report({
-					node,
-					message: ignoredReason
-				});
+			const {isHelper, isSource, isTest} = avaHelper.classifyFile(filename);
+
+			if (!isTest) {
+				if (isHelper) {
+					context.report({node, message: 'AVA treats this as a helper file.'});
+				} else if (isSource) {
+					context.report({node, message: 'AVA treats this as a source file.'});
+				} else {
+					context.report({node, message: 'AVA ignores this file.'});
+				}
 			}
 
 			hasTestCall = false;
@@ -77,22 +47,12 @@ const create = context => {
 	});
 };
 
-const schema = [{
-	type: 'object',
-	properties: {
-		files: {
-			type: 'array'
-		}
-	}
-}];
-
 module.exports = {
 	create,
 	meta: {
 		docs: {
 			url: util.getDocsUrl(__filename)
 		},
-		schema,
 		type: 'suggestion'
 	}
 };
diff --git a/rules/no-import-test-files.js b/rules/no-import-test-files.js
index b280f7c..822865a 100644
--- a/rules/no-import-test-files.js
+++ b/rules/no-import-test-files.js
@@ -1,8 +1,5 @@
 'use strict';
 const path = require('path');
-const arrify = require('arrify');
-const pkgUp = require('pkg-up');
-const multimatch = require('multimatch');
 const util = require('../util');
 
 const externalModuleRegExp = /^\w/;
@@ -10,29 +7,19 @@ function isExternalModule(name) {
 	return externalModuleRegExp.test(name);
 }
 
-function isTestFile(files, extensions, rootDir, sourceFile, importedFile) {
-	const absoluteImportedPath = path.resolve(path.dirname(sourceFile), importedFile);
-	const relativePath = path.relative(rootDir, absoluteImportedPath);
+const create = context => {
+	const filename = context.getFilename();
 
-	return multimatch([relativePath], files).filter(file => {
-		const extension = path.extname(file).slice(1);
-		return extensions.includes(extension);
-	}).length === 1;
-}
+	if (filename === '<text>') {
+		return {};
+	}
 
-function getProjectInfo() {
-	const packageFilePath = pkgUp.sync();
-	const {files, babel} = util.getAvaConfig(packageFilePath);
+	const resolveFrom = path.dirname(filename);
 
-	return {
-		rootDir: packageFilePath && path.dirname(packageFilePath),
-		files,
-		babel
-	};
-}
+	let loadedAvaHelper = false;
+	let avaHelper;
 
-function createImportValidator(context, files, extensions, projectInfo, filename) {
-	return (node, importPath) => {
+	const validateImportPath = (node, importPath) => {
 		if (!importPath || typeof importPath !== 'string') {
 			return;
 		}
@@ -42,36 +29,23 @@ function createImportValidator(context, files, extensions, projectInfo, filename
 			return;
 		}
 
-		const isImportingTestFile = isTestFile(files, extensions, projectInfo.rootDir, filename, importPath);
-		if (isImportingTestFile) {
+		if (!loadedAvaHelper) {
+			avaHelper = util.loadAvaHelper(filename);
+			loadedAvaHelper = true;
+		}
+
+		if (!avaHelper) {
+			return {};
+		}
+
+		const {isTest} = avaHelper.classifyImport(path.resolve(resolveFrom, importPath));
+		if (isTest) {
 			context.report({
 				node,
 				message: 'Test files should not be imported.'
 			});
 		}
 	};
-}
-
-const create = context => {
-	const filename = context.getFilename();
-
-	if (filename === '<text>') {
-		return {};
-	}
-
-	const projectInfo = getProjectInfo();
-	const options = context.options[0] || {};
-
-	const extensions = arrify(options.extensions || (projectInfo.babel && projectInfo.babel.extensions) || util.defaultExtensions);
-
-	const files = arrify(options.files || projectInfo.files || util.defaultFiles);
-
-	if (!projectInfo.rootDir) {
-		// Could not find a package.json folder
-		return {};
-	}
-
-	const validateImportPath = createImportValidator(context, files, extensions, projectInfo, filename);
 
 	return {
 		ImportDeclaration: node => {
@@ -89,22 +63,12 @@ const create = context => {
 	};
 };
 
-const schema = [{
-	type: 'object',
-	properties: {
-		files: {
-			type: 'array'
-		}
-	}
-}];
-
 module.exports = {
 	create,
 	meta: {
 		docs: {
 			url: util.getDocsUrl(__filename)
 		},
-		schema,
 		type: 'suggestion'
 	}
 };
diff --git a/rules/test-title-format.js b/rules/test-title-format.js
new file mode 100644
index 0000000..dcadc37
--- /dev/null
+++ b/rules/test-title-format.js
@@ -0,0 +1,46 @@
+'use strict';
+const {visitIf} = require('enhance-visitors');
+const createAvaRule = require('../create-ava-rule');
+const util = require('../util');
+
+const create = context => {
+	const ava = createAvaRule();
+
+	let titleRegExp;
+	if (context.options[0] && context.options[0].format) {
+		titleRegExp = new RegExp(context.options[0].format);
+	} else {
+		return {};
+	}
+
+	return ava.merge({
+		CallExpression: visitIf([
+			ava.isInTestFile,
+			ava.isTestNode,
+			ava.hasNoHookModifier
+		])(node => {
+			const requiredLength = ava.hasTestModifier('todo') ? 1 : 2;
+			const hasTitle = node.arguments.length >= requiredLength;
+
+			if (hasTitle) {
+				const title = node.arguments[0];
+				if (title.type === 'Literal' && !titleRegExp.test(title.value)) {
+					context.report({
+						node,
+						message: `The test title doesn't match the required format: \`${titleRegExp}\``
+					});
+				}
+			}
+		})
+	});
+};
+
+module.exports = {
+	create,
+	meta: {
+		type: 'suggestion',
+		docs: {
+			url: util.getDocsUrl(__filename)
+		}
+	}
+};
diff --git a/test/fixtures/load-config/factory-no-plain-return/ava.config.js b/test/fixtures/load-config/factory-no-plain-return/ava.config.js
deleted file mode 100644
index 211b0c3..0000000
--- a/test/fixtures/load-config/factory-no-plain-return/ava.config.js
+++ /dev/null
@@ -1,3 +0,0 @@
-class Config {}
-
-export default () => new Config();
diff --git a/test/fixtures/load-config/factory-no-plain-return/package.json b/test/fixtures/load-config/factory-no-plain-return/package.json
deleted file mode 100644
index 0967ef4..0000000
--- a/test/fixtures/load-config/factory-no-plain-return/package.json
+++ /dev/null
@@ -1,1 +0,0 @@
-{}
diff --git a/test/fixtures/load-config/factory-no-promise-return/ava.config.js b/test/fixtures/load-config/factory-no-promise-return/ava.config.js
deleted file mode 100644
index 1d4a258..0000000
--- a/test/fixtures/load-config/factory-no-promise-return/ava.config.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export default () => new Promise(resolve => {
-	resolve({
-		files: 'this-should-not-work'
-	});
-});
diff --git a/test/fixtures/load-config/factory-no-promise-return/package.json b/test/fixtures/load-config/factory-no-promise-return/package.json
deleted file mode 100644
index 0967ef4..0000000
--- a/test/fixtures/load-config/factory-no-promise-return/package.json
+++ /dev/null
@@ -1,1 +0,0 @@
-{}
diff --git a/test/fixtures/load-config/no-default-export/ava.config.js b/test/fixtures/load-config/no-default-export/ava.config.js
deleted file mode 100644
index c4470d2..0000000
--- a/test/fixtures/load-config/no-default-export/ava.config.js
+++ /dev/null
@@ -1,1 +0,0 @@
-export const config = {};
diff --git a/test/fixtures/load-config/no-default-export/package.json b/test/fixtures/load-config/no-default-export/package.json
deleted file mode 100644
index 0967ef4..0000000
--- a/test/fixtures/load-config/no-default-export/package.json
+++ /dev/null
@@ -1,1 +0,0 @@
-{}
diff --git a/test/fixtures/load-config/no-plain-config/ava.config.js b/test/fixtures/load-config/no-plain-config/ava.config.js
deleted file mode 100644
index fb31a41..0000000
--- a/test/fixtures/load-config/no-plain-config/ava.config.js
+++ /dev/null
@@ -1,1 +0,0 @@
-export default 'should not work!';
diff --git a/test/fixtures/load-config/no-plain-config/package.json b/test/fixtures/load-config/no-plain-config/package.json
deleted file mode 100644
index 0967ef4..0000000
--- a/test/fixtures/load-config/no-plain-config/package.json
+++ /dev/null
@@ -1,1 +0,0 @@
-{}
diff --git a/test/fixtures/load-config/no-promise-config/ava.config.js b/test/fixtures/load-config/no-promise-config/ava.config.js
deleted file mode 100644
index dc48959..0000000
--- a/test/fixtures/load-config/no-promise-config/ava.config.js
+++ /dev/null
@@ -1,1 +0,0 @@
-export default Promise.resolve('should not work!');
diff --git a/test/fixtures/load-config/no-promise-config/package.json b/test/fixtures/load-config/no-promise-config/package.json
deleted file mode 100644
index 0967ef4..0000000
--- a/test/fixtures/load-config/no-promise-config/package.json
+++ /dev/null
@@ -1,1 +0,0 @@
-{}
diff --git a/test/fixtures/load-config/package-no-file-yes-factory/ava.config.js b/test/fixtures/load-config/package-no-file-yes-factory/ava.config.js
deleted file mode 100644
index 4715349..0000000
--- a/test/fixtures/load-config/package-no-file-yes-factory/ava.config.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export default ({projectDir}) => {
-	return {
-		files: projectDir
-	};
-};
diff --git a/test/fixtures/load-config/package-no-file-yes-factory/package.json b/test/fixtures/load-config/package-no-file-yes-factory/package.json
deleted file mode 100644
index 0967ef4..0000000
--- a/test/fixtures/load-config/package-no-file-yes-factory/package.json
+++ /dev/null
@@ -1,1 +0,0 @@
-{}
diff --git a/test/fixtures/load-config/package-no-file-yes/ava.config.js b/test/fixtures/load-config/package-no-file-yes/ava.config.js
deleted file mode 100644
index 755aeb7..0000000
--- a/test/fixtures/load-config/package-no-file-yes/ava.config.js
+++ /dev/null
@@ -1,5 +0,0 @@
-const config = {
-	files: 'config-file-esm-test-value'
-};
-
-export default config;
diff --git a/test/fixtures/load-config/package-no-file-yes/package.json b/test/fixtures/load-config/package-no-file-yes/package.json
deleted file mode 100644
index 0967ef4..0000000
--- a/test/fixtures/load-config/package-no-file-yes/package.json
+++ /dev/null
@@ -1,1 +0,0 @@
-{}
diff --git a/test/fixtures/load-config/package-only/package.json b/test/fixtures/load-config/package-only/package.json
deleted file mode 100644
index d016439..0000000
--- a/test/fixtures/load-config/package-only/package.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
-	"ava": {
-		"failFast": true,
-		"concurrency": 10
-	}
-}
diff --git a/test/fixtures/load-config/package-yes-file-yes/ava.config.js b/test/fixtures/load-config/package-yes-file-yes/ava.config.js
deleted file mode 100644
index c6ddbbe..0000000
--- a/test/fixtures/load-config/package-yes-file-yes/ava.config.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default {
-	files: 'package-yes-files-yes-test-value'
-};
diff --git a/test/fixtures/load-config/package-yes-file-yes/package.json b/test/fixtures/load-config/package-yes-file-yes/package.json
deleted file mode 100644
index 2ae3bb0..0000000
--- a/test/fixtures/load-config/package-yes-file-yes/package.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
-	"ava": {
-		"files": [
-			"abc",
-			"!xyz"
-		]
-	}
-}
diff --git a/test/fixtures/load-config/throws/ava.config.js b/test/fixtures/load-config/throws/ava.config.js
deleted file mode 100644
index cd8e188..0000000
--- a/test/fixtures/load-config/throws/ava.config.js
+++ /dev/null
@@ -1,1 +0,0 @@
-throw new Error('foo');
diff --git a/test/fixtures/load-config/throws/package.json b/test/fixtures/load-config/throws/package.json
deleted file mode 100644
index 0967ef4..0000000
--- a/test/fixtures/load-config/throws/package.json
+++ /dev/null
@@ -1,1 +0,0 @@
-{}
diff --git a/test/no-ignored-test-files-with-package.js b/test/no-ignored-test-files-with-package.js
deleted file mode 100644
index 810f648..0000000
--- a/test/no-ignored-test-files-with-package.js
+++ /dev/null
@@ -1,79 +0,0 @@
-import path from 'path';
-import test from 'ava';
-import avaRuleTester from 'eslint-ava-rule-tester';
-import util from '../util';
-import rule from '../rules/no-ignored-test-files';
-
-const ruleTester = avaRuleTester(test, {
-	env: {
-		es6: true
-	}
-});
-
-const header = 'const test = require(\'ava\');\n';
-const rootDir = path.dirname(__dirname);
-
-const toPath = subPath => path.join(rootDir, subPath);
-const code = hasHeader => (hasHeader ? header : '') + 'test(t => { t.pass(); });';
-
-util.getAvaConfig = () => ({
-	files: ['lib/**/*.test.js']
-});
-
-ruleTester.run('no-ignored-test-files', rule, {
-	valid: [
-		{
-			code: code(true),
-			filename: toPath('lib/foo.test.js')
-		},
-		{
-			code: code(true),
-			filename: toPath('bar/foo.test.js'),
-			options: [{files: ['bar/**/*.test.js']}]
-		},
-		{
-			code: code(true),
-			filename: toPath('bar/foo.test.js'),
-			options: [{files: ['bar/**/*.test.js']}]
-		},
-		{
-			code: code(true),
-			filename: '<text>',
-			options: [{files: ['lib/**/*.spec.js']}]
-		}
-	],
-	invalid: [
-		{
-			code: code(true),
-			filename: toPath('lib/foo/fixtures/bar.test.js'),
-			errors: [{message: 'Test file is ignored because it is in `**/fixtures/** **/helpers/**`.'}]
-		},
-		{
-			code: code(true),
-			filename: toPath('lib/foo/helpers/bar.test.js'),
-			errors: [{message: 'Test file is ignored because it is in `**/fixtures/** **/helpers/**`.'}]
-		},
-		{
-			code: code(true),
-			filename: toPath('test.js'),
-			errors: [{message: 'Test file is ignored because it is not in `lib/**/*.test.js`.'}]
-		},
-		{
-			code: code(true),
-			filename: toPath('bar/foo.test.js'),
-			errors: [{message: 'Test file is ignored because it is not in `lib/**/*.test.js`.'}]
-		},
-		{
-			code: code(true),
-			filename: toPath('lib/foo.test.js'),
-			options: [{files: ['bar/**/*.test.js']}],
-			errors: [{message: 'Test file is ignored because it is not in `bar/**/*.test.js`.'}]
-		},
-		{
-			code: code(true),
-			filename: toPath('lib/foo.test.js'),
-			options: [{files: ['bar/**/*.test.js']}],
-			errors: [{message: 'Test file is ignored because it is not in `bar/**/*.test.js`.'}]
-		}
-	]
-});
diff --git a/test/no-ignored-test-files-without-package.js b/test/no-ignored-test-files-without-package.js
deleted file mode 100644
index f03cba2..0000000
--- a/test/no-ignored-test-files-without-package.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import path from 'path';
-import test from 'ava';
-import avaRuleTester from 'eslint-ava-rule-tester';
-import rule from '../rules/no-ignored-test-files';
-
-const ruleTester = avaRuleTester(test, {
-	env: {
-		es6: true
-	}
-});
-
-const header = 'const test = require(\'ava\');\n';
-const rootDir = path.dirname(__dirname);
-const toPath = subPath => path.join(rootDir, subPath);
-const code = hasHeader => (hasHeader ? header : '') + 'test(t => { t.pass(); });';
-
-ruleTester.run('no-ignored-test-files', rule, {
-	valid: [
-		{
-			code: code(true),
-			filename: toPath('test/foo/bar.js')
-		},
-		{
-			code: code(true),
-			filename: toPath('test/foo/not-fixtures/bar.js')
-		},
-		{
-			code: code(true),
-			filename: toPath('test/foo/not-helpers/bar.js')
-		},
-		{
-			code: header + 'foo(t => {});',
-			filename: toPath('test/foo/fixtures/bar.js')
-		},
-		{
-			code: header + 'foo(t => {});',
-			filename: toPath('test/foo/helpers/bar.js')
-		},
-		{
-			code: code(false),
-			filename: toPath('test/foo/fixtures/bar.js')
-		},
-		{
-			code: code(false),
-			filename: toPath('test/foo/helpers/bar.js')
-		},
-		{
-			code: code(true),
-			filename: toPath('test.js')
-		},
-		{
-			code: code(true),
-			filename: toPath('test-foo.js')
-		},
-		{
-			code: code(true),
-			filename: toPath('lib/foo.test.js')
-		},
-		{
-			code: code(true),
-			filename: toPath('lib/foo.spec.js'),
-			options: [{files: ['lib/**/*.spec.js']}]
-		},
-		{
-			code: code(true),
-			filename: '<text>',
-			options: [{files: ['lib/**/*.spec.js']}]
-		}
-	],
-	invalid: [
-		{
-			code: code(true),
-			filename: toPath('test/foo/fixtures/bar.js'),
-			errors: [{message: 'Test file is ignored because it is in `**/fixtures/** **/helpers/**`.'}]
-		},
-		{
-			code: code(true),
-			filename: toPath('test/foo/helpers/bar.js'),
-			errors: [{message: 'Test file is ignored because it is in `**/fixtures/** **/helpers/**`.'}]
-		},
-		{
-			code: code(true),
-			filename: toPath('lib/foo.spec.js'),
-			errors: [{message: 'Test file is ignored because it is not in `test.js test-*.js test/**/*.js **/__tests__/**/*.js **/*.test.js`.'}]
-		},
-		{
-			code: code(true),
-			filename: toPath('test/foo/bar.js'),
-			options: [{files: ['lib/**/*.spec.js']}],
-			errors: [{message: 'Test file is ignored because it is not in `lib/**/*.spec.js`.'}]
-		},
-		{
-			code: code(true),
-			filename: toPath('lib/foo.not-test.js'),
-			options: [{files: ['lib/**/*.spec.js']}],
-			errors: [{message: 'Test file is ignored because it is not in `lib/**/*.spec.js`.'}]
-		}
-	]
-});
diff --git a/test/no-ignored-test-files.js b/test/no-ignored-test-files.js
new file mode 100644
index 0000000..14aa03a
--- /dev/null
+++ b/test/no-ignored-test-files.js
@@ -0,0 +1,63 @@
+import path from 'path';
+import test from 'ava';
+import avaRuleTester from 'eslint-ava-rule-tester';
+import util from '../util';
+import rule from '../rules/no-ignored-test-files';
+
+const ruleTester = avaRuleTester(test, {
+	env: {
+		es6: true
+	}
+});
+
+const header = 'const test = require(\'ava\');\n';
+const rootDir = path.dirname(__dirname);
+
+const toPath = subPath => path.join(rootDir, subPath);
+const code = hasHeader => (hasHeader ? header : '') + 'test(t => { t.pass(); });';
+
+util.loadAvaHelper = () => ({
+	classifyFile: file => {
+		switch (file) {
+			case toPath('lib/foo.test.js'):
+				return {isHelper: false, isSource: false, isTest: true};
+			case toPath('lib/foo/fixtures/bar.test.js'):
+				return {isHelper: false, isSource: true, isTest: false};
+			case toPath('lib/foo/helpers/bar.test.js'):
+				return {isHelper: true, isSource: false, isTest: false};
+			default:
+				return {isHelper: false, isSource: false, isTest: false};
+		}
+	}
+});
+
+ruleTester.run('no-ignored-test-files', rule, {
+	valid: [
+		{
+			code: code(true),
+			filename: toPath('lib/foo.test.js')
+		}
+	],
+	invalid: [
+		{
+			code: code(true),
+			filename: toPath('lib/foo/fixtures/bar.test.js'),
+			errors: [{message: 'AVA treats this as a source file.'}]
+		},
+		{
+			code: code(true),
+			filename: toPath('lib/foo/helpers/bar.test.js'),
+			errors: [{message: 'AVA treats this as a helper file.'}]
+		},
+		{
+			code: code(true),
+			filename: toPath('test.js'),
+			errors: [{message: 'AVA ignores this file.'}]
+		},
+		{
+			code: code(true),
+			filename: toPath('bar/foo.test.js'),
+			errors: [{message: 'AVA ignores this file.'}]
+		}
+	]
+});
diff --git a/test/no-import-test-files.js b/test/no-import-test-files.js
index 02b8728..8e23d60 100644
--- a/test/no-import-test-files.js
+++ b/test/no-import-test-files.js
@@ -16,16 +16,14 @@ const ruleTester = avaRuleTester(test, {
 const rootDir = path.dirname(__dirname);
 const toPath = subPath => path.join(rootDir, subPath);
 
-util.getAvaConfig = () => ({
-	files: [
-		'lib/*.test.*',
-		'test/**/*.js'
-	],
-	babel: {
-		extensions: [
-			'js',
-			'jsx'
-		]
+util.loadAvaHelper = () => ({
+	classifyImport: importPath => {
+		switch (importPath) {
+			case toPath('lib/foo.test.js'):
+				return {isHelper: false, isSource: false, isTest: true};
+			default:
+				return {isHelper: false, isSource: false, isTest: false};
+		}
 	}
 });
 
@@ -41,8 +39,6 @@ ruleTester.run('no-import-test-files', rule, {
 		'const test = require(\'ava\');',
 		'console.log()',
 		'const value = require(somePath);',
-		// Ok because not a valid extension
-		'import test from \'./foo.test.mjs\';',
 		'const highlight = require(\'highlight.js\')',
 		{
 			code: 'const highlight = require(\'highlight.js\')',
@@ -56,21 +52,6 @@ ruleTester.run('no-import-test-files', rule, {
 			code: 'const test = require(\'./foo.test.js\');',
 			filename: toPath('lib/foo.js'),
 			errors
-		},
-		{
-			code: 'import test from \'./foo.test.js\';',
-			filename: toPath('lib/foo.js'),
-			errors
-		},
-		{
-			code: 'import test from \'./bar.js\';',
-			filename: toPath('test/foo.js'),
-			errors
-		},
-		{
-			code: 'import test from \'./foo.test.jsx\';',
-			filename: toPath('lib/foo.js'),
-			errors
 		}
 	]
 });
diff --git a/test/test-title-format.js b/test/test-title-format.js
new file mode 100644
index 0000000..3b7fa96
--- /dev/null
+++ b/test/test-title-format.js
@@ -0,0 +1,59 @@
+import test from 'ava';
+import avaRuleTester from 'eslint-ava-rule-tester';
+import rule from '../rules/test-title-format';
+
+const ruleTester = avaRuleTester(test, {
+	env: {
+		es6: true
+	}
+});
+
+const errors = [{ruleId: 'test-title-format'}];
+const header = 'const test = require(\'ava\');\n';
+
+ruleTester.run('test-title-format', rule, {
+	valid: [
+		header + 'test("Foo", t => { t.pass(); });',
+		{
+			code: header + 'test("Foo", t => { t.pass(); });',
+			options: [{format: '.'}]
+		},
+		{
+			code: header + 'test("Should pass tests.", t => { t.pass(); });',
+			options: [{format: '^Should .+\\.$'}]
+		},
+		{
+			code: header + 'test.todo("Should pass tests.");',
+			options: [{format: '^Should .+\\.$'}]
+		},
+		{
+			code: header + 'test(t => { t.pass(); });',
+			options: [{format: '^Should'}]
+		},
+		{
+			code: header + 'notTest("Foo", t => { t.pass(); });',
+			options: [{format: '^Should'}]
+		},
+		{
+			code: header + 'test(macro, t => { t.pass(); });',
+			options: [{format: '^Should'}]
+		},
+		// Shouldn't be triggered since it's not a test file
+		{
+			code: 'test("Test", t => { t.pass(); });',
+			options: [{format: '^Should'}]
+		}
+	],
+	invalid: [
+		{
+			code: header + 'test("Test something", t => { t.pass(); });',
+			options: [{format: '^Should'}],
+			errors
+		},
+		{
+			code: header + 'test.todo("Test something");',
+			options: [{format: '^Should'}],
+			errors
+		}
+	]
+});
diff --git a/test/util.js b/test/util.js
index c1f617e..9ddf619 100644
--- a/test/util.js
+++ b/test/util.js
@@ -1,10 +1,7 @@
-import path from 'path';
 import test from 'ava';
 import util from '../util';
 import packageJson from '../package';
 
-const buildPackageFilepath = fixture => path.join(__dirname, 'fixtures', 'load-config', fixture, 'package.json');
-
 test('returns the URL of the a named rule\'s documentation', t => {
 	const url = `https://github.com/avajs/eslint-plugin-ava/blob/v${packageJson.version}/docs/rules/foo.md`;
 	t.is(util.getDocsUrl('foo.js'), url);
@@ -19,46 +16,3 @@ test('determines the rule name from the file', t => {
 	const url = `https://github.com/avajs/eslint-plugin-ava/blob/v${packageJson.version}/docs/rules/util.md`;
 	t.is(util.getDocsUrl(__filename), url);
 });
-
-test('finds config in package.json', t => {
-	const config = util.getAvaConfig(buildPackageFilepath('package-only'));
-	t.is(config.failFast, true);
-});
-
-test('returns an empty object if both configs are present', t => {
-	t.deepEqual(util.getAvaConfig(buildPackageFilepath('package-yes-file-yes')), {});
-});
-
-test('loads config from file with `export default` syntax', t => {
-	const config = util.getAvaConfig(buildPackageFilepath('package-no-file-yes'));
-	t.is(config.files, 'config-file-esm-test-value');
-});
-
-test('loads config from factory function', t => {
-	const config = util.getAvaConfig(buildPackageFilepath('package-no-file-yes-factory'));
-	t.true(config.files.startsWith(__dirname));
-});
-
-test('returns an empty object if a config factory returns a promise', t => {
-	t.deepEqual(util.getAvaConfig(buildPackageFilepath('factory-no-promise-return')), {});
-});
-
-test('returns an empty object if a config exports a promise', t => {
-	t.deepEqual(util.getAvaConfig(buildPackageFilepath('no-promise-config')), {});
-});
-
-test('returns an empty object if a config factory does not return a plain object', t => {
-	t.deepEqual(util.getAvaConfig(buildPackageFilepath('factory-no-plain-return')), {});
-});
-
-test('returns an empty object if a config does not export a plain object', t => {
-	t.deepEqual(util.getAvaConfig(buildPackageFilepath('no-plain-config')), {});
-});
-
-test('returns an empty object if the ava.config.js file throws', t => {
-	t.deepEqual(util.getAvaConfig(buildPackageFilepath('throws')), {});
-});
-
-test('returns an empty object if a config file has no default export', t => {
-	t.deepEqual(util.getAvaConfig(buildPackageFilepath('no-default-export')), {});
-});