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) plusQUAL-PRINC-SHIFT-LEFTin 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:
- Unit:
stacks/nextjs/tests/unit/utils.test.ts- incl. agapScoreexample.- Contract:
stacks/nextjs/tests/contract/openapi.contract.test.ts- loadsopenapi.yaml, validates a payload againstCompareResponsewith ajv. Repoint it at your endpoint’s JSON and addRecommendResponse.- E2E:
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 ajson-schema-validatortest dependency inpom.xmlfor 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
// 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.