Defining test scope for UI component libraries
Your Vitest suite passes locally but fails non-deterministically in CI — or a seemingly unrelated refactor makes a dozen snapshot tests go red. These are the fingerprints of undefined test scope: the suite is testing more than you intended, and the excess is fragile.
This page sits under Test Scope Definition, part of Component Testing Fundamentals. It focuses on the concrete problem of scope drift inside a shared component library: how it starts, how to reproduce the exact failure, and how to eliminate it for good.
Problem statement
Symptom you searched for: “Vitest tests pass locally but fail in CI with inconsistent snapshot diffs” or “component test validates child components I did not render directly.”
When a component library ships without explicit scope contracts, each test file accumulates implicit dependencies — theme providers, query clients, global stores — that were never declared. On a developer’s machine those providers happen to be initialized correctly by a top-level app wrapper that also runs during local development. In CI, where no app shell exists, the providers are either absent or re-initialized per worker, producing different rendering output every run.
Root cause explanation
Scope drift originates in the gap between how a component is used in production (wrapped in a full provider tree) and how it is rendered in isolation during testing (no wrapper, or a partial one). When mock boundaries are left implicit, the test runner inherits whatever module-level singletons happen to be alive in the process — QueryClient caches, ThemeContext defaults, i18n locale strings — and those singletons carry different values depending on import order, worker process identity, and test file execution sequence.
The Vitest threads pool (the default) shares a single Node.js process across test files, meaning a QueryClient hydrated by test-file A is still warm when test-file B runs. Switching to pool: 'forks' fixes the process boundary, but it does not fix the root issue: context should never be implicit in the first place. See state injection patterns for the full treatment.
Scope drift boundary diagram
The diagram below shows the two render environments side by side. In production a component sits inside an application shell that provides all context. In testing with an undefined scope boundary, context leaks in from whichever module happened to initialize first.
Minimal reproduction
This snippet demonstrates the failure. A UserAvatar component reads locale from i18n context. Without an explicit wrapper, the test inherits whatever the module initializer set:
// UserAvatar.test.tsx — reproduces the drift
import { render, screen } from '@testing-library/react';
import { UserAvatar } from '../src/UserAvatar';
test('renders display name', () => {
// No provider wrapper — i18n context is inherited from module state.
// Passes locally (app shell ran first), fails in CI worker 2 (no app shell).
render(<UserAvatar userId="u-001" />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
Running this in a fresh CI worker where no app shell has bootstrapped i18n context causes UserAvatar to fall back to a raw key string (user.displayName) instead of the resolved name, failing the assertion.
Step-by-step fix
Step 1 — Switch Vitest to process isolation
Prevent module-level singletons from crossing file boundaries by running each test file in its own process:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: false,
pool: 'forks', // each file gets a clean Node.js process
snapshotFormat: {
printBasicPrototype: false,
escapeString: true,
},
},
});
What this does: pool: 'forks' replaces the default threads pool. Every test file now starts with a freshly initialized module registry, so no state from a previous file can contaminate the next one.
Step 2 — Create a deterministic render wrapper
Replace ad-hoc provider wrapping with a single, well-typed utility that every test file imports. This makes the scope contract explicit and auditable:
// test/setup/render-with-scope.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ThemeProvider } from '@design-system/tokens';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { I18nProvider } from '@design-system/i18n';
// Fresh client per call — never share a QueryClient between tests.
const makeClient = () =>
new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
export const renderWithScope = (
ui: React.ReactElement,
options: Omit<RenderOptions, 'wrapper'> = {}
) => {
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<QueryClientProvider client={makeClient()}>
<I18nProvider locale="en-US">
<ThemeProvider theme="light">{children}</ThemeProvider>
</I18nProvider>
</QueryClientProvider>
);
return render(ui, { wrapper: Wrapper, ...options });
};
What this does: every context provider is explicitly listed, every provider receives a deterministic value, and the QueryClient is created fresh per render call so no cached query data leaks between tests.
Step 3 — Enforce the boundary with an ESLint rule
Static analysis catches scope violations before they reach CI. Add a custom rule that blocks direct imports of global providers inside test files:
// eslint-plugin-test-scope.cjs
module.exports = {
rules: {
'no-implicit-context-import': {
meta: {
type: 'problem',
docs: {
description: 'Disallow importing global context providers directly in test files; use renderWithScope instead.',
},
},
create(context) {
return {
ImportDeclaration(node) {
const banned = /global-store|AppContext|ThemeProvider|I18nProvider/;
if (banned.test(node.source.value)) {
context.report({
node,
message:
'Import renderWithScope from test/setup/render-with-scope instead of pulling in providers directly.',
});
}
},
};
},
},
},
};
What this does: any test file that imports a provider by name will fail the lint step, keeping renderWithScope as the single entry point for context setup.
Step 4 — Gate scope violations in CI
Run isolated unit tests and visual regression suites in parallel so a scope boundary failure in one shard does not block unrelated visual checks:
# .github/workflows/component-tests.yml
name: Component Test Matrix
on: [pull_request]
jobs:
test-isolated:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx vitest run --shard=${{ matrix.shard }}/4 --coverage
env:
CI: true
test-visual:
runs-on: ubuntu-latest
needs: test-isolated # only run visual suite if unit scope is clean
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --retries=2 --workers=4
What this does: the needs: test-isolated dependency means the pipeline fails fast on scope violations before burning CI minutes on baseline management screenshot comparisons.
Verification
After applying the fix, run the suite and confirm every file starts with a clean module registry:
npx vitest run --reporter=verbose 2>&1 | grep -E "(PASS|FAIL|leaked)"
Expected output — no file should report a leaked singleton warning:
✓ src/components/UserAvatar/UserAvatar.test.tsx (3 tests) 142ms
✓ src/components/Button/Button.test.tsx (5 tests) 98ms
✓ src/components/Modal/Modal.test.tsx (4 tests) 201ms
Test Files 3 passed (3)
Tests 12 passed (12)
To confirm the ESLint rule is active:
npx eslint 'src/**/*.test.{ts,tsx}' --rule '{"test-scope/no-implicit-context-import": "error"}'
A clean library produces zero violations. If violations appear, replace direct provider imports with renderWithScope.
Edge cases and caveats
-
React 18 concurrent mode and
act()warnings. In React 18, state updates inside providers wrapped with concurrent features may produceact()warnings even inforksmode. Wrap state-triggering interactions withawait act(async () => { ... })in therenderWithScopehelper to silence false positives without suppressing real failures. -
Turborepo / pnpm workspace caches. Monorepo task caches can persist a stale
test/setupbuild across workspaces if the cache key does not include the setup file hash. Addtest/setup/**to yourturbo.jsoninputsarray so a change torender-with-scope.tsxcorrectly invalidates downstream test caches. -
MSW handler reset. The
renderWithScopewrapper handles context isolation, but mock boundaries for network requests need a separate teardown. Callserver.resetHandlers()inafterEachto prevent handler state from leaking between tests within the same file —pool: 'forks'only resets cross-file module state, not in-process handler registrations.
FAQ
Why does pool: 'forks' slow down my test suite?
Forking a new process per file has higher startup overhead than sharing threads. For suites under 50 files the difference is negligible (< 2 s). For larger libraries, add --shard to split files across parallel CI jobs rather than trying to recapture speed by reverting to threads.
Can I use vi.mock at the module level instead of renderWithScope?
vi.mock is appropriate for mocking specific imports (utility functions, API clients). It does not replace a render wrapper for context providers, because the provider’s value — not its existence — is what causes non-determinism. Use both: vi.mock for pure functions and network clients, renderWithScope for provider state.
Do I need a separate renderWithScope for each component library package?
Only if the provider trees genuinely differ between packages. In most monorepos a single shared @design-system/test-utils package exporting renderWithScope works for all component packages. This also ensures the scope contract is versioned and reviewed alongside the design system itself.
Related
- Test Scope Definition — the parent page covering scope tiers across atomic, molecular, and composite component layers
- Mock Boundaries — how to declare explicit API and module stubs at the component edge
- State Injection — factory patterns for deterministic fixture state without live backend dependencies
- Setting Up Mock APIs for Frontend Component Tests — MSW configuration patterns for isolating network calls in unit tests
- Visual Regression Snapshot Strategies — how clean scope boundaries translate into stable screenshot baselines