diff --git a/README.md b/README.md
index 2be88aa20..7687b1cb3 100644
--- a/README.md
+++ b/README.md
@@ -363,6 +363,7 @@ Manually fixable by
| [prefer-called-with](docs/rules/prefer-called-with.md) | Suggest using `toBeCalledWith()` or `toHaveBeenCalledWith()` | | | | |
| [prefer-comparison-matcher](docs/rules/prefer-comparison-matcher.md) | Suggest using the built-in comparison matchers | | | 🔧 | |
| [prefer-each](docs/rules/prefer-each.md) | Prefer using `.each` rather than manual loops | | | | |
+| [prefer-ending-with-an-expect](docs/rules/prefer-ending-with-an-expect.md) | Prefer having the last statement in a test be an assertion | | | | |
| [prefer-equality-matcher](docs/rules/prefer-equality-matcher.md) | Suggest using the built-in equality matchers | | | | 💡 |
| [prefer-expect-assertions](docs/rules/prefer-expect-assertions.md) | Suggest using `expect.assertions()` OR `expect.hasAssertions()` | | | | 💡 |
| [prefer-expect-resolves](docs/rules/prefer-expect-resolves.md) | Prefer `await expect(...).resolves` over `expect(await ...)` syntax | | | 🔧 | |
diff --git a/docs/rules/prefer-ending-with-an-expect.md b/docs/rules/prefer-ending-with-an-expect.md
new file mode 100644
index 000000000..7c7b6cd81
--- /dev/null
+++ b/docs/rules/prefer-ending-with-an-expect.md
@@ -0,0 +1,168 @@
+# Prefer having the last statement in a test be an assertion (`prefer-ending-with-an-expect`)
+
+
+
+Prefer ending tests with an `expect` assertion.
+
+## Rule details
+
+This rule triggers when a test body does not end with an `expect` call, which
+can indicate an unfinished test.
+
+Examples of **incorrect** code for this rule:
+
+```js
+it('lets me change the selected option', () => {
+ const container = render(MySelect, {
+ props: { options: [1, 2, 3], selected: 1 },
+ });
+
+ expect(container).toBeDefined();
+ expect(container.toHTML()).toContain('');
+
+ container.setProp('selected', 2);
+});
+```
+
+Examples of **correct** code for this rule:
+
+```js
+it('lets me change the selected option', () => {
+ const container = render(MySelect, {
+ props: { options: [1, 2, 3], selected: 1 },
+ });
+
+ expect(container).toBeDefined();
+ expect(container.toHTML()).toContain(' ');
+
+ container.setProp('selected', 2);
+
+ expect(container.toHTML()).not.toContain(' ');
+ expect(container.toHTML()).toContain(' ');
+});
+```
+
+## Options
+
+```json
+{
+ "jest/prefer-ending-with-an-expect": [
+ "error",
+ {
+ "assertFunctionNames": ["expect"],
+ "additionalTestBlockFunctions": []
+ }
+ ]
+}
+```
+
+### `assertFunctionNames`
+
+This array option specifies the names of functions that should be considered to
+be asserting functions. Function names can use wildcards i.e `request.*.expect`,
+`request.**.expect`, `request.*.expect*`
+
+Examples of **incorrect** code for the `{ "assertFunctionNames": ["expect"] }`
+option:
+
+```js
+/* eslint jest/prefer-ending-with-an-expect: ["error", { "assertFunctionNames": ["expect"] }] */
+
+import { expectSaga } from 'redux-saga-test-plan';
+import { addSaga } from '../src/sagas';
+
+test('returns sum', () => {
+ expectSaga(addSaga, 1, 1).returns(2).run();
+});
+```
+
+Examples of **correct** code for the
+`{ "assertFunctionNames": ["expect", "expectSaga"] }` option:
+
+```js
+/* eslint jest/prefer-ending-with-an-expect: ["error", { "assertFunctionNames": ["expect", "expectSaga"] }] */
+
+import { expectSaga } from 'redux-saga-test-plan';
+import { addSaga } from '../src/sagas';
+
+test('returns sum', () => {
+ expectSaga(addSaga, 1, 1).returns(2).run();
+});
+```
+
+Since the string is compiled into a regular expression, you'll need to escape
+special characters such as `$` with a double backslash:
+
+```js
+/* eslint jest/prefer-ending-with-an-expect: ["error", { "assertFunctionNames": ["expect\\$"] }] */
+
+it('is money-like', () => {
+ expect$(1.0);
+});
+```
+
+Examples of **correct** code for working with the HTTP assertions library
+[SuperTest](https://www.npmjs.com/package/supertest) with the
+`{ "assertFunctionNames": ["expect", "request.**.expect"] }` option:
+
+```js
+/* eslint jest/prefer-ending-with-an-expect: ["error", { "assertFunctionNames": ["expect", "request.**.expect"] }] */
+const request = require('supertest');
+const express = require('express');
+
+const app = express();
+
+describe('GET /user', function () {
+ it('responds with json', function (done) {
+ doSomething();
+
+ request(app).get('/user').expect('Content-Type', /json/).expect(200, done);
+ });
+});
+```
+
+### `additionalTestBlockFunctions`
+
+This array can be used to specify the names of functions that should also be
+treated as test blocks:
+
+```json
+{
+ "rules": {
+ "jest/prefer-ending-with-an-expect": [
+ "error",
+ { "additionalTestBlockFunctions": ["each.test"] }
+ ]
+ }
+}
+```
+
+The following is _correct_ when using the above configuration:
+
+```js
+each([
+ [2, 3],
+ [1, 3],
+]).test(
+ 'the selection can change from %d to %d',
+ (firstSelection, secondSelection) => {
+ const container = render(MySelect, {
+ props: { options: [1, 2, 3], selected: firstSelection },
+ });
+
+ expect(container).toBeDefined();
+ expect(container.toHTML()).toContain(
+ ` `,
+ );
+
+ container.setProp('selected', secondSelection);
+
+ expect(container.toHTML()).not.toContain(
+ ` `,
+ );
+ expect(container.toHTML()).toContain(
+ ` `,
+ );
+ },
+);
+```
diff --git a/src/__tests__/__snapshots__/rules.test.ts.snap b/src/__tests__/__snapshots__/rules.test.ts.snap
index d27e91fcb..06883704c 100644
--- a/src/__tests__/__snapshots__/rules.test.ts.snap
+++ b/src/__tests__/__snapshots__/rules.test.ts.snap
@@ -48,6 +48,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
"jest/prefer-called-with": "error",
"jest/prefer-comparison-matcher": "error",
"jest/prefer-each": "error",
+ "jest/prefer-ending-with-an-expect": "error",
"jest/prefer-equality-matcher": "error",
"jest/prefer-expect-assertions": "error",
"jest/prefer-expect-resolves": "error",
@@ -139,6 +140,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
"jest/prefer-called-with": "error",
"jest/prefer-comparison-matcher": "error",
"jest/prefer-each": "error",
+ "jest/prefer-ending-with-an-expect": "error",
"jest/prefer-equality-matcher": "error",
"jest/prefer-expect-assertions": "error",
"jest/prefer-expect-resolves": "error",
diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts
index 36a1f0407..857f65a41 100644
--- a/src/__tests__/rules.test.ts
+++ b/src/__tests__/rules.test.ts
@@ -2,7 +2,7 @@ import { existsSync } from 'fs';
import { resolve } from 'path';
import plugin from '../';
-const numberOfRules = 62;
+const numberOfRules = 63;
const ruleNames = Object.keys(plugin.rules);
const deprecatedRules = Object.entries(plugin.rules)
.filter(([, rule]) => rule.meta.deprecated)
diff --git a/src/rules/__tests__/prefer-ending-with-an-expect.test.ts b/src/rules/__tests__/prefer-ending-with-an-expect.test.ts
new file mode 100644
index 000000000..fe9bd3a65
--- /dev/null
+++ b/src/rules/__tests__/prefer-ending-with-an-expect.test.ts
@@ -0,0 +1,459 @@
+import { AST_NODE_TYPES } from '@typescript-eslint/utils';
+import dedent from 'dedent';
+import rule from '../prefer-ending-with-an-expect';
+import { FlatCompatRuleTester as RuleTester, espreeParser } from './test-utils';
+
+const ruleTester = new RuleTester({
+ parser: espreeParser,
+ parserOptions: {
+ ecmaVersion: 2015,
+ },
+});
+
+ruleTester.run('prefer-ending-with-an-expect', rule, {
+ valid: [
+ 'it.todo("will test something eventually")',
+ 'test.todo("will test something eventually")',
+ "['x']();",
+ 'it("is weird", "because this should be a function")',
+ 'it("is weird", "because this should be a function", () => {})',
+ 'it("should pass", () => expect(true).toBeDefined())',
+ 'test("should pass", () => expect(true).toBeDefined())',
+ 'it("should pass", myTest); function myTest() { expect(true).toBeDefined() }',
+ {
+ code: dedent`
+ test('should pass', () => {
+ expect(true).toBeDefined();
+ foo(true).toBe(true);
+ });
+ `,
+ options: [{ assertFunctionNames: ['expect', 'foo'] }],
+ },
+ {
+ code: 'it("should return undefined",() => expectSaga(mySaga).returns());',
+ options: [{ assertFunctionNames: ['expectSaga'] }],
+ },
+ {
+ code: "test('verifies expect method call', () => expect$(123));",
+ options: [{ assertFunctionNames: ['expect\\$'] }],
+ },
+ {
+ code: "test('verifies expect method call', () => new Foo().expect(123));",
+ options: [{ assertFunctionNames: ['Foo.expect'] }],
+ },
+ {
+ code: dedent`
+ test('verifies deep expect method call', () => {
+ tester.foo().expect(123);
+ });
+ `,
+ options: [{ assertFunctionNames: ['tester.foo.expect'] }],
+ },
+ {
+ code: dedent`
+ test('verifies chained expect method call', () => {
+ doSomething();
+
+ tester
+ .foo()
+ .bar()
+ .expect(456);
+ });
+ `,
+ options: [{ assertFunctionNames: ['tester.foo.bar.expect'] }],
+ },
+ {
+ code: dedent`
+ test("verifies the function call", () => {
+ td.verify(someFunctionCall())
+ })
+ `,
+ options: [{ assertFunctionNames: ['td.verify'] }],
+ },
+ {
+ code: 'it("should pass", () => expect(true).toBeDefined())',
+ options: [
+ {
+ assertFunctionNames: undefined,
+ additionalTestBlockFunctions: undefined,
+ },
+ ],
+ },
+ 'it("should pass", () => { expect(true).toBeDefined() })',
+ 'it("should pass", function () { expect(true).toBeDefined() })',
+ dedent`
+ it('is a complete test', () => {
+ const container = render(Greeter);
+
+ expect(container).toBeDefined();
+
+ container.setProp('name', 'Bob');
+
+ expect(container.toHTML()).toContain('Hello Bob!');
+ });
+ `,
+ {
+ code: dedent`
+ describe('GET /user', function () {
+ it('responds with json', function (done) {
+ doSomething();
+ request(app).get('/user').expect('Content-Type', /json/).expect(200, done);
+ });
+ });
+ `,
+ options: [{ assertFunctionNames: ['expect', 'request.**.expect'] }],
+ },
+ {
+ code: dedent`
+ each([
+ [2, 3],
+ [1, 3],
+ ]).test(
+ 'the selection can change from %d to %d',
+ (firstSelection, secondSelection) => {
+ const container = render(MySelect, {
+ props: { options: [1, 2, 3], selected: firstSelection },
+ });
+
+ expect(container).toBeDefined();
+ expect(container.toHTML()).toContain(
+ \` \`
+ );
+
+ container.setProp('selected', secondSelection);
+
+ expect(container.toHTML()).not.toContain(
+ \` \`
+ );
+ expect(container.toHTML()).toContain(
+ \` \`
+ );
+ }
+ );
+ `,
+ options: [{ additionalTestBlockFunctions: ['each.test'] }],
+ },
+ ],
+ invalid: [
+ {
+ code: 'it("should fail", () => {});',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'test("should fail", () => {});',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'test.skip("should fail", () => {});',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.MemberExpression,
+ },
+ ],
+ },
+ {
+ code: 'it("should fail", () => { somePromise.then(() => {}); });',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'test("should fail", () => { foo(true).toBe(true); })',
+ options: [{ assertFunctionNames: ['expect'] }],
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'it("should also fail",() => expectSaga(mySaga).returns());',
+ options: [{ assertFunctionNames: ['expect'] }],
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'it("should pass", () => somePromise().then(() => expect(true).toBeDefined()))',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'it("should pass", () => render(Greeter))',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'it("should pass", () => { render(Greeter) })',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'it("should pass", function () { render(Greeter) })',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'it("should not pass", () => class {})',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'it("should not pass", () => ([]))',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'it("should not pass", () => { const x = []; })',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: 'it("should not pass", function () { class Mx {} })',
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: dedent`
+ it('is a complete test', () => {
+ const container = render(Greeter);
+
+ expect(container).toBeDefined();
+
+ container.setProp('name', 'Bob');
+ });
+ `,
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ ],
+});
+
+ruleTester.run('wildcards', rule, {
+ valid: [
+ {
+ code: "test('should pass *', () => expect404ToBeLoaded());",
+ options: [{ assertFunctionNames: ['expect*'] }],
+ },
+ {
+ code: "test('should pass *', () => expect.toHaveStatus404());",
+ options: [{ assertFunctionNames: ['expect.**'] }],
+ },
+ {
+ code: "test('should pass', () => tester.foo().expect(123));",
+ options: [{ assertFunctionNames: ['tester.*.expect'] }],
+ },
+ {
+ code: "test('should pass **', () => tester.foo().expect(123));",
+ options: [{ assertFunctionNames: ['**'] }],
+ },
+ {
+ code: "test('should pass *', () => tester.foo().expect(123));",
+ options: [{ assertFunctionNames: ['*'] }],
+ },
+ {
+ code: "test('should pass', () => tester.foo().expect(123));",
+ options: [{ assertFunctionNames: ['tester.**'] }],
+ },
+ {
+ code: "test('should pass', () => tester.foo().expect(123));",
+ options: [{ assertFunctionNames: ['tester.*'] }],
+ },
+ {
+ code: "test('should pass', () => tester.foo().bar().expectIt(456));",
+ options: [{ assertFunctionNames: ['tester.**.expect*'] }],
+ },
+ {
+ code: "test('should pass', () => request.get().foo().expect(456));",
+ options: [{ assertFunctionNames: ['request.**.expect'] }],
+ },
+ {
+ code: "test('should pass', () => request.get().foo().expect(456));",
+ options: [{ assertFunctionNames: ['request.**.e*e*t'] }],
+ },
+ ],
+ invalid: [
+ {
+ code: "test('should fail', () => request.get().foo().expect(456));",
+ options: [{ assertFunctionNames: ['request.*.expect'] }],
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: "test('should fail', () => request.get().foo().bar().expect(456));",
+ options: [{ assertFunctionNames: ['request.foo**.expect'] }],
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: "test('should fail', () => tester.request(123));",
+ options: [{ assertFunctionNames: ['request.*'] }],
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: "test('should fail', () => request(123));",
+ options: [{ assertFunctionNames: ['request.*'] }],
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: "test('should fail', () => request(123));",
+ options: [{ assertFunctionNames: ['request.**'] }],
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ ],
+});
+
+ruleTester.run('aliases', rule, {
+ valid: [
+ {
+ code: dedent`
+ import { test } from '@jest/globals';
+
+ test('should pass', () => {
+ expect(true).toBeDefined();
+ foo(true).toBe(true);
+ });
+ `,
+ options: [{ assertFunctionNames: ['expect', 'foo'] }],
+ parserOptions: { sourceType: 'module' },
+ },
+ {
+ code: dedent`
+ import { test as checkThat } from '@jest/globals';
+
+ checkThat('this passes', () => {
+ expect(true).toBeDefined();
+ foo(true).toBe(true);
+ });
+ `,
+ options: [{ assertFunctionNames: ['expect', 'foo'] }],
+ parserOptions: { sourceType: 'module' },
+ },
+ {
+ code: dedent`
+ const { test } = require('@jest/globals');
+
+ test('verifies chained expect method call', () => {
+ tester
+ .foo()
+ .bar()
+ .expect(456);
+ });
+ `,
+ options: [{ assertFunctionNames: ['tester.foo.bar.expect'] }],
+ parserOptions: { sourceType: 'module' },
+ },
+ ],
+ invalid: [
+ {
+ code: dedent`
+ import { test as checkThat } from '@jest/globals';
+
+ checkThat('this passes', () => {
+ // ...
+ });
+ `,
+ options: [{ assertFunctionNames: ['expect', 'foo'] }],
+ parserOptions: { sourceType: 'module' },
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.Identifier,
+ },
+ ],
+ },
+ {
+ code: dedent`
+ import { test as checkThat } from '@jest/globals';
+
+ checkThat.skip('this passes', () => {
+ // ...
+ });
+ `,
+ parserOptions: { sourceType: 'module' },
+ errors: [
+ {
+ messageId: 'mustEndWithExpect',
+ type: AST_NODE_TYPES.MemberExpression,
+ },
+ ],
+ },
+ ],
+});
diff --git a/src/rules/prefer-ending-with-an-expect.ts b/src/rules/prefer-ending-with-an-expect.ts
new file mode 100644
index 000000000..e0661f29a
--- /dev/null
+++ b/src/rules/prefer-ending-with-an-expect.ts
@@ -0,0 +1,141 @@
+/*
+ * This implementation is adapted from eslint-plugin-jasmine.
+ * MIT license, Remco Haszing.
+ */
+
+import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
+import {
+ type FunctionExpression,
+ createRule,
+ getNodeName,
+ isFunction,
+ isTypeOfJestFnCall,
+} from './utils';
+
+/**
+ * Checks if node names returned by getNodeName matches any of the given star patterns
+ * Pattern examples:
+ * request.*.expect
+ * request.**.expect
+ * request.**.expect*
+ */
+function matchesAssertFunctionName(
+ nodeName: string,
+ patterns: readonly string[],
+): boolean {
+ return patterns.some(p =>
+ new RegExp(
+ `^${p
+ .split('.')
+ .map(x => {
+ if (x === '**') {
+ return '[a-z\\d\\.]*';
+ }
+
+ return x.replace(/\*/gu, '[a-z\\d]*');
+ })
+ .join('\\.')}(\\.|$)`,
+ 'ui',
+ ).test(nodeName),
+ );
+}
+
+export default createRule<
+ [
+ Partial<{
+ assertFunctionNames: readonly string[];
+ additionalTestBlockFunctions: readonly string[];
+ }>,
+ ],
+ 'mustEndWithExpect'
+>({
+ name: __filename,
+ meta: {
+ docs: {
+ description: 'Prefer having the last statement in a test be an assertion',
+ },
+ messages: {
+ mustEndWithExpect: 'Tests should end with an assertion',
+ },
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ assertFunctionNames: {
+ type: 'array',
+ items: { type: 'string' },
+ },
+ additionalTestBlockFunctions: {
+ type: 'array',
+ items: { type: 'string' },
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ type: 'suggestion',
+ },
+ defaultOptions: [
+ {
+ assertFunctionNames: ['expect'],
+ additionalTestBlockFunctions: [],
+ },
+ ],
+ create(
+ context,
+ [{ assertFunctionNames = ['expect'], additionalTestBlockFunctions = [] }],
+ ) {
+ function getLastStatement(fn: FunctionExpression): TSESTree.Node | null {
+ if (fn.body.type === AST_NODE_TYPES.BlockStatement) {
+ if (fn.body.body.length === 0) {
+ return null;
+ }
+
+ const lastStatement = fn.body.body[fn.body.body.length - 1];
+
+ if (lastStatement.type === AST_NODE_TYPES.ExpressionStatement) {
+ return lastStatement.expression;
+ }
+
+ return lastStatement;
+ }
+
+ return fn.body;
+ }
+
+ return {
+ CallExpression(node) {
+ const name = getNodeName(node.callee) ?? '';
+
+ if (
+ !isTypeOfJestFnCall(node, context, ['test']) &&
+ !additionalTestBlockFunctions.includes(name)
+ ) {
+ return;
+ }
+
+ if (node.arguments.length < 2 || !isFunction(node.arguments[1])) {
+ return;
+ }
+
+ const lastStatement = getLastStatement(node.arguments[1]);
+
+ if (
+ lastStatement?.type === AST_NODE_TYPES.CallExpression &&
+ (isTypeOfJestFnCall(lastStatement, context, ['expect']) ||
+ matchesAssertFunctionName(
+ getNodeName(lastStatement.callee)!,
+ assertFunctionNames,
+ ))
+ ) {
+ return;
+ }
+
+ context.report({
+ messageId: 'mustEndWithExpect',
+ node: node.callee,
+ });
+ },
+ };
+ },
+});