Claude Skill

tdd

Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development.

LLM Mart · 0 points · 17 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download gaia-react-gaia-.claude_skills_tdd-6bb0226.zip · 14 KB
Part of gaia-react/gaia — 26 skills

Install

skills CLI npx skills add https://github.com/gaia-react/gaia/tree/main/.claude/skills/tdd
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install gaia-react-gaia@llmmart
Git git clone https://github.com/gaia-react/gaia.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole gaia-react/gaia collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Test-Driven Development

Selecting a Stack Reference

Before writing the first red test, consult the reference for your stack:

Add a new references/tests-{stack}.md when adopting a new stack. The stack reference covers concrete patterns, test layers, mocking rules, and good/bad examples specific to that environment.

Philosophy

Core principle: tests verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't.

Good tests are integration-style, they exercise real code paths through public APIs and describe what the system does, not how. A good test reads like a specification: "user submits a valid form and sees a success toast" tells you exactly what capability exists. These tests survive refactors because they don't care about internal structure.

Bad tests are coupled to implementation. They mock internal collaborators, spy on state setters, or assert on internal call signatures. The warning sign: your test breaks when you refactor, but behavior hasn't changed.

Anti-Pattern: Horizontal Slices

DO NOT write all tests first, then all implementation. This is "horizontal slicing", treating RED as "write all tests" and GREEN as "write all code."

Tests written in bulk test imagined behavior, not actual behavior. You outrun your headlights, committing to structure before understanding the implementation, producing tests insensitive to real changes.

Correct approach: vertical slices via tracer bullets. One test → one implementation → repeat.

WRONG (horizontal):
  RED:   test1, test2, test3, test4, test5
  GREEN: impl1, impl2, impl3, impl4, impl5

RIGHT (vertical):
  RED→GREEN: test1→impl1
  RED→GREEN: test2→impl2
  RED→GREEN: test3→impl3
  ...

Workflow

1. Planning

Before writing any code:

  • Confirm which layer owns this test (see stack reference for layer breakdown)
  • Confirm which behaviors to test (prioritize)
  • Identify opportunities for deep modules, small interface, deep implementation
  • Design interfaces for testability
  • List the behaviors to test (not implementation steps)
  • Get user approval on the plan

Ask: "What should the public interface look like? Which behaviors are most important to test?"

You can't test everything. Focus on critical paths and complex logic, not every edge case.

2. Tracer Bullet

Write ONE test that confirms ONE thing end-to-end for this layer. RED → GREEN. The tracer bullet confirms the testing infrastructure wires up before adding real coverage.

3. Incremental Loop

For each remaining behavior: RED → GREEN. One test at a time. Only enough code to pass the current test. Don't anticipate future tests.

Bound the green chase. If a test won't pass after a few focused attempts, stop and reassess instead of thrashing the implementation: the test, the interface, or an assumption may be wrong. Surface the blocker rather than looping indefinitely to force green.

Authoring an honest RED on the deterministic surface

The deterministic surface (pure utils, service parsers, spec-derivable hooks) is RED-gated: a new test there commits only after a genuine failing-first run is observed at its current body.

Author the test against the not-yet-written or stub implementation symbol. Write the test for the behavior you are about to build, pointing at a symbol that does not exist yet (or exists only as a stub that returns the wrong value). Run it; it fails because the implementation is missing or incomplete. That failure is the honest RED: a real missing-implementation failure, not a manufactured one. Then write the implementation that turns it green.

RIGHT:  test names parseAmount() → run → fails (parseAmount undefined / stub) → implement → green
WRONG:  implement parseAmount() → write the test → break parseAmount() to force red → restore it → green

Never break working production code to force a red, then restore it. That pattern relocates the theater into the implementation file: it is mechanically identical to the green-only theater the RED gate condemns. The content signal covers the test's comment-free content, so rewording a comment leaves a captured RED intact, while editing what the test itself executes invalidates it. The signal never covers the implementation, so this is a pattern the gate cannot catch: it is an authoring obligation, not an enforced one. The honest path is always to author the test against the absent or stub symbol, so the red comes for free from the missing implementation.

Single-pass-author exemption. When the implementation already exists in the same change with no prior failing observation (a single-pass author landing impl and test together), do NOT manufacture a red by breaking and restoring the impl. Author the test honestly against the existing behavior and route it to the worthiness audit, exactly as an emergent test is routed. A test that lands alongside its implementation with no prior failing observation is detectable, and a missed RED on it is caught late by the advisory audit, never by forcing theater up front.

4. Refactor

After all tests pass, look for refactor candidates:

  • Extract duplication
  • Deepen modules (move complexity behind simple interfaces)
  • Apply SOLID principles where natural
  • Consider what new code reveals about existing code
  • Run tests after each refactor step

Never refactor while RED. Get to GREEN first.

5. Determinism Roll-up

After green, classify every touched source file and report the verdict. Classification is per-file and silent: never prompt the human to choose strict versus test-after, the classifier decides from the file's content.

Run the determinism classifier over each touched source file:

node .gaia/scripts/classifier/classify-determinism.mjs <repo-relative-source-path>

It emits {file, classification: "strict" | "emergent", reasons}. A strict file is on the deterministic surface and owes a RED; an emergent file is clock-/entropy-/I-O-bound or tree-dependent and commits without one.

Emit the per-file verdict for every touched file in the end-of-task summary, unconditionally. One line per file, listed whether or not you judged the classification surprising. The roll-up is not gated on your sense of what is non-obvious: a silent misclassification (a pure-looking file the classifier marks emergent, or vice versa) only stays auditable if it appears as a named line. Do not suppress the routine-looking ones.

Determinism roll-up:
  • app/utils/money.ts            strict
  • app/components/Cart/index.tsx  emergent (path not a .ts under app/components)

This roll-up renders into the single end-of-task summary (see "Surfacing the advisory findings").

6. Worthiness Audit (emergent surface)

The deterministic surface earns its honesty proof from the RED gate. The emergent surface (app/components/**, .playwright/**) has no stable failing-then-passing run to gate on, so its honesty and worthiness come from an advisory audit instead. Run this audit after green, on the emergent-surface test files the task changed (the classifier roll-up above tells you which touched files are emergent).

Dispatch a fresh-context audit (no-orchestrator path)

On the main loop, with no orchestrator above you, the audit MUST run in a genuine fresh-context Agent sub-leaf, never as an in-context self-review by the test author. The author who just wrote the tests shares their blind spots; a same-model fresh-context reviewer that never saw the authoring rationale is the cheapest way to recover honesty signal. An in-context "I'll review my own tests" pass does not satisfy this step.

Dispatch one Agent leaf running the committed evaluator prompt (.claude/agents/worthiness-evaluator.md) over the task's changed emergent test files plus their sibling suites. The evaluator judges each test on two axes (honesty, worthiness), returns a keep/fix/delete verdict per test, and edits no files: every delete is a proposal a human confirms.

Write the worthiness ledger (no-orchestrator path)

With no orchestrator to record verdicts, the tdd skill is the ledger writer, otherwise principle-6 always-on is violated and the merge presence gate has nothing to read. For each verdict the evaluator returns, append one ledger line via the writer:

node .gaia/scripts/audit-ledger/append-worthiness.mjs <repo-rel-test-path> <fullName> <verdict> [artifact]

<verdict> is keep | fix | delete; [artifact] is REQUIRED for a non-keep verdict (the cited machine-verified sibling for a redundancy delete, the unreachable/missing assertion for a fix) and omitted for keep. The writer recomputes the test-identity signal from the file via the RED-ledger signal helper, so it byte-matches what the presence gate later recomputes; the ledger at .gaia/local/worthiness-ledger/<tree_key>/worthiness.jsonl (<tree_key> printed by bash .gaia/scripts/main-root-lib.sh --tree-key) is append-only and gitignored.

Surface the evaluator's verdicts in the same end-of-task summary (see "Surfacing the advisory findings"). Deletes are proposals: present them for human confirmation, never act on them.

Path-scoped guarantees

The guarantee this audit provides is scoped to the subset a same-model fresh-context reviewer reliably flags, not a proof of test worthiness. State the surviving guarantees by path:

  • No-orchestrator path (this skill alone), surviving guarantees: static honesty lint on every test; a real RED on the deterministic surface; the fresh-subagent worthiness audit if it was dispatched. The audit is advisory and depends on this dispatch step running; a skipped dispatch leaves only the static lint and the RED gate.
  • Orchestrated path, additional guarantees (NOT available here): the post-phase merge presence gate (which recomputes signals and refuses a merge whose audit ledger is absent or mismatched) and leaf isolation enforced by the orchestrator. These are orchestrator-owned; the no-orchestrator path does not provide them.

7. Surfacing the advisory findings

Advisory findings (worthiness verdicts and the structural a11y floor below) NEVER interrupt mid-implementation. There are no mid-flow prompts. They surface only where attention already is: the end-of-task summary, before commit (no-orchestrator path), and the orchestrator's SUMMARY.md plus the pre-merge summary (orchestrated path). This is the SAME end-of-task summary the determinism roll-up and the worthiness verdicts already render into; the surfacing rules here describe how that one summary presents the findings, not a second summary.

Structural a11y floor (judge-independent non-triviality)

Run the structural a11y floor over each changed emergent-surface test file that calls an a11y helper (expectNoA11yViolations / runAxe):

node .gaia/scripts/a11y-structural/check-a11y-triviality.mjs <repo-relative-test-path>

It emits {file, verdict: "trivial" | "non-trivial" | "not-a11y", findings}. A trivial verdict flags a vacuous a11y test as an advisory non-triviality fix, on a static-AST shape alone: the render passes no props (only defaults), or the rendered markup carries no interactive or landmark node while the component's stories declare interactive variants. This is the judge-independent producer of the non-triviality signal; the worthiness evaluator's matching fix is corroborating evidence, never the pass condition. When the floor says trivial and the evaluator says keep, the floor wins and the disagreement is surfaced. A render-only axe pass stays a complete a11y test for a component with no interactive behavior (a Spinner, a static badge); the floor only flags it when the shape or the stories show unexercised behavior. The floor is ADVISORY: it adds a fix finding to the summary, it never blocks a commit. Route a trivial finding into the worthiness ledger as a fix with the structural reason as its artifact.

Two tiers, capped

The summary presents advisory findings in two tiers:

  • HONESTY findings auto-fix. A test that fails the honesty axis (couples to implementation, asserts a tautology, asserts platform bytes) is rewritten in place and left in the working tree for normal review. No confirmation prompt: the fix is a normal code edit the human reviews like any other.
  • Every proposed DELETE requires human confirmation and renders its evidence INLINE. A delete is never acted on. Each proposed delete renders, in the summary, the cited redundant sibling assertion AND the subsuming seam assertion that makes it redundant, so the human confirms against the evidence without opening files. A delete whose cited sibling cannot be machine-verified is downgraded, never shown as a delete.

Cap the list: show the top-N by severity with a count (delete proposals before fix findings; "3 of 11 findings shown"), never a wall of every line.

Where it renders

  • No-orchestrator path (main loop): one end-of-task summary before commit, sharing the surface with the determinism roll-up and the worthiness verdicts. One summary, not three.
  • Orchestrated path: the same two-tier content goes into the leaf's SUMMARY.md ledger entry and the pre-merge summary, so the orchestrator's merge presence gate and the human reviewer see the deletes and their inline evidence before a merge.

Checklist Per Cycle

[ ] Test describes behavior, not implementation
[ ] Test uses public interface only (no spying on internals)
[ ] Test would survive an internal refactor
[ ] Code is minimal for this test
[ ] No speculative features added
[ ] Mock only at system boundaries (network, time, randomness)
Files (gaia)
  • references
    • tests-react.md 18.2 KB
      # React Testing Reference (Vitest + RTL + MSW + Storybook)
      
      ## Testing Layers
      
      Four layers share one mocking foundation (`msw` + `@msw/data`). Write tests at the **lowest layer that can verify the behavior**, a button's disabled state is a component test, not E2E; a route's redirect is E2E, a loader's parsing is a service test.
      
      | Layer       | Tool                           | Runner     | File location                  | What to assert                                      |
      | ----------- | ------------------------------ | ---------- | ------------------------------ | --------------------------------------------------- |
      | Unit / hook | RTL `renderHook`               | Vitest     | `app/hooks/<name>/tests/`      | hook return values, state transitions, callbacks    |
      | Component   | RTL + Storybook `composeStory` | Vitest     | `app/components/<Name>/tests/` | rendered DOM, user interactions, props behavior     |
      | Service     | MSW handlers + Zod             | Vitest     | `app/services/<name>/tests/`   | parsed response shape, request payload, error cases |
      | E2E         | Playwright + MSW browser       | Playwright | `.playwright/e2e/*.spec.ts`    | full user flow across routes                        |
      
      ## Component Tests via `composeStory`
      
      The story is the test's source of truth. Use `composeStory`, never render a fresh `<Component prop={...} />` directly in tests, because stories already set up decorators (i18n, router, state) via `test/stubs`. Rendering fresh bypasses those stubs and produces flaky or incomplete tests.
      
      ```tsx
      // app/components/PriceTag/tests/index.stories.tsx
      import type {Meta, StoryFn} from '@storybook/react-vite';
      import PriceTag from '..';
      
      const meta: Meta = {component: PriceTag};
      export default meta;
      
      export const Default: StoryFn = () => <PriceTag amount={4999} currency="USD" />;
      export const Discounted: StoryFn = () => (
        <PriceTag amount={4999} currency="USD" discountPercent={20} />
      );
      ```
      
      ```tsx
      // app/components/PriceTag/tests/index.test.tsx
      import {composeStory} from '@storybook/react-vite';
      import {describe, expect, test} from 'vitest';
      import {render, screen} from 'test/rtl';
      import Meta, {Default, Discounted} from './index.stories';
      
      const DefaultTag = composeStory(Default, Meta);
      const DiscountedTag = composeStory(Discounted, Meta);
      
      describe('PriceTag', () => {
        test('renders formatted price', () => {
          render(<DefaultTag />);
          expect(screen.getByText('$49.99')).toBeInTheDocument();
        });
      
        test('shows strike-through original when discounted', () => {
          render(<DiscountedTag />);
          expect(screen.getByText('$39.99')).toBeInTheDocument();
          expect(screen.getByText('$49.99')).toHaveClass('line-through');
        });
      });
      ```
      
      The tracer bullet for any component: `composeStory(Default, Meta)` renders without throwing. Use ARIA roles and accessible names as selectors, `getByRole('button', {name: 'Save'})`, `getByText('$49.99')`, not class selectors or test ids.
      
      ### Overriding a prop (callback spies)
      
      When a test overrides a prop on a composed story, especially a callback it spies on, the story must accept `(args)` and spread `{...args}` **last**, after any hardcoded default, so the override wins. Storybook's own guidance says the render function "spreads `args` onto the component" (https://storybook.js.org/docs/writing-stories), and `composeStory` says render-time props "override the values passed in the story's args" (https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#composestory). `args` only reaches the real component through that spread, so a story that hardcodes the callback, or spreads `{...args}` before it, silently drops the override.
      
      Storybook's own examples spread every prop from `args`, so there's nothing to order against. This repo's stories hardcode structural/demo props inline (labels, names, options) and spread `{...args}` only for the controllable knobs, see `app/components/Form/RadioButtons/tests/index.stories.tsx`, so ordering is load-bearing: `{...args}` must come after the hardcoded props for an override to win.
      
      ```tsx
      // app/components/Toggle/tests/index.stories.tsx
      // GOOD - accepts args and spreads {...args} LAST, so a test can override onChange
      const Template: StoryFn = (args) => (
        <Toggle label="Notifications" onChange={() => {}} {...args} />
      );
      
      export const Default = Template.bind({});
      Default.args = {checked: false};
      ```
      
      ```tsx
      // app/components/Toggle/tests/index.test.tsx
      // GOOD - the override reaches the real component, so the spy assertion is real
      test('emits onChange when toggled', async () => {
        const onChange = vi.fn();
        render(<Toggle onChange={onChange} />);
        await userEvent.click(screen.getByRole('switch', {name: 'Notifications'}));
        expect(onChange).toHaveBeenCalledWith(true);
      });
      ```
      
      (`Toggle` here is `composeStory(Default, Meta)` in the test, mirroring the `const DefaultTag = composeStory(Default, Meta)` idiom above.)
      
      ```tsx
      // BAD: hardcodes onChange (or never accepts args), so a render-time override is dropped
      const Template: StoryFn = (args) => (
        <Toggle label="Notifications" {...args} onChange={() => {}} />
      );
      // equally broken: a story that never accepts (args):
      // export const Default: StoryFn = () => <Toggle label="Notifications" onChange={() => {}} />;
      ```
      
      ```tsx
      // BAD: the spy is never wired, so this assertion passes vacuously
      test('does not emit onChange while disabled', async () => {
        const onChange = vi.fn();
        render(<Toggle disabled onChange={onChange} />); // override silently dropped
        await userEvent.click(screen.getByRole('switch', {name: 'Notifications'}));
        expect(onChange).not.toHaveBeenCalled(); // green even if the disabled guard is broken
      });
      ```
      
      Why bad: the story hardcodes `onChange`, so the composed story ignores the render-time override and hands the component its own `() => {}`. The spy is never wired, so `not.toHaveBeenCalled()` passes no matter what the component does; it would stay green even if the disabled guard were removed. (A positive `toHaveBeenCalledWith` on the same broken story fails loudly instead, which tempts a raw-render "fix" that bypasses the story's stubs; the real fix is to make the story spread `{...args}` last.)
      
      ## Hook Tests via `renderHook`
      
      ```tsx
      // app/hooks/useToggle/tests/index.test.ts
      import {act, renderHook} from 'test/rtl';
      import {describe, expect, test} from 'vitest';
      import useToggle from '..';
      
      describe('useToggle', () => {
        test('starts with initial value', () => {
          const {result} = renderHook(() => useToggle(true));
          expect(result.current[0]).toBe(true);
        });
        test('toggles value', () => {
          const {result} = renderHook(() => useToggle(false));
          act(() => result.current[1]());
          expect(result.current[0]).toBe(true);
        });
      });
      ```
      
      Assert on `result.current`, the observable hook surface. Don't reach into closures or internal state.
      
      ## Service Tests via MSW
      
      MSW handlers run inside Vitest, you're exercising the real `api()` wrapper, real Zod parsing, and real URL resolution. No `vi.mock('fetch')`.
      
      ```tsx
      // app/services/gaia/things/tests/requests.test.ts
      import {afterEach, describe, expect, test} from 'vitest';
      import database, {resetTestData} from 'test/mocks/database';
      import {getThings} from '../requests.server';
      
      describe('getThings', () => {
        afterEach(() => resetTestData());
      
        test('returns Zod-parsed Things collection', async () => {
          const things = await getThings();
          expect(things).toHaveLength(3);
          expect(things[0]).toMatchObject({
            id: expect.any(String),
            name: expect.any(String),
          });
        });
      
        test('throws on malformed response', async () => {
          await database.things.create({id: 'x', name: null as unknown as string});
          await expect(getThings()).rejects.toThrow();
        });
      });
      ```
      
      The tracer bullet for services: the happy-path request returns a Zod-parsed result.
      
      ## When to Mock
      
      Mock at **system boundaries** only:
      
      | Boundary                | Mock via                               | Example                                       |
      | ----------------------- | -------------------------------------- | --------------------------------------------- |
      | HTTP / external APIs    | MSW handlers in `test/mocks/`          | REST calls made by `app/services/`            |
      | Database read/write     | `@msw/data` collections via `database` | `await database.things.create(...)` in a test |
      | Time                    | `vi.useFakeTimers()`                   | Debounced handlers, TTL expiry                |
      | Randomness              | `vi.spyOn` at boundary                 | IDs, crypto                                   |
      | Navigation (unit scope) | `stubs.reactRouter({routes})`          | Buttons that push to `/done`                  |
      
      **Never mock:**
      
      - Your own services, hooks, components, or utilities. If a component uses `useThings()`, test it against the real hook reading from real MSW. Mocking `useThings` means you're testing a fiction.
      - `react-router` or `react-i18next`. Use `stubs.reactRouter()` / `stubs.state()` from `test/stubs`. Global i18n is wired in `test/setup.ts`.
      - Zod schemas. If a service fails to parse, that's a real bug the test should surface.
      
      **Mutating data in a test**: write to `database` directly; reset in `afterEach` via `resetTestData()` from `test/mocks/database`. The read-then-verify shape tests the interface end-to-end and survives schema renames as long as the public service contract holds.
      
      MSW handlers run in Vitest, Storybook, AND Playwright, one mock layer, three testing scopes.
      
      ## Testing Forms with Conform
      
      For components using `@conform-to/react`, create a story that wraps the component with `useForm`:
      
      ```tsx
      export const Default: StoryFn = () => {
        const [form, fields] = useForm({
          onValidate: ({formData}) => parseWithZod(formData, {schema}),
        });
      
        return (
          <Form {...getFormProps(form)}>
            <MyFormComponent fields={fields} />
          </Form>
        );
      };
      ```
      
      ### Custom form components: use `useInputControl`
      
      When using custom form components (like `YearMonthDay`, `TimePicker`, etc.) that manage their own internal state, you **must** use `useInputControl` to properly integrate them with Conform's validation state:
      
      ```tsx
      // BAD - Local state conflicts with Conform's validation
      const [value, setValue] = useState(savedData?.field ?? DEFAULT);
      const handleChangeValue = useCallback((newValue) => {
        setValue(newValue);
      }, []);
      
      <CustomComponent onChange={handleChangeValue} value={value} />;
      
      // GOOD - useInputControl keeps component synced with Conform
      const fieldControl = useInputControl(fields.fieldName);
      
      <CustomComponent
        onBlur={fieldControl.blur}
        onChange={fieldControl.change}
        value={fieldControl.value ?? DEFAULT}
      />;
      ```
      
      **Why this matters**: When validation fails, Conform takes control of the field value. If you use local `useState`, the component becomes disconnected from Conform's state and stops responding to changes after validation errors occur.
      
      See `app/components/Form/YearMonthDay/tests/` for a complete example of this pattern in action.
      
      ## Bad Tests
      
      ```tsx
      // BAD: asserts on translation internals, not user output
      test('greets the user', () => {
        const tSpy = vi.fn();
        vi.mock('react-i18next', () => ({useTranslation: () => ({t: tSpy})}));
        render(<Greeting name="Ada" />);
        expect(tSpy).toHaveBeenCalledWith('greeting.hello', {name: 'Ada'});
      });
      ```
      
      Why bad: renaming the key (`greeting.hello` → `pages.home.greeting`) breaks the test even though the user still sees "Hello, Ada".
      
      ```tsx
      // BAD: mocks react-router, tests the mock
      vi.mock('react-router', () => ({useNavigate: () => mockNavigate}));
      test('submit navigates to /done', async () => {
        render(<CheckoutButton />);
        await userEvent.click(screen.getByRole('button'));
        expect(mockNavigate).toHaveBeenCalledWith('/done');
      });
      ```
      
      Why bad: tests the mock, not the component. Use `stubs.reactRouter({routes: [{path: '/done', storyId: '...'}]})` and assert on the resulting page.
      
      ```tsx
      // BAD: reads MSW internals
      test('saveThing called POST', async () => {
        const handler = server
          .listHandlers()
          .find((h) => h.info.path.endsWith('/things'));
        expect(handler).toBeDefined();
      });
      ```
      
      Why bad: handler existence proves nothing. Assert on the effect, database state after the call, or the returned value.
      
      ## Worth Keeping: the Discriminator, Composition, and Platform Rules
      
      A test that passes can still be worthless. The honesty rules ("Bad Tests" above) ask whether a test _can_ fail for a real reason; these rules ask whether the test is worth having at all. Tests that re-prove a dependency, re-prove a child component, or pin the byte output of a platform formatter add maintenance cost and break on unrelated upgrades while catching none of your bugs.
      
      ### The discriminator
      
      Before keeping any test, ask:
      
      > If this test failed, would the bug be in MY code or in the dependency? If the dependency, delete the test.
      
      `date-fns`, `Intl`, Zod, `react-router`, `react-i18next` all have their own suites. A test whose only failure mode is "the library changed" tests the library, not you.
      
      ### The composition rule
      
      A test for component `C` asserts the **emergent behavior of its children together**: the seam where data and events flow through `C`. It never re-proves what the children's own suites already cover.
      
      ```tsx
      // app/components/Checkout/tests/index.test.tsx
      // GOOD - the seam: PriceTag + QuantityStepper feeding the running total in Checkout
      test('total updates when quantity changes', async () => {
        render(<DefaultCheckout />);
        await userEvent.click(screen.getByRole('button', {name: 'Increase quantity'}));
        expect(screen.getByRole('status', {name: 'Order total'})).toHaveTextContent('$99.98');
      });
      
      // BAD - re-proves PriceTag's own suite; nothing here is about Checkout
      test('price renders with two decimals', () => {
        render(<DefaultCheckout />);
        expect(screen.getByText('$49.99')).toBeInTheDocument(); // PriceTag's job, tested in PriceTag's suite
      });
      ```
      
      This is a judgment call, not a lint rule: applied bluntly it strips real integration regressions. An agent applies it **PROPOSE-only and NEVER auto-deletes a child-redundant test.** Any proposed delete must cite both the specific redundant sibling assertion AND the seam assertion that subsumes it, and the cited sibling assertion is machine-verified to contain a matching assertion before the proposal reaches a human. Security, escaping, and data-integrity seam tests (for example a Toast XSS-escaping test) carry a never-delete-without-a-verified-sibling carve-out: they stay even when a sibling looks redundant.
      
      ### The platform rule
      
      When a helper delegates to a platform formatter (`Intl`, `date-fns`), test the logic you own, not the formatter's output bytes.
      
      ```tsx
      // WORTHLESS - tests Intl, not you
      // formatPrice = (n) => new Intl.NumberFormat('en-US',
      //   {style: 'currency', currency: 'USD'}).format(n);
      test('formats as USD', () => expect(formatPrice(49.99)).toBe('$49.99'));
      
      // WORTHY - tests YOUR logic; Intl is just the boundary it delegates to
      // formatPrice = (cents, currency) => {
      //   if (cents == null) return '';
      //   return new Intl.NumberFormat(LOCALE_BY_CURRENCY[currency],
      //     {style: 'currency', currency}).format(cents / 100);
      // };
      test('renders nothing for a null amount', () =>
        expect(formatPrice(null, 'USD')).toBe(''));
      test('converts cents to major units', () =>
        expect(formatPrice(4999, 'USD')).toBe('$49.99'));
      test('picks the locale for the currency', () => {
        // Assert the locale SELECTION you own with a TOLERANT matcher, never byte-exact
        // glyphs. Intl uses a narrow no-break space that varies by ICU version, so
        // toBe('49,99 €') is itself the platform-coupled anti-pattern this rule warns
        // against - it breaks on a Node/ICU upgrade with no bug in your code.
        const out = formatPrice(4999, 'EUR');
        expect(out).toMatch(/49,99/);
        expect(out).toContain('€');
      });
      ```
      
      The null-amount guard and the cents-to-major conversion and the currency-to-locale selection are yours. The decimal separator, currency glyph, and spacing belong to `Intl`. Assert the first set; tolerate the second.
      
      ### Thin wrappers over a platform formatter
      
      A helper that only selects a locale or format and delegates to `Intl` or `date-fns` is the platform rule's most common shape. `formatMY` in `app/utils/date.ts` delegates straight to `date-fns`'s `format` with a fixed `MM/yy` pattern:
      
      ```ts
      export const formatMY = (date = new Date()): string => format(date, 'MM/yy');
      ```
      
      ```ts
      // WORTHLESS - re-proves date-fns formats MM/yy; the bug would be in date-fns
      test('formats MM/yy', () =>
        expect(formatMY(new Date('2026-01-15'))).toBe('01/26'));
      ```
      
      `formatMY` owns no branching logic to test, so it has nothing worth a unit test of its own; it is exercised through the components that render a card expiry. A sibling like `formatFullYear`, which DOES branch on language (`'en'` versus the `年` suffix), is worth testing on the branch you own, not on the year digits `date-fns` produces.
      
      ## Tracer Bullets and a11y
      
      The tracer bullet for any component, `composeStory(Default, Meta)` renders without throwing, and the structural a11y check, `expectNoA11yViolations` on that render, are both **starting points, approved as a complete test ONLY for components with no interactive behavior** (a Spinner, a static badge). For a behavior-rich component, a tracer-bullet-only test is the start of a test, not the whole of it: the interactions, state transitions, and error paths still need assertions.
      
      The same caveat extends to accessibility: `expectNoA11yViolations` on a render-only container is a starting point for interactive components, not a complete a11y test. A render-only axe pass says nothing about focus order, keyboard operation, or the accessible state of controls a user actually drives.
      
      ## Red Flags
      
      - `vi.fn()` spy on a function the component uses internally
      - `toHaveBeenCalled` as the only assertion (you're testing call-through, not behavior)
      - Importing from `../internals` or `.server.ts` files the public consumer wouldn't touch
      - Test names like "`useX` returns an object with `a`, `b`, `c`", that's testing shape, not behavior
      - Asserting on i18n keys, raw class lists, or DOM structure instead of accessible roles and text
      - `vi.mock('~/services/...')`, you've mocked something MSW already handles
      - `vi.mock('~/hooks/...')` or `vi.mock('~/components/...')`, internal collaborator mocking
      - Test setup that reimplements application logic to seed data (write to `database` instead)
      - A fixture file larger than the code it tests
      
  • deep-modules.md 1.2 KB
    # Deep Modules
    
    From "A Philosophy of Software Design":
    
    **Deep module** = small interface + lots of implementation
    
    ```
    ┌─────────────────────┐
    │   Small Interface   │  ← Few methods, simple params
    ├─────────────────────┤
    │                     │
    │                     │
    │  Deep Implementation│  ← Complex logic hidden
    │                     │
    │                     │
    └─────────────────────┘
    ```
    
    **Shallow module** = large interface + little implementation (avoid)
    
    ```
    ┌─────────────────────────────────┐
    │       Large Interface           │  ← Many methods, complex params
    ├─────────────────────────────────┤
    │  Thin Implementation            │  ← Just passes through
    └─────────────────────────────────┘
    ```
    
    When designing interfaces, ask:
    
    - Can I reduce the number of methods?
    - Can I simplify the parameters?
    - Can I hide more complexity inside?
    
  • interface-design.md 653 B
    # Interface Design for Testability
    
    Good interfaces make testing natural:
    
    1. **Accept dependencies, don't create them**
    
       ```typescript
       // Testable
       function processOrder(order, paymentGateway) {}
    
       // Hard to test
       function processOrder(order) {
         const gateway = new StripeGateway();
       }
       ```
    
    2. **Return results, don't produce side effects**
    
       ```typescript
       // Testable
       function calculateDiscount(cart): Discount {}
    
       // Hard to test
       function applyDiscount(cart): void {
         cart.total -= discount;
       }
       ```
    
    3. **Small surface area**
       - Fewer methods = fewer tests needed
       - Fewer params = simpler test setup
    
  • refactoring.md 387 B
    # Refactor Candidates
    
    After TDD cycle, look for:
    
    - **Duplication** → Extract function/class
    - **Long methods** → Break into private helpers (keep tests on public interface)
    - **Shallow modules** → Combine or deepen
    - **Feature envy** → Move logic to where data lives
    - **Primitive obsession** → Introduce value objects
    - **Existing code** the new code reveals as problematic
    
  • SKILL.md 13.9 KB
    ---
    name: tdd
    description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development.
    ---
    
    # Test-Driven Development
    
    ## Selecting a Stack Reference
    
    Before writing the first red test, consult the reference for your stack:
    
    - **React / Vitest / MSW / Storybook** → [references/tests-react.md](references/tests-react.md)
    
    Add a new `references/tests-{stack}.md` when adopting a new stack. The stack reference covers concrete patterns, test layers, mocking rules, and good/bad examples specific to that environment.
    
    ## Philosophy
    
    **Core principle**: tests verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't.
    
    **Good tests** are integration-style, they exercise real code paths through public APIs and describe _what_ the system does, not _how_. A good test reads like a specification: "user submits a valid form and sees a success toast" tells you exactly what capability exists. These tests survive refactors because they don't care about internal structure.
    
    **Bad tests** are coupled to implementation. They mock internal collaborators, spy on state setters, or assert on internal call signatures. The warning sign: your test breaks when you refactor, but behavior hasn't changed.
    
    ## Anti-Pattern: Horizontal Slices
    
    **DO NOT write all tests first, then all implementation.** This is "horizontal slicing", treating RED as "write all tests" and GREEN as "write all code."
    
    Tests written in bulk test _imagined_ behavior, not _actual_ behavior. You outrun your headlights, committing to structure before understanding the implementation, producing tests insensitive to real changes.
    
    **Correct approach**: vertical slices via tracer bullets. One test → one implementation → repeat.
    
    ```
    WRONG (horizontal):
      RED:   test1, test2, test3, test4, test5
      GREEN: impl1, impl2, impl3, impl4, impl5
    
    RIGHT (vertical):
      RED→GREEN: test1→impl1
      RED→GREEN: test2→impl2
      RED→GREEN: test3→impl3
      ...
    ```
    
    ## Workflow
    
    ### 1. Planning
    
    Before writing any code:
    
    - [ ] Confirm which layer owns this test (see stack reference for layer breakdown)
    - [ ] Confirm which behaviors to test (prioritize)
    - [ ] Identify opportunities for [deep modules](deep-modules.md), small interface, deep implementation
    - [ ] Design interfaces for [testability](interface-design.md)
    - [ ] List the behaviors to test (not implementation steps)
    - [ ] Get user approval on the plan
    
    Ask: "What should the public interface look like? Which behaviors are most important to test?"
    
    **You can't test everything.** Focus on critical paths and complex logic, not every edge case.
    
    ### 2. Tracer Bullet
    
    Write ONE test that confirms ONE thing end-to-end for this layer. `RED → GREEN`. The tracer bullet confirms the testing infrastructure wires up before adding real coverage.
    
    ### 3. Incremental Loop
    
    For each remaining behavior: `RED → GREEN`. One test at a time. Only enough code to pass the current test. Don't anticipate future tests.
    
    **Bound the green chase.** If a test won't pass after a few focused attempts, stop and reassess instead of thrashing the implementation: the test, the interface, or an assumption may be wrong. Surface the blocker rather than looping indefinitely to force green.
    
    #### Authoring an honest RED on the deterministic surface
    
    The deterministic surface (pure utils, service parsers, spec-derivable hooks) is RED-gated: a new test there commits only after a genuine failing-first run is observed at its current body.
    
    **Author the test against the not-yet-written or stub implementation symbol.** Write the test for the behavior you are about to build, pointing at a symbol that does not exist yet (or exists only as a stub that returns the wrong value). Run it; it fails because the implementation is missing or incomplete. That failure is the honest RED: a real missing-implementation failure, not a manufactured one. Then write the implementation that turns it green.
    
    ```
    RIGHT:  test names parseAmount() → run → fails (parseAmount undefined / stub) → implement → green
    WRONG:  implement parseAmount() → write the test → break parseAmount() to force red → restore it → green
    ```
    
    **Never break working production code to force a red, then restore it.** That pattern relocates the theater into the implementation file: it is mechanically identical to the green-only theater the RED gate condemns. The content signal covers the test's comment-free content, so rewording a comment leaves a captured RED intact, while editing what the test itself executes invalidates it. The signal never covers the implementation, so this is a pattern the gate cannot catch: it is an authoring obligation, not an enforced one. The honest path is always to author the test against the absent or stub symbol, so the red comes for free from the missing implementation.
    
    **Single-pass-author exemption.** When the implementation already exists in the same change with no prior failing observation (a single-pass author landing impl and test together), do NOT manufacture a red by breaking and restoring the impl. Author the test honestly against the existing behavior and route it to the worthiness audit, exactly as an emergent test is routed. A test that lands alongside its implementation with no prior failing observation is detectable, and a missed RED on it is caught late by the advisory audit, never by forcing theater up front.
    
    ### 4. Refactor
    
    After all tests pass, look for [refactor candidates](refactoring.md):
    
    - [ ] Extract duplication
    - [ ] Deepen modules (move complexity behind simple interfaces)
    - [ ] Apply SOLID principles where natural
    - [ ] Consider what new code reveals about existing code
    - [ ] Run tests after each refactor step
    
    **Never refactor while RED.** Get to GREEN first.
    
    ### 5. Determinism Roll-up
    
    After green, classify every touched source file and report the verdict. Classification is **per-file and silent**: never prompt the human to choose strict versus test-after, the classifier decides from the file's content.
    
    Run the determinism classifier over each touched source file:
    
    ```
    node .gaia/scripts/classifier/classify-determinism.mjs <repo-relative-source-path>
    ```
    
    It emits `{file, classification: "strict" | "emergent", reasons}`. A `strict` file is on the deterministic surface and owes a RED; an `emergent` file is clock-/entropy-/I-O-bound or tree-dependent and commits without one.
    
    **Emit the per-file verdict for every touched file in the end-of-task summary, unconditionally.** One line per file, listed whether or not you judged the classification surprising. The roll-up is not gated on your sense of what is non-obvious: a silent misclassification (a pure-looking file the classifier marks emergent, or vice versa) only stays auditable if it appears as a named line. Do not suppress the routine-looking ones.
    
    ```
    Determinism roll-up:
      • app/utils/money.ts            strict
      • app/components/Cart/index.tsx  emergent (path not a .ts under app/components)
    ```
    
    This roll-up renders into the single end-of-task summary (see "Surfacing the advisory findings").
    
    ### 6. Worthiness Audit (emergent surface)
    
    The deterministic surface earns its honesty proof from the RED gate. The emergent surface (`app/components/**`, `.playwright/**`) has no stable failing-then-passing run to gate on, so its honesty and worthiness come from an **advisory audit** instead. Run this audit after green, on the emergent-surface test files the task changed (the classifier roll-up above tells you which touched files are `emergent`).
    
    #### Dispatch a fresh-context audit (no-orchestrator path)
    
    On the main loop, with no orchestrator above you, the audit MUST run in a **genuine fresh-context `Agent` sub-leaf**, never as an in-context self-review by the test author. The author who just wrote the tests shares their blind spots; a same-model fresh-context reviewer that never saw the authoring rationale is the cheapest way to recover honesty signal. An in-context "I'll review my own tests" pass does not satisfy this step.
    
    Dispatch one `Agent` leaf running the committed evaluator prompt (`.claude/agents/worthiness-evaluator.md`) over the task's changed emergent test files plus their sibling suites. The evaluator judges each test on two axes (honesty, worthiness), returns a `keep`/`fix`/`delete` verdict per test, and **edits no files**: every `delete` is a proposal a human confirms.
    
    #### Write the worthiness ledger (no-orchestrator path)
    
    With no orchestrator to record verdicts, **the tdd skill is the ledger writer**, otherwise principle-6 always-on is violated and the merge presence gate has nothing to read. For each verdict the evaluator returns, append one ledger line via the writer:
    
    ```
    node .gaia/scripts/audit-ledger/append-worthiness.mjs <repo-rel-test-path> <fullName> <verdict> [artifact]
    ```
    
    `<verdict>` is `keep` | `fix` | `delete`; `[artifact]` is REQUIRED for a non-keep verdict (the cited machine-verified sibling for a redundancy delete, the unreachable/missing assertion for a fix) and omitted for `keep`. The writer recomputes the test-identity signal from the file via the RED-ledger signal helper, so it byte-matches what the presence gate later recomputes; the ledger at `.gaia/local/worthiness-ledger/<tree_key>/worthiness.jsonl` (`<tree_key>` printed by `bash .gaia/scripts/main-root-lib.sh --tree-key`) is append-only and gitignored.
    
    Surface the evaluator's verdicts in the same end-of-task summary (see "Surfacing the advisory findings"). Deletes are proposals: present them for human confirmation, never act on them.
    
    #### Path-scoped guarantees
    
    The guarantee this audit provides is scoped to **the subset a same-model fresh-context reviewer reliably flags**, not a proof of test worthiness. State the surviving guarantees by path:
    
    - **No-orchestrator path (this skill alone), surviving guarantees:** static honesty lint on every test; a real RED on the deterministic surface; the fresh-subagent worthiness audit **if it was dispatched**. The audit is advisory and depends on this dispatch step running; a skipped dispatch leaves only the static lint and the RED gate.
    - **Orchestrated path, additional guarantees (NOT available here):** the post-phase merge presence gate (which recomputes signals and refuses a merge whose audit ledger is absent or mismatched) and leaf isolation enforced by the orchestrator. These are orchestrator-owned; the no-orchestrator path does not provide them.
    
    ### 7. Surfacing the advisory findings
    
    Advisory findings (worthiness verdicts and the structural a11y floor below) NEVER interrupt mid-implementation. There are no mid-flow prompts. They surface only where attention already is: the end-of-task summary, before commit (no-orchestrator path), and the orchestrator's `SUMMARY.md` plus the pre-merge summary (orchestrated path). This is the SAME end-of-task summary the determinism roll-up and the worthiness verdicts already render into; the surfacing rules here describe how that one summary presents the findings, not a second summary.
    
    #### Structural a11y floor (judge-independent non-triviality)
    
    Run the structural a11y floor over each changed emergent-surface test file that calls an a11y helper (`expectNoA11yViolations` / `runAxe`):
    
    ```
    node .gaia/scripts/a11y-structural/check-a11y-triviality.mjs <repo-relative-test-path>
    ```
    
    It emits `{file, verdict: "trivial" | "non-trivial" | "not-a11y", findings}`. A `trivial` verdict flags a vacuous a11y test as an advisory **non-triviality fix**, on a static-AST shape alone: the render passes no props (only defaults), or the rendered markup carries no interactive or landmark node while the component's stories declare interactive variants. This is the judge-independent producer of the non-triviality signal; the worthiness evaluator's matching `fix` is corroborating evidence, never the pass condition. When the floor says `trivial` and the evaluator says `keep`, the floor wins and the disagreement is surfaced. A render-only axe pass stays a complete a11y test for a component with no interactive behavior (a Spinner, a static badge); the floor only flags it when the shape or the stories show unexercised behavior. The floor is ADVISORY: it adds a `fix` finding to the summary, it never blocks a commit. Route a `trivial` finding into the worthiness ledger as a `fix` with the structural reason as its artifact.
    
    #### Two tiers, capped
    
    The summary presents advisory findings in two tiers:
    
    - **HONESTY findings auto-fix.** A test that fails the honesty axis (couples to implementation, asserts a tautology, asserts platform bytes) is rewritten in place and left in the working tree for normal review. No confirmation prompt: the fix is a normal code edit the human reviews like any other.
    - **Every proposed DELETE requires human confirmation and renders its evidence INLINE.** A delete is never acted on. Each proposed delete renders, in the summary, the cited redundant sibling assertion AND the subsuming seam assertion that makes it redundant, so the human confirms against the evidence without opening files. A delete whose cited sibling cannot be machine-verified is downgraded, never shown as a delete.
    
    Cap the list: show the **top-N by severity with a count** (`delete` proposals before `fix` findings; "3 of 11 findings shown"), never a wall of every line.
    
    #### Where it renders
    
    - **No-orchestrator path (main loop):** one end-of-task summary before commit, sharing the surface with the determinism roll-up and the worthiness verdicts. One summary, not three.
    - **Orchestrated path:** the same two-tier content goes into the leaf's `SUMMARY.md` ledger entry and the pre-merge summary, so the orchestrator's merge presence gate and the human reviewer see the deletes and their inline evidence before a merge.
    
    ## Checklist Per Cycle
    
    ```
    [ ] Test describes behavior, not implementation
    [ ] Test uses public interface only (no spying on internals)
    [ ] Test would survive an internal refactor
    [ ] Code is minimal for this test
    [ ] No speculative features added
    [ ] Mock only at system boundaries (network, time, randomness)
    ```
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related