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
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.
Related
- Component Testing Fundamentals — parent section covering the full testing workflow from isolation to CI
- Isolation Principles — how to prevent context leakage and global state mutations in component tests
- Mock Boundaries — MSW handler design, lifecycle management, and transport-layer interception
- State Injection — supplying deterministic initial state to components inside a scoped test environment
- Defining test scope for UI component libraries — applying these patterns to a multi-package design system