Skip to content

ADR-0017: Page Integration Tests

Accepted Cross-Project Universal

Date: 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:

ConcernUnit tests (existing)Page integration tests (new)
Child componentsStubbed/mockedReal
Services (HTTP, auth, router)MockedMocked
Mounting strategyshallowMountmount
What they provePage logic in isolationComponent composition works
Coverage accountingCounts toward 100% thresholdSeparate — 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 (createHttpService returns 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:

typescript
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 before

File 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.ts

A 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

OptionVerdictReason
Rely on unit tests aloneRejectedCannot catch prop/slot/event contract mismatches between parent and child. The exact failure mode observed in production.
E2E tests (Cypress/Playwright against running app)RejectedSlow, flaky, requires backend. Overkill for catching component composition issues.
Browser integration tests for all pages (Vitest Browser Mode)Rejected2-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 servicesAcceptedDirectly 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 (asyncMount companion to asyncShallowMount)

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

WhatMechanismScope
Every domain page has an integration testArchitecture testAll **/domains/*/pages/*.vue files
Integration tests use real child componentsLint rule or code review prohibiting vi.mock() of shared component pathsAll integration test files
Services are mocked (no real HTTP)Test-guard reporter catches slow tests from real I/OAll integration test files
Separate coverage accountingSeparate vitest config with own coverage settingsIntegration vitest config

Implementation

TerritoryStateNotes
brick-inventoryCompleteBIO 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.
kendoNot Started43 domain pages (32 tenant + 11 central). asyncShallowMount helper exists (21 usages). Phased rollout by shared component usage count, targeting 1 dedicated sprint.
entreezuilIn Progress12/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).

Architecture documentation for contributors and collaborators.