ADR-0017: Page Integration Tests
Accepted Cross-Project UniversalDate: 2026-03-30
Context
Frontend test suites across territories enforce 100% coverage via unit tests with heavy per-file mocking. Every page test stubs its child components — ModalDialog becomes <div><slot /></div>, FormField becomes <input />. This proves each page's logic works in isolation, but cannot prove the page works when real components compose together.
The failure mode is concrete and recurring: a developer renames a prop on a shared component, updates the component's unit test, and all tests pass. But pages that use that component are now broken — the stub never validated the prop contract. The developer pushes, CI passes, and the breakage is discovered manually in the browser.
This is the fundamental limitation of unit tests with mocked dependencies: they test each node in isolation but not the edges between nodes. The gap grows with the number of shared components and the number of pages that compose them.
Browser-based integration tests (Playwright, Cypress) are not the answer for this specific problem. They test the full stack including routing, API, and browser rendering — overkill for catching component composition issues, and too slow/flaky for a CI-enforced test suite.
Origin
This pattern was developed and proven in brick-inventory (BIO ADR-013, 2026-03-27). BIO's frontend has ~25 shared components and 18 domain pages. After implementing page integration tests, composition breakage that previously surfaced only in manual browser testing was caught at CI time. The pattern is framework-agnostic within the Vue 3 + Vitest ecosystem.
Decision
A separate integration test suite for domain pages
A new test layer that mounts each domain page with real child components and mocked services/HTTP. The distinction from unit tests:
| Concern | Unit tests (existing) | Page integration tests (new) |
|---|---|---|
| Child components | Stubbed/mocked | Real |
| Services (HTTP, auth, router) | Mocked | Mocked |
| Mounting strategy | shallowMount | mount |
| What they prove | Page logic in isolation | Component composition works |
| Coverage accounting | Counts toward 100% threshold | Separate — does not count |
What gets a page integration test
Domain page components — the pages under each domain's pages/ directory (per ADR-0014). These are the composition points where shared components meet domain logic. Rollout is phased by shared component usage count: pages that compose the most shared components are tested first, as they have the largest prop/slot/event contract surface. The metric is straightforward — count shared component imports per page file, sort descending, work top-down. The goal is full coverage; the phasing is prioritization, not scoping.
What to test
Page integration tests verify composition correctness, not business logic:
- Child components render with correct props (the prop contract is honored)
- Slots are populated and display expected content
- Events flow correctly between child and parent
- Conditional rendering shows/hides the right components based on state
- Loading/error/empty states render the correct component tree
What NOT to test
- Detailed business logic (unit tests cover this)
- Individual component internals (unit tests cover this)
- HTTP request/response details (unit tests cover this)
- Browser-native API behavior (browser tests or E2E cover this)
Mocking strategy
Mock only the HTTP transport layer:
The mock boundary sits at the HTTP transport layer — not the service layer. An in-memory mock-server implements the HttpService interface, replacing network transport while letting everything above it run real:
- Real: Stores (adapter-store hydration, reactive wiring, Object.freeze, caching), translation service (returns actual rendered text), router service (runs in happy-dom), auth service (login flow through mock-server), camelCase transforms
- Mocked: HTTP transport (
createHttpServicereturns mock-server-backed instance) - Retained non-HTTP mocks (case-by-case): Browser APIs not available in happy-dom (e.g., barcode-detector), file system helpers (CSV export), complex modals with their own HTTP chains
Test fixtures use snake_case matching the real API response format. The store's retrieveAll() calls the mock-server, receives snake_case data, and applies toCamelCaseTyped() — exercising the full transform pipeline. Assertions verify user-visible text (e.g., "Storage") rather than translation keys (e.g., "storage.title").
Do NOT mock:
- Shared UI components (modals, buttons, badges, form fields, etc.)
- Domain-specific child components
- Stores, translation services, router services, or auth services
- Icon components (use Vite resolve.alias to a lightweight stub if barrel import performance is prohibitive)
Async component support
Pages with top-level await (async <script setup>) require a Suspense wrapper for mounting. Territories should provide an asyncMount helper alongside the existing asyncShallowMount:
export const asyncMount = async <T extends Component>(
testComponent: T,
options?: MountingOptions<any>
) => {
const wrapper = mount(
defineComponent({
render: () => h(Suspense, [h(testComponent, options?.props ?? {})]),
}),
{ global: options?.global }
);
await flushPromises();
return wrapper.getComponent(testComponent);
};Mock system isolation
Integration tests must not inherit the unit test mock infrastructure. Territories with centralized auto-mocking (e.g., kendo's 3-tier mock system where setup.ts imports shared mock lists that globally stub all shared components) must ensure the integration vitest config uses a separate setup file that does not import those mock lists.
The integration setup file contains only browser API polyfills (e.g., HTMLDialogElement.showModal, HTMLMediaElement.readyState) and optional test helpers (e.g., data-test query helpers). It does not mock any services, stores, or components. The single vi.mock("@script-development/fs-http") transport mock lives in each test file, not the setup file — keeping mocks explicit and self-documenting per test.
vitest.integration.config.ts
→ setup: './tests/integration/setup.ts' # Browser API polyfills only
→ alias: '@integration' → test helpers # Mock-server, async-mount
→ fileParallelism: false # Prevents EnvironmentTeardownError
→ does NOT import: './tests/mocks/lists/' # No shared component stubs
vitest.config.ts (unit tests, unchanged)
→ setup: './tests/js/setup.ts' # Full mock lists as beforeFile organization
Integration tests live in a separate directory, mirroring the domain structure:
tests/integration/
├── setup.ts # Service-boundary mocks only
└── apps/[app]/domains/
└── [domain]/pages/
└── [Page].spec.tsA separate Vitest config (e.g., vitest.integration.config.ts) with its own project definitions and its own setup file. New npm scripts for running integration tests independently.
Coverage accounting
Page integration tests are a separate suite that does not contribute to the 100% unit coverage threshold. This is deliberate:
- The 100% threshold drives developers to write unit tests for every file. If integration tests counted, a developer could skip a unit test because "the integration test covers that line."
- Integration tests exist to catch composition failures, not to prove coverage. A line "covered" by an integration test that mounts 15 components deep is not meaningfully tested — it's incidentally executed.
Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Rely on unit tests alone | Rejected | Cannot catch prop/slot/event contract mismatches between parent and child. The exact failure mode observed in production. |
| E2E tests (Cypress/Playwright against running app) | Rejected | Slow, flaky, requires backend. Overkill for catching component composition issues. |
| Browser integration tests for all pages (Vitest Browser Mode) | Rejected | 2-10x slower than happy-dom. Setup overhead (Playwright deps, WSL2 system packages). Most pages don't use browser-native APIs. |
| Page integration tests in happy-dom with real components, mocked services | Accepted | Directly addresses the gap at minimal infrastructure cost. Same speed class as unit tests. No browser infrastructure needed. |
Consequences
Positive
- Catches the specific failure mode — prop renames, slot changes, and event contract breaks are caught before they reach a browser
- Low infrastructure cost — happy-dom, same Vitest setup, no Playwright needed
- Phased rollout — start with highest-risk pages, expand based on evidence
- Works with existing async component helpers (
asyncMountcompanion toasyncShallowMount)
Negative
- Pages now have two test files each (unit + integration) — accepted cost, they test different things
- Slower than unit tests — real component trees are heavier than stubs
- New pattern for allies to learn — "when do I write a unit test vs an integration test?"
Risks
- Icon library barrel import performance — Large icon packages (e.g.,
@phosphor-icons/vuewith 4500+ exports) add significant collect time per file. Mitigated by Vite resolve.alias to lightweight stubs in the integration config. - Threshold calibration — Test-guard reporters calibrated for unit tests may need separate thresholds for integration tests.
- Pattern misuse — Developers might write integration tests instead of unit tests, eroding isolation discipline. Mitigated by separate coverage accounting — the 100% unit threshold still requires unit tests.
Resolved Questions
How do territories with centralized auto-mocking handle integration tests?
Resolved 2026-03-30. Integration tests use a separate Vitest config with a separate setup file that does not import the unit test mock lists. Unit test setup stubs everything for isolation; integration test setup stubs only service boundaries. This is a config-level separation, not a per-test decision. See "Mock system isolation" section above.
What defines "highest-risk pages" for phased rollout?
Resolved 2026-03-30. Shared component usage count — count shared component imports per page file, sort descending, work top-down. Simple, measurable, no judgment calls. The metric is a prioritization tool; the goal is full coverage within one dedicated sprint.
Enforcement
| What | Mechanism | Scope |
|---|---|---|
| Every domain page has an integration test | Architecture test | All **/domains/*/pages/*.vue files |
| Integration tests use real child components | Lint rule or code review prohibiting vi.mock() of shared component paths | All integration test files |
| Services are mocked (no real HTTP) | Test-guard reporter catches slow tests from real I/O | All integration test files |
| Separate coverage accounting | Separate vitest config with own coverage settings | Integration vitest config |
Implementation
| Territory | State | Notes |
|---|---|---|
| brick-inventory | Complete | BIO ADR-013. 18 domain pages, 17 integration tests. Mock-server pattern (2026-03-31): in-memory HTTP service replaces transport layer, all stores/translation/router/auth run real. fileParallelism: false. Vite alias for icon stubs + @integration for test helpers. Architecture test enforces coverage. |
| kendo | Not Started | 43 domain pages (32 tenant + 11 central). asyncShallowMount helper exists (21 usages). Phased rollout by shared component usage count, targeting 1 dedicated sprint. |
| entreezuil | In Progress | 12/12 pages tested (65 tests, PR #43 merged 2026-04-15). Stripped-down mock-server: no response middleware (entreezuil uses snake_case on both sides, no case transform needed). One router-mock residual on ResetPassword.spec.ts — coupled to a vue-router mock for useRoute() params, not the circular dep (circular dep was resolved by PR #44's composable migration). |