Why Jest and Vitest Need Test-Quality Rules
Jest and Vitest are the dominant JavaScript/TypeScript test runners — and AI-generated tests using them are consistently low quality. The AI produces tests that: assert implementation details (checking internal state instead of behavior), over-mock (replacing every dependency with a mock so nothing real is tested), abuse snapshots (toMatchSnapshot on everything — passes today, meaningless tomorrow), and lack meaningful assertions (testing that a function was called, not that it produced the correct result).
Bad tests are worse than no tests: they give false confidence, break on every refactor, and consume CI time without catching bugs. Good tests verify behavior (what the user sees or what the API returns), mock only external boundaries, and use assertions that describe expected outcomes — not implementation details.
These rules apply to both Jest and Vitest (APIs are 95% compatible). Specify which your project uses — the configuration differs, but the test-writing patterns are identical.
Rule 1: Test Behavior, Not Implementation
The rule: 'Test what the code does, not how it does it. For components: test what the user sees and interacts with, not internal state or method calls. For functions: test inputs → outputs, not intermediate steps. For APIs: test HTTP request → response, not internal service calls. A test that breaks when you refactor (without changing behavior) is testing implementation — rewrite it.'
For components: 'Use @testing-library/react (or vue, svelte). Query by role: screen.getByRole("button", { name: "Submit" }). Query by label: screen.getByLabelText("Email"). Never query by className, testID, or internal component structure. Simulate user events: await userEvent.click(button). Assert on screen output: expect(screen.getByText("Success")).toBeInTheDocument().'
For functions: 'Test the public API: const result = calculateTax(income, state); expect(result).toBe(expectedTax). Never spy on internal functions. Never assert that a private method was called. If the function delegates to a helper, test the helper separately through its own public API.'
- Components: test user-visible behavior — getByRole, getByLabelText, getByText
- Functions: test input → output — not intermediate steps or internal calls
- APIs: test request → response — not service layer internals
- If a refactor breaks the test without changing behavior — the test is wrong
- Never test implementation: no spying on internal methods, no checking state
If a refactor breaks your test without changing behavior — the test is testing implementation, not behavior. Rewrite it. Good tests survive refactors because they test what the code does, not how it does it.
Rule 2: Testing Library Patterns
The rule: 'Use @testing-library for all component testing. Follow the query priority: getByRole > getByLabelText > getByPlaceholderText > getByText > getByDisplayValue > getByAltText > getByTitle > getByTestId. Use userEvent (from @testing-library/user-event) for interactions — not fireEvent (it doesn't simulate real user behavior). Use screen for queries — not destructured render results.'
For async: 'Use findBy* for elements that appear asynchronously: await screen.findByText("Loaded"). Use waitFor for assertions that need retrying: await waitFor(() => expect(mockFn).toHaveBeenCalled()). Never use act() manually unless the testing library docs specifically say to — most cases are handled by findBy and waitFor.'
For what NOT to test: 'Don't test that React renders (it does). Don't test that a CSS class is applied (that's styling, not behavior). Don't test that a prop was passed (test the effect of the prop). Don't test internal component state (test what the user sees when state changes).'
Rule 3: Mock External Boundaries Only
The rule: 'Mock only external boundaries: HTTP APIs (msw or jest.fn for fetch), third-party services (Stripe, SendGrid), time (jest.useFakeTimers), random (jest.spyOn(Math, "random")). Never mock your own code — test through the real implementation. Never mock the module you're testing. The test should exercise real code paths, not verify that mocks were called correctly.'
For HTTP mocking: 'Use MSW (Mock Service Worker) for API mocking — it intercepts at the network level, so your code uses real fetch/axios. server.use(http.get("/api/users", () => HttpResponse.json(users))). MSW works in both unit tests and Storybook — one mock setup for both.'
AI generates jest.mock for every import: mock the database, mock the logger, mock the config, mock the utility functions. The result tests nothing — it verifies that mocks were called, not that the code works. Real tests use real code with only external boundaries mocked.
- Mock: HTTP APIs, third-party services, time, random — external boundaries
- Never mock: your own modules, the module under test, utility functions
- MSW for HTTP mocking — intercepts at network level, real fetch/axios
- jest.useFakeTimers for time-dependent tests — advance manually
- If you're mocking more than 2 things, the code needs restructuring, not more mocks
If you're mocking more than 2 dependencies, the code needs restructuring — not more mocks. Over-mocked tests verify that mocks were called correctly, not that your code works. Test real code with only external boundaries mocked.
Rule 4: Snapshots Sparingly
The rule: 'Use snapshot testing only for stable, serializable output: generated HTML for email templates, CLI output format, API response shape. Never snapshot entire React component renders — they break on every styling change and nobody reviews the diff. Use inline snapshots (toMatchInlineSnapshot) when the expected output is short enough to read in the test file.'
For when snapshots work: 'toMatchSnapshot is good for: CLI help text output, generated SQL queries, serialized configuration objects, and email HTML templates. It's bad for: React component output (too large, too fragile), API responses (use specific field assertions), and anything that changes frequently.'
AI defaults to toMatchSnapshot on everything — it's the laziest assertion. The test passes today, and when the snapshot changes tomorrow (for any reason), someone hits 'u' to update without reading the diff. Snapshots that aren't reviewed are tests that don't test.
toMatchSnapshot passes today, changes tomorrow, and someone hits 'u' without reading the diff. Use snapshots only for stable, small output (email templates, CLI text). For components and APIs, write explicit assertions.
Rule 5: Test Naming, Structure, and Configuration
The rule: 'Name tests as sentences describing behavior: it("displays an error when the email is invalid"). Use describe for the unit under test, it for specific behaviors. Group by scenario with nested describe: describe("LoginForm") > describe("when credentials are invalid") > it("shows an error message"). Use beforeEach for shared setup — never repeat setup in every test.'
For Jest vs Vitest config: 'Jest: jest.config.ts with ts-jest or @swc/jest transform. Vitest: vitest.config.ts — shares vite.config.ts transforms automatically. Vitest is faster (native ESM, Vite's transform pipeline) and recommended for Vite-based projects. Jest is standard for Next.js, CRA, and non-Vite projects. Both use the same test syntax — switching is a config change, not a rewrite.'
For CI: 'Run with --ci flag for CI-optimized output. Use --coverage for coverage reports. Set coverage thresholds: coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } }. Use --maxWorkers=2 in CI to prevent out-of-memory on limited CI runners. Use --bail to stop on first failure in CI.'
- Test names as sentences: 'displays error when email invalid' — not 'test1'
- describe for unit, nested describe for scenario, it for behavior
- beforeEach for shared setup — never duplicate across tests
- Vitest for Vite projects (faster) — Jest for Next.js/CRA (standard)
- CI: --ci, --coverage, --maxWorkers=2, --bail, coverage thresholds
Complete Jest/Vitest Rules Template
Consolidated rules for Jest or Vitest projects.
- Test behavior, not implementation — input → output, user-visible results
- Testing Library: getByRole > getByLabelText > getByText — userEvent for interactions
- Mock external boundaries only: HTTP (MSW), third-party, time — never your own code
- Snapshots sparingly: email templates, CLI output — never component renders
- Test names as sentences — describe/it structure — beforeEach for setup
- Vitest for Vite, Jest for Next.js — same syntax, different config
- CI: --ci --coverage --maxWorkers=2 --bail — coverage thresholds enforced
- eslint-plugin-testing-library — no-focused-tests — no-disabled-tests