Test lint rules

This guide covers the lint rules that ship with Flow. You will learn how to:

  • Enable the rules in your ESLint config
  • Understand what each rule enforces and how to fix a finding
  • Turn off a rule you do not want

Overview

Flow ships a set of lint rules that keep your test suite in the shape the testing harness docs and the design tests skill describe. Spec files contain tests and nothing else, shared state does not leak between tests, types live in one place, and browser locators match exactly.

Written guidance alone does not hold these conventions. An agent under pressure to get a test green will export a helper from a spec file or hoist a let to the top of a group, and the test still passes. The lint rules turn each convention into an error that shows up in your editor, in npm run lint, and in the build skill's lint gate, which must pass before build moves on.

The rules are written in ast-grep YAML and run inside ESLint through @adonisjs/eslint-ast-grep. They sit next to your other ESLint rules, so editor integration, disable comments, and severity overrides all work as usual.

Enabling the rules

The rules are published inside the Flow package at @adonisplus/flow/rules. You enable them in two steps, and flow:install does not make either change for you.

First, install the ESLint integration as a development dependency.

npm i -D @adonisjs/eslint-ast-grep

Then, pass the Flow rules directory to astGrep in your ESLint config. The function loads every rule in the directory and returns ESLint configs that you spread into configApp.

eslint.config.js
import { configApp } from '@adonisjs/eslint-config'
import { astGrep } from '@adonisjs/eslint-ast-grep'

export default configApp() 
export default configApp(...astGrep('@adonisplus/flow/rules')) 

Run ESLint to see the findings in your existing test suite.

npx eslint tests

Each rule is registered as ast-grep/<rule-id>, and every finding includes a note explaining the fix. Upgrading Flow updates the rules in place, so a new rule reaches your project with the next Flow release.

Note

The rules only check files under tests/, resolved from the directory that holds your eslint.config.js. In a monorepo, add the config to the AdonisJS app, not to the workspace root.

The rules

test-files-contain-tests

A spec file describes tests. Reusable code in a spec file gets imported by other spec files, which couples tests together and hides helpers where nobody looks for them.

The rule reports any export, any named function or class outside a test callback, and any named type or interface in files under tests/. It ignores tests/helpers/ and tests/bootstrap.ts. Imports, const values, and functions and inline types declared inside a single test remain allowed.

tests/functional/posts/store.spec.ts
import { test } from '@japa/runner'
import { createUser } from '#tests/helpers/users'
import type { PostPayload } from '#tests/helpers/types'

type PostPayload = { title: string } 

export function createUser() { 
  return UserFactory.create() 
} 

test('creates a post', async ({ client }) => {
  const user = await createUser()
  const payload: PostPayload = { title: 'Hello' }
  const response = await client.post('/posts').loginAs(user).json(payload)
  response.assertStatus(201)
})

To fix a finding, move the reusable function to a module under tests/helpers/ and the named type to tests/helpers/types.ts, then import them.

test-helpers-contain-runtime-code

Helper modules hold runtime code, and every shared test type lives in tests/helpers/types.ts. Keeping types in one file means a helper never has to import another helper just for its types.

The rule reports top-level type aliases and interfaces in tests/helpers/, except in types.ts. Inline annotations and types declared inside a function remain allowed.

tests/helpers/users.ts
import { UserFactory } from '#database/factories/user_factory'
import type { UserAttributes } from '#tests/helpers/types'

export interface UserAttributes { 
  email: string
} 

export function createUser(attributes: Partial<UserAttributes> = {}) {
  return UserFactory.merge(attributes).create()
}

no-shared-mutable-test-state

A let or var shared between tests carries state from one test into the next. The suite then passes or fails depending on the order the tests run in, and a single test cannot run on its own.

The rule reports let and var declarations in files under tests/ unless they sit inside a test callback. That covers the file's top level, test.group callbacks, and group hooks. It recognizes callbacks passed as test(title, callback), test(title).run(callback), and test(title).with(data).run(callback).

tests/functional/sessions/store.spec.ts
import { test } from '@japa/runner'
import { createUser } from '#tests/helpers/users'

test.group('Sessions store', (group) => {
  let user: User

  group.each.setup(async () => { 
    user = await createUser() 
  }) 

  test('signs in with valid credentials', async ({ client }) => {
    const user = await createUser() 
    const response = await client.post('/login').form({ email: user.email, password: 'secret' })
    response.assertRedirectsTo('/dashboard')
  })
})

Declare the state inside the test that uses it. Use const for a module-level binding that never changes.

playwright-locators-are-exact

Playwright matches locator text as a case-insensitive substring unless you set exact. A locator for 'Sign in' also matches 'Sign in with a passkey', so a test that passes today breaks, or silently clicks the wrong element, once the page gains similar text.

The rule reports getByLabel, getByText, getByPlaceholder, getByTitle, and getByAltText calls, and getByRole calls that filter by name, when they do not set exact. Regular expressions are exempt, because exact does not apply to them. Options passed as a variable or a spread are allowed, since the rule cannot inspect them. Testing Library calls on screen or within(...) are skipped, since that library already matches exactly by default.

tests/browser/sessions/create.spec.ts
import { test } from '@japa/runner'

test('signs in with valid credentials', async ({ visit }) => {
  const page = await visit('/login')

  await page.getByLabel('Email').fill('virk@adonisjs.com') 
  await page.getByLabel('Email', { exact: true }).fill('virk@adonisjs.com') 
  await page.getByRole('button', { name: 'Sign in' }).click() 
  await page.getByRole('button', { name: 'Sign in', exact: true }).click() 
})

Pass { exact: false } when a partial match is what you intend. The option states the choice explicitly either way.

Turning off a rule

Each rule is an ordinary ESLint rule, so you can turn it off for the whole project by passing an extra config object to configApp.

eslint.config.js
import { configApp } from '@adonisjs/eslint-config'
import { astGrep } from '@adonisjs/eslint-ast-grep'

export default configApp(...astGrep('@adonisplus/flow/rules'), {
  rules: {
    'ast-grep/playwright-locators-are-exact': 'off',
  },
})

For a single line, use a standard disable comment such as // eslint-disable-next-line ast-grep/no-shared-mutable-test-state.

Next steps

  • Read Design tests for the skill that decides which tests to write.
  • Read Build for the lint gate that enforces these rules during implementation.
Terms & License Agreement