# Strong-Assertion Pattern for Journey Tests

This is the template for fixing the weak-assertion problem across the 11
journey-level Playwright tests in `tests/e2e/journeys/`. The pattern was
extracted while strengthening `analysis-recommendations.spec.ts` on
2026-05-10. Apply it iteratively to the other journey files.

## The problem we're fixing

The journey tests catch a lot of "page renders without crashing" failures.
They miss most of the bugs you actually care about: AI-generated code that
returns the wrong entity, computes the wrong number, swaps two fields,
or silently passes empty data through a controller.

Concrete examples of weak assertions found in v1 of the journey tests:

```typescript
// ❌ Passes if "Rewrite content" appears anywhere on the page —
//    sidebar, breadcrumb, help text, an unrelated recommendation.
await expect(page.locator('body')).toContainText('Rewrite content');

// ❌ Passes if any positive integer appears on the page. Always true.
//    Footer year, sidebar counts, currency, version numbers.
const pageText = await page.innerText('body');
expect(/\b[1-9]\d*\b/.test(pageText)).toBe(true);

// ❌ Asserts the page rendered some kind of "content" word. Useless.
await expect(page.locator('body')).toContainText('content');
```

A test built on these assertions is **performative**: it always passes,
so it always inspires confidence, but it catches no bugs. Worse than no
test, because it occupies a slot in your mental model of what's covered.

## The five strengthening rules

### 1. Replace `body.toContainText` with role-scoped assertions

The body of an Inertia page contains *everything* — chrome, sidebars,
breadcrumbs, footer, help text, announcement banners. Asserting on body
text catches almost nothing.

```typescript
// ❌ Loose
await expect(page.locator('body')).toContainText('Search engine optimization');

// ✅ Scope to the role + accessible name
await expect(
  page.getByRole('heading', { level: 1, name: SEEDED_RECOMMENDATION_TITLE })
).toBeVisible();

// ✅ Or scope by the unique aria-label that contains the seeded value
await expect(
  page.getByRole('checkbox', {
    name: new RegExp(`Select recommendation:\\s*${escapeRegex(SEEDED_TITLE)}`)
  })
).toBeVisible();
```

This makes the assertion fail loudly if the title is silently rendered
in a fallback location (e.g. as a tooltip while the main element is
empty), or if the wrong recommendation is loaded entirely.

### 2. Assert exact seeded values, not loose patterns

If the seed creates exactly **one** recommendation, assert exactly **one**,
not "at least one" or "any positive integer."

```typescript
// ❌ Always passes
expect(/\b[1-9]\d*\b/.test(pageText)).toBe(true);

// ✅ The UI says "You have 1 recommendation ready" or
//    "You have N recommendations ready". Assert the singular form
//    when seeded count is 1, plural when ≥ 2.
const expectedRegex =
  SEEDED_COUNT === 1
    ? /You have 1 recommendation ready/i
    : new RegExp(`You have ${SEEDED_COUNT} recommendations ready`, 'i');
await expect(page.getByText(expectedRegex).first()).toBeVisible();
```

Pluralization is a strong discriminator — a controller bug that returns
0 or 2 recommendations would now fail the assertion in a specific way.

### 3. Add a NEGATIVE assertion alongside every positive one

Every "this should be visible" assertion benefits from a paired
"this should NOT be visible." A failing controller often produces
*both* the empty state AND a misleading partial render.

```typescript
// ✅ Positive: the recommendation is shown
await expect(checkbox).toBeVisible();

// ✅ Negative: the empty-state copy must not be shown at the same time
await expect(page.getByText(/no recommendations/i).first()).toBeHidden();

// ✅ Negative: no global error banner
await expect(
  page.getByRole('alert').filter({ hasText: /error|failed/i }).first()
).toBeHidden();
```

The negative assertions catch:
- Empty-state shown alongside data (rare but possible — happens when
  counts and the data array drift apart in the controller)
- Inertia error overlays from a closure prop throwing
- Partial renders where the success path coexists with a failure path

### 4. Verify navigation outcomes by content, not just URL

A controller bug that returns the wrong entity for the right URL would
pass a URL-only assertion. Assert on what's *on the destination page*.

```typescript
// ❌ Only checks URL
await expect(page).toHaveURL(/\/sites\/\d+\/recommendations\/\d+/);

// ✅ URL + entity content on the destination page
await expect(page).toHaveURL(
  new RegExp(`/sites/${seedData.siteId}/recommendations/${seedData.recommendationId}\\b`)
);
await expect(
  page.getByRole('heading', { level: 1, name: SEEDED_TITLE })
).toBeVisible();
await expect(
  page.getByText(SEEDED_PAGE_URL, { exact: true }).first()
).toBeVisible();
```

The `\\b` word boundary in the URL regex is important: without it,
`recommendations/1` would also match `recommendations/12345`.

### 5. Embed the discriminating-mutation in a comment

Every strengthened assertion should explicitly name at least one
production-code mutation that would now fail it but would NOT have
failed under the weak version. This makes the intent self-documenting
and prevents future-you from "simplifying" the assertion back into
performative weakness.

```typescript
// STRENGTHENED: catches a controller bug where draft.recommendation
// eager-load is missing → component falls back to "Edit Draft #N",
// which the v1 substring assertion still matched.
await expect(
  page.getByRole('heading', { level: 1, name: SEEDED_TITLE })
).toBeVisible();
```

If you can't articulate a mutation the assertion catches, the
assertion is probably weak.

## A discriminating-power checklist

For each test you strengthen, ask:

- Does it assert on a value that came directly from the seeded entity?
  (Title, URL, ID, specific status, specific impact score range)
- Could a controller silently swap one entity for another and still pass?
- Could a typo or off-by-one in pagination still pass?
- Could the empty-state being rendered alongside data still pass?
- Could an unhandled-promise rejection in a closure prop still pass?

If the answer to any of the last four is "yes," the test is still weak
and needs another assertion.

## What to extract as named constants

Pull seeded values into top-of-file constants so the test reads like a
spec, and so a single seed change fails *every* assertion that depends
on it (loud failure, easy to fix):

```typescript
const SEEDED_RECOMMENDATION_TITLE = 'Rewrite content to recover lost traffic';
const SEEDED_RECOMMENDATION_PAGE_URL = 'https://e2e-test.example.com/blog/seo-tips';
const SEEDED_REASONING_FRAGMENT = 'This page lost 29% of clicks';
const SEEDED_DRAFT_LEAD_TEXT = 'Search engine optimization is evolving rapidly in 2024';
const SEEDED_RECOMMENDATION_COUNT = 1;
```

If `E2ESeedCommand` changes the seeded title, every test referencing the
constant fails with a clear name in the failure message. That's the
behavior we want — the test surfaces the drift loudly.

## What NOT to do

- **Don't snapshot HTML.** Snapshot tests look strong but fail on
  every CSS change and you'll start blindly accepting diffs. Snapshot
  data structures (e.g. JSON returned from an API) — not rendered HTML.

- **Don't use `waitFor(() => true)` or arbitrary `setTimeout`.** Use
  `waitForLoadState('networkidle')` or scoped `expect(...).toBeVisible()`
  with Playwright's built-in retry semantics.

- **Don't filter console errors by including the application's domain
  or generic "error" keywords.** Only filter known third-party noise
  (browser extensions). A generic filter masks real bugs.

- **Don't assert on element COUNTS unless the count is part of the
  contract.** `getByRole('listitem').count()` is brittle to chrome
  changes; `expect(page.getByText(SEEDED_TITLE).first()).toBeVisible()`
  is precise about intent.

- **Don't add new `data-testid` attributes to production code just for
  testing.** The codebase already exposes structure via roles and
  aria-labels (e.g. `aria-label="Select recommendation: ..."`). Use
  those. New `data-testid`s coupling tests to implementation are a
  one-way ratchet toward fragile tests.

## Suggested rollout order across the 11 journey tests

Strongest expected ROI first. Each item is ~1 hour of focused work
once you've internalized the pattern.

| # | File | Why this order |
|---|---|---|
| 1 | `analysis-recommendations.spec.ts` | ✅ Done 2026-05-10. The other 10 follow this template. |
| 2 | `onboarding.spec.ts` | First-time experience; AI bugs here block ALL new users. |
| 3 | `draft-publishing.spec.ts` | Final value-delivery step; bugs here mean users do work and get nothing. |
| 4 | `gsc-sync.spec.ts` | Entry point of all data; sync bugs corrupt downstream analysis. |
| 5 | `roi-dashboard.spec.ts` | The value-prop demonstration; broken here = customer churn. |
| 6 | `content-editor.spec.ts` | Tightly coupled to AI-written editor logic — high mutation surface. |
| 7 | `opportunity-map.spec.ts` | Aggregator of multiple detector outputs; cross-feature bugs. |
| 8 | `error-scenarios.spec.ts` | Already negative-assertion-focused; needs less rework. |
| 9 | `settings-flow.spec.ts` | Lower stakes per-flow but high frequency of use. |
| 10 | `team-members.spec.ts` | Isolated subsystem; lower priority. |
| 11 | `email-unsubscribe.spec.ts` | Lowest stakes; one-off compliance flow. |

After strengthening each file, run it locally with the production code
mutated in one obvious way (e.g. delete a controller's eager load,
return a different entity). The test should fail with a clear message.
If it doesn't, the strengthening missed something — add another
discriminating assertion.

## When to revisit this pattern

This document is living. Update it when:

- A new pattern of weak assertion shows up that the five rules don't
  cover (add a sixth rule).
- A strengthened assertion proves brittle (false positive in CI) — add
  guidance on the brittleness pattern.
- The seeded data shape changes — update the constants section to point
  at the new canonical seed values.
