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.

Production vs test render boundaries Left side shows a production app shell with ThemeProvider, QueryClientProvider, and a Button component nested inside. Right side shows a test environment where ThemeProvider and QueryClientProvider leak in implicitly, causing non-deterministic output. Production AppShell (entry point) ThemeProvider + QueryClientProvider (explicit, always present) Button Test (implicit scope) ThemeProvider? (leaked from module init) QueryClientProvider? (stale cache from prev file) Button

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 produce act() warnings even in forks mode. Wrap state-triggering interactions with await act(async () => { ... }) in the renderWithScope helper to silence false positives without suppressing real failures.

  • Turborepo / pnpm workspace caches. Monorepo task caches can persist a stale test/setup build across workspaces if the cache key does not include the setup file hash. Add test/setup/** to your turbo.json inputs array so a change to render-with-scope.tsx correctly invalidates downstream test caches.

  • MSW handler reset. The renderWithScope wrapper handles context isolation, but mock boundaries for network requests need a separate teardown. Call server.resetHandlers() in afterEach to 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.