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) plus QUAL-PRINC-SHIFT-LEFT in practice.

Write the failing test before the implementation (Way #1). The stubs below are illustrative - short on purpose. Wire them to the real files in ../../../../stacks/nextjs/ and ../../../../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:

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 hereFor /recommend & /compare that meansTooling
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

// 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

// 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. Run it after you author RecommendResponse - it fails until code and contract agree (REQ-API-2).

Next.js - Vitest + validator

// 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

@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

// 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). 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.

Downloads for this session

Grab the templates and sample files used here.