# Test Pyramid Starter - what goes where, with stubs for *this* app

> **The rule that does the heavy lifting:** test each behaviour at the **lowest viable layer** (`QTEST-EARLY`), and test it **once** (`QTEST-NO-DUP`, `QUAL-PRINC-AUTOMATION` - no duplication). A bug in your gap-score maths is a *unit* bug - don't go hunting for it through a browser. Pushing tests down the pyramid is [Way #4 (small steps)](../../ways-of-working.md) plus `QUAL-PRINC-SHIFT-LEFT` in practice.
>
> Write the failing test **before** the implementation ([Way #1](../../ways-of-working.md)). The stubs below are illustrative - short on purpose. Wire them to the real files in [`../../../../stacks/nextjs/`](../../../../stacks/nextjs/) and [`../../../../stacks/spring-boot/`](../../../../stacks/spring-boot/).

> ### ✅ Already wired for you (Next.js) - copy these, don't bootstrap
> The harness is set up and **green out of the box** (`cd stacks/nextjs && npm install && npm test` → 7 passing). One runnable example per fast layer:
> - **Unit:** [`stacks/nextjs/tests/unit/utils.test.ts`](../../../../stacks/nextjs/tests/unit/utils.test.ts) - incl. a `gapScore` example.
> - **Contract:** [`stacks/nextjs/tests/contract/openapi.contract.test.ts`](../../../../stacks/nextjs/tests/contract/openapi.contract.test.ts) - loads `openapi.yaml`, validates a payload against `CompareResponse` with ajv. **Repoint it at your endpoint's JSON and add `RecommendResponse`.**
> - **E2E:** [`stacks/nextjs/tests/e2e/dashboard.spec.ts`](../../../../stacks/nextjs/tests/e2e/dashboard.spec.ts) - `npm run test:e2e` (first run: `npx playwright install chromium`).
>
> Spring Boot already has `spring-boot-starter-test` (JUnit5 + MockMvc) and a `json-schema-validator` test dependency in `pom.xml` for the contract layer.

---

## Which logic lives at which layer

| Layer (`QTEST-*`) | Put here | For `/recommend` & `/compare` that means | Tooling |
|---|---|---|---|
| **Unit** (`QTEST-UNIT`) | Pure functions, maths, sort/filter rules. The cheapest, fastest, most numerous tests. | `gapScore = 1 − value`; "average only `responded:true` dimensions"; "exclude completed"; "sort gapScore desc then stage". | Vitest (Next.js) · JUnit5 (Spring) |
| **Component** (`QTEST-COMPONENT`) | One module + its real collaborators in process (route handler + in-memory repo). | The route returns 5 items, correctly ordered, given a seeded fixture - without a running server. | Vitest (Next.js) · MockMvc (Spring) |
| **Contract** (`QTEST-CONTRACT`) | "Does the response match the published schema?" Catches drift between code and `openapi.yaml` (`REQ-API-7`, `REQ-API-2`). | Validate the `200` body against `RecommendResponse` / `CompareResponse`. | Vitest + a JSON-schema/OpenAPI validator · MockMvc + validator |
| **System / E2E** (`QTEST-SYSTEM`, `QTEST-E2E`) | One full happy path through the running app. Expensive - keep them few. | Open the page, see the ranked recommendations render. **One** path, not every permutation. | Playwright (Next.js) · JUnit + running server (Spring) |
| **Manual** (`QTEST-MANUAL`) | Only what can't be automated (exploratory, visual judgement). | A quick "does the radar overlay read correctly?" eyeball. | Human |

**Anti-duplication check** (`QTEST-NO-DUP`): the ordering rule is tested *once*, at the unit layer. The E2E test does **not** re-assert the exact order - it only proves the path works end to end. Don't pay for the same assertion twice.

---

## Stub 1 - UNIT: gap score + responded-only averaging (`QTEST-UNIT`, `QTEST-EARLY`)

The core maths. Seed facts: Alice's governance and business_impact are `responded:false` (value `0.0`) → both have `gapScore 1.0`. Platform team `ai_literacy` average is over **responded members only**: `(0.72 + 0.65 + 0.50) / 3 ≈ 0.62`.

**Next.js - Vitest**
```ts
// src/lib/recommend.test.ts
import { describe, it, expect } from 'vitest';
import { gapScore, teamAverage } from './scoring';

describe('gapScore', () => {
  it('is 1 - value for a responded dimension', () => {
    expect(gapScore({ value: 0.72, responded: true })).toBeCloseTo(0.28);
  });
  it('is 1.0 for an N/A dimension (Alice: governance, business_impact)', () => {
    expect(gapScore({ value: 0.0, responded: false })).toBe(1.0);
  });
});

describe('teamAverage', () => {
  it('averages ONLY responded:true members (Platform ai_literacy)', () => {
    const platform = [
      { value: 0.72, responded: true },  // Alice
      { value: 0.65, responded: true },  // Bob
      { value: 0.50, responded: true },  // Carol
    ];
    expect(teamAverage(platform)).toBeCloseTo(0.62, 2);
  });
  it('ignores N/A members rather than counting them as zero', () => {
    const dim = [
      { value: 0.40, responded: true },
      { value: 0.0,  responded: false }, // must NOT drag the average down
    ];
    expect(teamAverage(dim)).toBeCloseTo(0.40, 2);
  });
});
```

**Spring Boot - JUnit5**
```java
// src/test/java/com/rise/mini/ScoringTest.java
@Test void gapScore_isOneForNaDimension() {
    assertThat(Scoring.gapScore(0.0, /*responded*/ false)).isEqualTo(1.0);
}
@Test void teamAverage_countsOnlyRespondedMembers() {       // Platform ai_literacy
    var values = List.of(new Dim(0.72, true), new Dim(0.65, true), new Dim(0.50, true));
    assertThat(Scoring.teamAverage(values)).isCloseTo(0.62, within(0.01));
}
```

## Stub 2 - CONTRACT: response matches the OpenAPI schema (`QTEST-CONTRACT`, `REQ-API-7`)

Proves the implementation hasn't drifted from [`../../../../stacks/nextjs/openapi.yaml`](../../../../stacks/nextjs/openapi.yaml). Run it after you author `RecommendResponse` - it fails until code and contract agree (`REQ-API-2`).

**Next.js - Vitest + validator**
```ts
// src/app/api/recommend/recommend.contract.test.ts
import { describe, it, expect } from 'vitest';
import Ajv from 'ajv-formats-aware'; // or openapi-response-validator
import { GET } from './route';
import { schemaFor } from '../../../../test/openapi';

it('GET /recommend body validates against RecommendResponse', async () => {
  const res = await GET(new Request('http://test/api/recommend'));
  const body = await res.json();
  const validate = schemaFor('RecommendResponse'); // pulled from openapi.yaml
  expect(validate(body)).toBe(true);
  expect(body.recommendations).toHaveLength(5);     // contract: top 5
});
```

**Spring Boot - MockMvc + validator**
```java
@Test void recommend_matchesContract() throws Exception {
    var json = mockMvc.perform(get("/api/recommend"))
        .andExpect(status().isOk())
        .andReturn().getResponse().getContentAsString();
    OpenApiValidator.forSchema("RecommendResponse").validate(json); // throws on drift
}
```

## Stub 3 - E2E: exactly one happy path (`QTEST-E2E`)

The whole stack, once. Note it asserts the *path works and content renders* - it deliberately does **not** re-check the exact sort order (that's Stub 1's job - `QTEST-NO-DUP`).

**Next.js - Playwright**
```ts
// e2e/recommend.spec.ts
import { test, expect } from '@playwright/test';

test('Lessons page shows personalised recommendations for Alice', async ({ page }) => {
  await page.goto('http://localhost:3099/lessons');
  const rail = page.getByRole('region', { name: /recommended/i });
  await expect(rail.getByRole('listitem')).toHaveCount(5);  // top 5 rendered
  await expect(rail).toContainText('Responsible AI Usage');  // a governance gap lesson
  await expect(rail).not.toContainText('AI Security Basics'); // lesson-10 is completed
});
```

---

## Definition of Done for the Feature Sprint (`QUAL-PRINC-SDLC`)
A demo "counts" when, for your endpoint, you have: **1 unit test** (the maths), **1 contract test** (no drift), **1 E2E happy path** - and a human approved it (`ENG-PRIN-REVIEW-STMT`, [Way #2](../../ways-of-working.md)). That's the minimum that proves it works without wasting effort re-testing the same thing (`QUAL-PRINC-WASTE`).

*The five ways: [ways-of-working.md](../../ways-of-working.md).*
