npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@crescware/eslint-plugin-crescware-single-behavior-per-test

v0.0.1

Published

An ESLint-compatible rule, run through [oxlint](https://oxc.rs/docs/guide/usage/linter)'s `jsPlugins`, that forbids writing more than one top-level `expect()` in a single `test()` / `it()` — and, instead of merely banning it, tells you exactly how to fix

Downloads

248

Readme

@crescware/eslint-plugin-crescware-single-behavior-per-test

An ESLint-compatible rule, run through oxlint's jsPlugins, that forbids writing more than one top-level expect() in a single test() / it() — and, instead of merely banning it, tells you exactly how to fix it.

Why

A test should verify a single behavior, but agents and humans alike pile several expects into one test. Plain prose (CLAUDE.md, review comments) does not stop this because prose has no binding force — it can be ignored. A lint rule does: it fails the edit loop and the build.

But a bare ban (e.g. max-expects: 1) is weak. If it only says "no" without showing the way out, the reader optimizes for the cheapest way to clear the error — commenting out one expect. A ban with no exit is not a norm. This rule carries the exit in the error message itself. The message is written as a prompt for the reader (today, usually a coding agent): it names the concrete next edit, and for the one genuinely undecidable case it asks rather than commands.

The ban is the floor; the guidance rides on top of it. The severity is always error.

Install

pnpm add -D @crescware/eslint-plugin-crescware-single-behavior-per-test

Usage

Register the plugin in your .oxlintrc.json and enable the rule:

{
  "jsPlugins": ["@crescware/eslint-plugin-crescware-single-behavior-per-test"],
  "rules": {
    "crescware-single-behavior-per-test/single-behavior-per-test": "error"
  }
}

What it reports

The rule counts the direct expect() assertions in a test / it callback — the ones written as statements directly in the callback body. When there are two or more, it classifies the test by what varies between the assertions and emits one of these verdicts. Each message is either an assertion (the fix is determined by the syntax) or a question (the syntax cannot decide).

consolidate (assertion)

Several fields of the same object are asserted separately. Compare the object once.

// reported
test("compute result", () => {
  const r = compute();
  expect(r.status).toBe("ok");
  expect(r.code).toBe(200);
});

// fix: one exhaustive toEqual (list every field; no partial-match matcher)
test("compute result", () => {
  expect(compute()).toEqual({ status: "ok", code: 200 });
});

split-by-act (assertion)

A state change (assignment, mutation, await, or a call on the value under test) sits between assertions. The before- and after-states are two behaviors.

// reported
test("counter", () => {
  const c = new Counter();
  expect(c.value).toBe(0);
  c.increment();
  expect(c.value).toBe(1);
});

// fix: split at the Act into two tests, each with its own Arrange/Act

split-by-heterogeneity (assertion)

The matchers or the asserted shapes differ — the test checks more than one contract.

// reported (a value contract and an error contract)
test("parse", () => {
  expect(parse("x")).toEqual({ ok: true });
  expect(parseThrows).toThrow();
});

// fix: one test() per contract

each-or-split (question)

The same operation is called with only the inputs changing. Syntax cannot tell whether these are the same claim over different data (→ test.each) or different contracts (→ split), so the rule asks and hands you the criterion: restate each case as one sentence — same sentence with different values means test.each, a different claim means split.

// reported — you decide test.each vs split
test("add", () => {
  expect(add(1, 2)).toBe(3);
  expect(add(3, 4)).toBe(7);
});

loop-each (assertion)

Assertions run inside a loop, an iteration callback (forEach / map / …), or a repeated call to a local assertion helper — a hand-rolled parametrized test. A loop applies an identical body per item, so this is unambiguously test.each (and test.each reports which case failed, where a loop stops at the first).

// reported
test("all positive", () => {
  for (const x of items) {
    expect(x).toBeGreaterThan(0);
  }
});

// fix
test.each(items)("%s is positive", (x) => {
  expect(x).toBeGreaterThan(0);
});

generic (question)

When the exit cannot be named — a computed matcher, an unusual receiver, different objects, an exact duplicate, mismatched call arguments — the ban still holds and the message hands you the four-branch self-diagnosis checklist so you can route the fix yourself.

Scope

  • Only direct assertions are classified. Assertions in a loop / iteration callback / repeated local helper are routed to loop-each; assertions behind a cross-file or imported helper are a static-analysis blind spot and are not counted.
  • Modifier and async chains are understood: expect(x).not.toBe(...), expect(x).resolves.toBe(...), await expect(x).resolves.toBe(...), expect.soft(x).toBe(...).
  • test, it, it.only, and test.each(table)(...) callbacks are recognized.
  • The rule never autofixes — a lazy set of partial assertions cannot be mechanically rebuilt into an exhaustive toEqual — it reports only.

Companion: no-restricted-matchers

The consolidate fix asks for an exhaustive toEqual and forbids partial-match matchers (toMatchObject etc.), because a field you omit goes unchecked. This rule states that in prose but does not enforce it; pair it with your test framework's no-restricted-matchers to give that prohibition real teeth. The two cooperate loosely and toggle independently.

Stack

  • Runtime: Node.js 24 (via mise)
  • Package manager: pnpm (via corepack)
  • Language: TypeScript (native preview)
  • Test: Vitest (fixture integration tests that run oxlint over fixtures/cases)
  • Lint: oxlint (the repo dogfoods this very rule)
  • Format: oxfmt
  • Unused code: Knip

Setup

mise install
corepack enable
pnpm install

Scripts

| Command | Description | | ------------------ | ---------------------------------------- | | pnpm build | Compile src to dist | | pnpm check | Run all checks (types, lint, knip, test) | | pnpm check:types | Type check | | pnpm check:lint | Lint and format check | | pnpm check:knip | Unused files/exports check | | pnpm test | Run fixture integration tests | | pnpm format | Fix lint and format |