Test Scope Definition for Frontend Components

Precise scope boundaries are the foundation of a reliable component test suite. This page is part of Component Testing Fundamentals, where it sits between the lower-level concern of how to isolate a component and the higher-level question of what to validate once you have isolated it.

Without explicit scope rules, teams end up with unit tests that secretly import a router, integration tests that hit live APIs, and visual suites that re-render the entire application on every commit. The result is a CI pipeline that is slow, noisy, and trusted by nobody.

Prerequisites

Before working through the steps below, confirm the following are in place:


How test scope tiers map to component complexity

Before writing a line of configuration, assign every component in the repository to one of three tiers:

Tier Example components Validation focus Typical runner
Atomic Button, Icon, Badge Props → render output, ARIA attributes Vitest unit
Molecular SearchBar, DatePicker, FormField Context consumers, controlled state, keyboard interaction Vitest integration
Organisational ProductGrid, CheckoutFlow, NavBar Route transitions, API-driven state, cross-component data flow Playwright or Vitest browser mode

This table is a heuristic, not a rule. A Button that reads from a theme context belongs in the molecular tier regardless of its visual simplicity.


SVG: Execution tier routing

Test scope tier routing Three horizontal swim lanes labelled Unit, Integration, and Visual Regression. Each lane shows which component types flow into it and what CI event triggers it. UNIT Atomic components Button · Icon · Badge vitest.unit.config.ts *.unit.test.{ts,tsx} Every commit push · pre-merge INTEGRATION Molecular components SearchBar · DatePicker vitest.integration.config.ts *.integration.test.{ts,tsx} PR open · affected only nx affected VISUAL REGRESSION Organisational ProductGrid · NavBar Chromatic / Playwright Storybook stories PR merge gate baseline approval required

Step-by-step implementation

Step 1 — Create separate Vitest configs for each tier

Intent: Route test files to the correct execution tier using file-naming conventions rather than manual exclusion lists. This prevents integration tests from running in the fast unit pass and vice versa.

// vitest.unit.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    name: 'unit',
    include: [
      '**/__tests__/**/*.unit.test.{ts,tsx}',
      '**/?(*.)unit.test.{ts,tsx}',
    ],
    // Exclude integration, visual, and story files from the unit pass
    exclude: [
      '**/node_modules/**',
      '**/dist/**',
      '**/*.integration.test.{ts,tsx}',
      '**/*.visual.test.{ts,tsx}',
    ],
    environment: 'jsdom',
    pool: 'forks',   // separate worker per file — no shared module registry
    setupFiles: ['./test/setup/unit.ts'],
  },
});
// vitest.integration.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    name: 'integration',
    include: ['**/?(*.)integration.test.{ts,tsx}'],
    exclude: ['**/node_modules/**', '**/dist/**'],
    environment: 'jsdom',
    pool: 'forks',
    setupFiles: ['./test/setup/integration.ts'],
    // Integration tests can be slower — raise the per-test timeout
    testTimeout: 10_000,
  },
});

Wire both into package.json scripts:

{
  "scripts": {
    "test:unit":        "vitest run --config vitest.unit.config.ts",
    "test:integration": "vitest run --config vitest.integration.config.ts",
    "test:visual":      "chromatic --build-script-name build:storybook --only-changed",
    "test:ci":          "npm run test:unit && npm run test:integration"
  }
}

Verify: Run npm run test:unit and confirm that no file ending in .integration.test.tsx appears in the output.


Step 2 — Set up deterministic isolation in each tier’s setup file

Intent: Every test file must start from a clean DOM, with all mocks restored and all timers reset. Using afterEach hooks in a shared setup file makes this automatic and impossible to forget. This is the foundation of the isolation principles that prevent cascade failures.

// test/setup/unit.ts
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';

afterEach(() => {
  cleanup();           // unmount React trees and remove DOM nodes
  vi.restoreAllMocks(); // reset all spy/stub implementations
  vi.useRealTimers();   // undo any vi.useFakeTimers() calls
});
// test/setup/integration.ts
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll, vi } from 'vitest';
import { server } from '../mocks/server'; // MSW node server

// Start MSW once for the entire integration suite
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

afterEach(() => {
  cleanup();
  vi.restoreAllMocks();
  vi.useRealTimers();
  server.resetHandlers(); // remove any per-test handler overrides
});

afterAll(() => server.close());

Verify: Add a failing test that calls vi.useFakeTimers() without cleanup, run the suite twice, and confirm no timeout warnings carry across runs.


Step 3 — Enforce dependency boundaries with MSW

Intent: Network and third-party integrations must be intercepted at the transport layer before component hydration. This is how you apply mock boundaries to scope out live API calls entirely. The onUnhandledRequest: 'error' option in Step 2 ensures that any request not explicitly declared in a handler fails loudly — exposing scope violations immediately.

// test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/v1/products', () => {
    return HttpResponse.json([
      { id: 'prod_1', name: 'Component Kit', status: 'active' },
      { id: 'prod_2', name: 'Icon Pack', status: 'draft' },
    ]);
  }),

  http.get('/api/v1/products/:id', ({ params }) => {
    return HttpResponse.json({ id: params.id, name: 'Component Kit', status: 'active' });
  }),

  // Explicit 401 to test error states
  http.get('/api/v1/user/profile', () => {
    return new HttpResponse(null, { status: 401 });
  }),
];
// test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

To test an error path in a single file without affecting other files:

// ProductGrid.integration.test.tsx
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';

test('shows empty state when API returns 404', async () => {
  // Override the default handler just for this test
  server.use(
    http.get('/api/v1/products', () => new HttpResponse(null, { status: 404 })),
  );

  render(<ProductGrid />);
  expect(await screen.findByText('No products found')).toBeInTheDocument();
  // server.resetHandlers() in afterEach restores the default
});

Verify: Run the integration suite with --reporter=verbose and confirm every test file shows “MSW: listening” in the setup output and no FetchError: connect ECONNREFUSED lines appear.


Step 4 — Add coverage thresholds that enforce scope completeness

Intent: Coverage thresholds tied to each tier’s config make scope regression visible in CI. A sudden drop in branch coverage on the unit config means someone moved logic into a component that only the integration pass exercises — a scope violation.

// vitest.unit.config.ts — add inside test: {}
coverage: {
  provider: 'v8',
  include: ['src/components/**/*.{ts,tsx}'],
  exclude: [
    '**/*.stories.{ts,tsx}',
    '**/*.integration.test.{ts,tsx}',
    '**/node_modules/**',
  ],
  thresholds: {
    lines:      85,
    branches:   80,
    functions:  85,
    statements: 85,
  },
},

Verify: Run npm run test:unit -- --coverage and check the final line. A passing run ends with no red threshold violations:

 % Coverage report from v8
 File                | % Stmts | % Branch | % Funcs | % Lines
 Button.tsx          |   100   |   100    |   100   |   100
 SearchBar.tsx       |   91.3  |   87.5   |   90.0  |   91.3
 All files           |   88.4  |   82.1   |   87.2  |   88.4
✓ Coverage thresholds met

Step 5 — Configure CI gating with a tiered matrix

Intent: Run unit tests on every push for fast feedback, integration tests on every PR, and visual regression only when Storybook stories change. Failing to gate each tier independently means either slow push feedback or missed regressions on merge.

# .github/workflows/test-scope.yml
name: Component Test Scope Gates
on:
  push:
    branches: [main]
  pull_request:

jobs:
  unit:
    name: Unit tests (Node ${{ matrix.node }})
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [18, 20]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '${{ matrix.node }}' }
      - run: npm ci
      - run: npx vitest run --config vitest.unit.config.ts --coverage
        env:
          TZ: UTC   # deterministic date/time assertions
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: unit-coverage-${{ matrix.node }}
          path: coverage/

  integration:
    name: Integration tests
    runs-on: ubuntu-latest
    needs: unit   # only run if unit pass succeeds
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npx vitest run --config vitest.integration.config.ts
        env:
          TZ: UTC
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: integration-results
          path: test-results/

  visual:
    name: Visual regression
    runs-on: ubuntu-latest
    needs: integration
    # Only fire when Storybook source or stories change
    if: |
      contains(github.event.head_commit.modified, '.stories.') ||
      contains(github.event.head_commit.modified, 'storybook/')
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }  # Chromatic needs git history for baselines
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npx chromatic --project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }} --only-changed --exit-once-uploaded

Verify: Open the Actions tab after a push that touches only a non-story file. The visual job should be skipped (if evaluates to false). After a PR that modifies a .stories.tsx file, all three jobs should run in sequence.


Configuration reference

Option Type Default Effect
test.pool 'threads' | 'forks' | 'vmForks' 'forks' 'forks' gives each file a separate process — safest for components that mutate globals
test.environment 'jsdom' | 'happy-dom' | 'node' 'node' Must be 'jsdom' or 'happy-dom' for any component that reads the DOM
test.setupFiles string[] [] Runs before each test file; use for global cleanup hooks and MSW server.listen()
test.testTimeout number 5000 Raise to 10000 for integration tests that wait for async renders
coverage.thresholds object none Enforced per-config — set lower thresholds on the integration config where full branch coverage is harder
coverage.provider 'v8' | 'istanbul' 'v8' v8 is faster; istanbul gives more granular branch data for TypeScript
onUnhandledRequest 'error' | 'warn' | 'bypass' 'warn' Set to 'error' in server.listen() to surface scope violations immediately

Common pitfalls

1. Forgetting server.resetHandlers() between tests

If you override an MSW handler inside a test and do not call server.resetHandlers() in afterEach, the override persists into the next test. The test after it may pass or fail depending on execution order — a classic source of flakiness. The integration setup file in Step 2 handles this automatically.

2. Using threads pool with components that touch window or document globals

Vitest’s threads pool shares the same Node.js process across test files. Components that attach listeners to window or mutate document.title will pollute other files. Switch to pool: 'forks' — each file gets a clean process.

3. Letting integration tests import from vitest.unit.config.ts include patterns

If your unit config uses **/*.test.{ts,tsx} as an include glob (too broad), integration test files match it and run in the unit pass — with no MSW server set up. Narrow to **/*.unit.test.{ts,tsx} and name files consistently.

4. Setting coverage thresholds on integration tests that hit MSW stubs

MSW stubs return fixed payloads, so the error-handling branches of API-driven components often go uncovered in integration tests. Either write explicit error-state tests (as shown in Step 3) or exclude the error paths from the integration coverage report — do not lower thresholds to compensate.

5. Running visual regression on every push

Uploading Storybook builds to Chromatic on every push burns through snapshot quota and slows PRs. Gate visual regression on story file changes (the if: condition in Step 5) and add --only-changed so Chromatic compares only stories whose files changed.


Integration points

This scope definition layer connects directly to the adjacent concerns in the testing workflow:

  • Isolation Principles — the dependency boundary rules you enforce here (forks pool, cleanup hooks, no real context) are the execution of isolation principles; read that page to understand why these boundaries exist.
  • Mock Boundaries — MSW handler design and lifecycle management is covered in depth there; this page shows only how to wire it into a scoped setup.
  • State Injection — once scope boundaries are defined, state injection determines what initial data each scoped test receives.
  • Defining test scope for UI component libraries — applies the tiering approach from this page to a published design system with multiple packages.

FAQ

What belongs in unit scope versus integration scope for a React component?

Unit scope covers props, derived state, and render output in complete isolation — no router, no real context, no network. If a component reads from ThemeContext, stub the context value explicitly; do not wrap it in the real provider. Integration scope introduces real context providers, routing, and at least one real child component, but still uses MSW stubs in place of live network calls. The dividing line is whether the test needs to assert on cross-component data flow — if yes, it is integration.

How do I stop tests from interfering with each other in a monorepo?

Use pool: 'forks' so each test file runs in its own worker process. Call cleanup() and vi.restoreAllMocks() in an afterEach hook in the setup file rather than inside individual tests — this makes isolation unconditional. Reset MSW handlers after every test with server.resetHandlers(). For monorepo packages, ensure each package’s Vitest config points to its own setupFiles so there is no cross-package shared state.

When should visual regression run, and when can I skip it?

Run visual regression on every PR that touches render output — layout, typography, colour, spacing, icon paths. Skip it for pure logic changes (reducers, custom hooks with no render side-effects) and for modifications to test files only. Automate this decision using the if: condition on the CI job rather than relying on developers to remember.

Why does nx affected pick up more packages than I expect?

nx affected traces the complete dependency graph. Touching a shared utility or a design token file marks every downstream package as affected. Narrow the blast radius by splitting frequently-changed utilities into their own package with a minimal, stable public API — then token changes no longer invalidate every component package in the graph.