Observable outcome tests over mocks
Skill AllanWessels/Bratan/docs/build-skills/observable-outcome-tests-over-mocks
Tests that mock the layer where the bug lives cannot catch bugs at that layer. Write tests that drive the same code path the user drives, ending at something the user can see.From its SKILL.md
npx -y skills add AllanWessels/Bratan --skill observable-outcome-tests-over-mocksAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
6.7 KB, ~1.5k tokens by cl100k_base, as published. Nobody here has run it
Observable Outcome Tests Over Mocks
When to use
- A bug escaped a "passing" test suite — the test mocked the layer the bug lives in.
- You are writing a test for a component that depends on a hook or API call.
- You need to verify cross-component behavior (component A's state should refresh component B's display via a shared cache or query client).
- You are auditing an existing test suite for structural blind spots.
When NOT to use
- The mock is for an external service with no hermetic alternative (network call to a third-party API, hardware probe). In that case, mock at the network boundary (msw / httpx mock transport), not at the hook level.
- Unit-testing a pure function with no component coupling.
- The test's goal is to verify the shape of the payload sent to an API (wire-shape coverage) — mocking the API response is appropriate here, as long as you also have an observable-outcome test at the integration level.
How to apply
The three observable-outcome patterns
1. Cross-query invalidation
Problem: Component A mutates data; component B should re-render with the
new data via a shared query cache. Tests that mock useComponentA and
useComponentB independently cannot catch the coupling.
Fix: Render both components in the same test with a real QueryClient and
a stubbed HTTP transport:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
const server = setupServer(
rest.post("/api/ingest", (req, res, ctx) => res(ctx.json({ ok: true }))),
rest.get("/api/corpus", (req, res, ctx) =>
res(ctx.json({ files: [{ id: "f1", n_chunks: 42 }] }))
),
);
test("ingest → corpus list shows chunk count", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<IngestButton fileId="f1" />
<CorpusList />
</QueryClientProvider>
);
await userEvent.click(screen.getByRole("button", { name: /ingest/i }));
// Observable outcome: the user sees the chunk count, not the internal state
await waitFor(() =>
expect(screen.getByText("42 chunks")).toBeInTheDocument()
);
});
2. Observable persistence
Problem: "Ingest succeeds but list_corpus reports false" — the mutation succeeds, the test asserts the mutation was called, but nothing asserts that the list reflects the change.
Fix: Assert the post-action visible state, not the call count:
// BAD — asserts internal behavior, not user-visible outcome
expect(startIngest.mutate).toHaveBeenCalledTimes(1);
// GOOD — asserts what the user sees after the action completes
await waitFor(() =>
expect(screen.getByTestId("corpus-row-f1")).toHaveTextContent("42 chunks")
);
3. Empty-state / gating assertions
Problem: Tests skip directly to the "after action" state. The user-reported bug is in the "before action" state (misleading labels, broken placeholders, incorrectly-enabled controls).
Fix: Assert BOTH states — empty/locked state before the action AND the enabled state after:
test("writing surface is locked until a passage is anchored", async () => {
render(<CaseWizard />);
// EMPTY STATE: user sees this first
expect(screen.getByTestId("empty-state-no-anchor")).toBeVisible();
expect(screen.queryByLabelText(/question/i)).not.toBeInTheDocument();
// ACTION: user anchors a passage
await userEvent.click(screen.getByTestId("passage-item-1"));
// ENABLED STATE: now the writing surface appears
expect(screen.queryByTestId("empty-state-no-anchor")).not.toBeInTheDocument();
expect(screen.getByLabelText(/question/i)).toBeEnabled();
});
Playwright for cross-component / cross-route flows
Some interactions cannot be tested at the unit level without mocking the very thing you need to test. Promote these to Playwright:
- Multi-step wizard state that must persist across route navigations.
- Config changes in step N that affect the UI in step M.
- Ingest → corpus browser refresh (mutation → cache invalidation → re-render).
- Authoring → file-on-disk persistence.
Playwright spec template (minimum viable assertions):
test("<feature> — full state machine", async ({ page }) => {
await resetAppState(); // hermetic start
await page.goto("/<route>");
// 1. EMPTY state — assert before any action
await expect(page.getByTestId("<feature>-empty-state")).toBeVisible();
// 2. ACTION
await page.getByRole("button", { name: /<cta>/i }).click();
// 3. LOADING → DONE transition
await expect(page.getByTestId("<feature>-loading")).toBeVisible();
await expect(page.getByTestId("<feature>-loading")).toBeHidden();
// 4. ENABLED state
await expect(page.getByLabel(/<input>/i)).toBeEnabled();
// 5. PERSISTENCE — survive a reload
await page.reload();
await expect(page.getByLabel(/<input>/i)).toHaveValue("expected");
// 6. ERROR PATH — force failure, assert humane copy
// 7. RECOVERY — fix failure, assert error UX clears
});
Why this works
A test that mocks a hook exercises the test's wiring, not the component's wiring. The bug you are trying to catch is in the coupling between the component and the hook — that coupling is exactly what the mock removes. Observable-outcome tests are harder to write and slower to run, but they test the contract the user has with the system, not the contract the test author has with the mock.
Anti-patterns to avoid
vi.hoistedmocking every hook — if every dependency is mocked, the test proves the component renders correctly when all its dependencies cooperate. That is not a useful property.- Asserting call counts instead of visible state —
expect(fn).toHaveBeenCalledTimes(1)tells you the function ran. It does not tell you what the user sees. - Tests that only assert the "after" state — the bug is often in the "before" state. Always assert both.
- Ad-hoc verifier Playwright specs that don't run in CI — a spec that only runs manually against a dev server is not part of the test suite. Fold it into CI or delete it.
Cross-links
- [[audit-then-fanout-fix]] — how to systematically find mock-blind-spot gaps before writing the replacement tests
- [[fix-first-then-test]] — observable-outcome failures reveal real bugs; fix the code before re-running
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.