Versioning Test Utilities as an Internal Package

Every monorepo grows a render helper. It starts in one package, gets copied to a second because it was easier than importing across a boundary, and within a year there are six versions that differ in which providers they wrap, whether they reset between tests, and what they re-export. The result is that a test written in one package cannot be moved to another, and a change to the shared provider stack has to be made six times. This guide covers building the helpers as a real workspace package instead: what belongs in it, how to keep its API stable enough that consumers are not constantly updated, how to test the test utilities themselves, and how to change them once dozens of files depend on them. It assumes a pnpm workspace with Vitest, following monorepo and workspace testing.

Root Cause Analysis

Test utilities get copied rather than shared for a rational reason: at the moment of copying, the shared package does not exist, and creating one is more work than duplicating twelve lines. The cost arrives later and is paid by someone else, which is the classic shape of an infrastructure problem.

What makes the duplicates diverge is that each copy is edited in place to serve its local need. One package adds a router provider; another adds a query client with retries disabled; a third adds a theme wrapper. None of these edits is wrong, and each is invisible to the others. By the time somebody notices, the helpers have different signatures and unifying them means touching hundreds of test files.

The second, subtler problem is that test utilities are usually untested. They are “just test code”, so nobody writes tests for them — but a bug in a shared render helper produces failures in every consumer simultaneously, and because the failure appears in application tests, hours get spent debugging the application before anyone suspects the helper. Treating the utilities as a package with its own suite inverts that: the helper’s bug fails in the helper’s tests, where it is obvious.

Copied helpers diverge; a shared package does not Three packages each holding their own copy of a render helper acquire different providers over time, while three packages depending on one test-utils package share a single definition that changes in one place. Copied ui/render.tsx theme only web/render.tsx theme + router admin/render.tsx theme + query client 3 edits Shared @acme/test-utils one definition, its own tests packages/ui apps/web apps/admin 1 edit
The divergence is not caused by carelessness — each local edit is reasonable and invisible to the others.

Reproducible Setup

Create the package with the same shape as any other workspace package, including its own test script.

// packages/test-utils/package.json
{
  "name": "@acme/test-utils",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": "./src/index.ts",
    "./factories": "./src/factories/index.ts",
    "./msw": "./src/msw/index.ts"
  },
  "scripts": { "test": "vitest run" },
  "peerDependencies": { "react": ">=18", "vitest": ">=1" },
  "dependencies": {
    "@testing-library/react": "^16.0.0",
    "@testing-library/user-event": "^14.5.2",
    "msw": "^2.4.0"
  }
}

Marking React and Vitest as peer dependencies is deliberate: the package must use whatever version the consumer has, not install a second copy, which is the usual cause of two-React and two-Vitest errors in a workspace.

Implementation

Step 1 — Decide what belongs in it, and be strict. Three things qualify: a render function that installs the standard provider stack, data factories, and request handlers for the mock server. Application-specific assertions do not — they belong to the package whose behaviour they describe.

// packages/test-utils/src/render.tsx
import { render as rtlRender, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@acme/ui';
import type { ReactElement, ReactNode } from 'react';

export type RenderWithProviders = RenderOptions & {
  theme?: 'light' | 'dark';
  queryClient?: QueryClient;
};

export function render(ui: ReactElement, options: RenderWithProviders = {}) {
  const { theme = 'light', queryClient, ...rtl } = options;
  const client =
    queryClient ??
    new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });

  const Wrapper = ({ children }: { children: ReactNode }) => (
    <QueryClientProvider client={client}>
      <ThemeProvider theme={theme}>{children}</ThemeProvider>
    </QueryClientProvider>
  );

  return { ...rtlRender(ui, { wrapper: Wrapper, ...rtl }), queryClient: client };
}

Step 2 — Re-export the underlying libraries. Consumers should import everything from one place, so the day a library is swapped or wrapped, there is one import specifier to change across the workspace.

// packages/test-utils/src/index.ts
export { render } from './render';
export { screen, within, waitFor, fireEvent, act } from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';
export * from './factories';

Step 3 — Give the utilities their own tests. They are small and the tests are quick, and they turn an obscure downstream failure into an obvious local one.

// packages/test-utils/src/render.test.tsx
import { test, expect } from 'vitest';
import { render, screen } from './index';
import { useTheme } from '@acme/ui';

function ThemeProbe() {
  return <span data-testid="theme">{useTheme()}</span>;
}

test('installs the theme provider with the requested theme', () => {
  render(<ThemeProbe />, { theme: 'dark' });
  expect(screen.getByTestId('theme')).toHaveTextContent('dark');
});

test('gives each render a query client with retries disabled', () => {
  const { queryClient } = render(<span />);
  expect(queryClient.getDefaultOptions().queries?.retry).toBe(false);
});

Step 4 — Keep the API additive. The helper has dozens of call sites almost immediately, so a required new parameter is a workspace-wide refactor. Add optional options, default them to current behaviour, and let consumers opt in.

// additive: existing calls keep working unchanged
export type RenderWithProviders = RenderOptions & {
  theme?: 'light' | 'dark';
  queryClient?: QueryClient;
  route?: string;            // new, optional, defaults to '/'
  locale?: string;           // new, optional, defaults to 'en'
};
Additive change versus breaking change in a shared helper An optional new option with a default leaves every existing call site working, while a new required parameter or a renamed option requires editing every call in the workspace at once. Additive optional option, defaulted 0 call sites change ship it whenever you like Breaking renamed or required option every call site, one commit deprecate first, migrate with a codemod
In a workspace there is no version skew to hide behind: a breaking change to a shared helper lands everywhere at once.

Step 5 — When a breaking change is genuinely needed, deprecate then migrate. Keep the old signature working for one cycle, mark it, and migrate mechanically rather than by hand.

// packages/test-utils/src/render.tsx
/** @deprecated pass `{ theme }` in options instead; removed after the next release */
export function renderWithTheme(ui: ReactElement, theme: 'light' | 'dark') {
  return render(ui, { theme });
}
# migrate every call site in one reviewable commit
npx jscodeshift -t codemods/render-with-theme.ts "{packages,apps}/*/src/**/*.test.tsx"
grep -rn "renderWithTheme" packages apps | wc -l   # expect 0 before removing it

Verification

Verify that no duplicate helpers remain, since the whole point is that there is one. A grep for the old local paths is the cheapest possible check and is worth keeping in the lint step.

grep -rln "from '.*test/render'" packages apps
# (no output — every package imports @acme/test-utils)

Then verify that consumers resolve a single copy of React and Vitest, because a second copy produces errors that look like application bugs and are anything but.

pnpm why react --json | jq '[.[].dependencies] | length'
# 1

Finally, verify the helper’s own suite runs as part of the workspace and is not accidentally excluded — a private package with tests nobody runs is exactly as useful as no tests.

pnpm vitest run --reporter=basic | grep test-utils
#  ✓ |test-utils| src/render.test.tsx (2 tests) 96ms
What belongs in the shared package and what stays local Render helpers, data factories and mock request handlers are shared; application-specific assertions, page helpers and domain fixtures stay in the package whose behaviour they describe. Shared render with the provider stack data factories and builders mock server handlers custom matchers Stays local assertions about one feature page helpers for one app fixtures for one domain setup files with local hooks
The dividing line is reuse across packages, not whether the code happens to live in a test file.

One organisational point makes the difference between a package that stays healthy and one that becomes a dumping ground. Give it an owner — a person or a team listed in the code owners file — so that additions get the same review as any other shared code. Without an owner, every consumer adds the helper they needed, nobody removes anything, and within a year the package is the same six divergent helpers it was created to replace, only now they all live in one file.

Troubleshooting

Symptom: “Invalid hook call” or two React copies after adopting the package. Diagnosis: React is a direct dependency of the utilities package rather than a peer, so the workspace installs a second copy. Fix: move it to peerDependencies as in the setup, and add it to devDependencies so the package’s own tests can still run.

Symptom: the shared render helper grows a parameter for every consumer’s special case. Diagnosis: the helper is absorbing application concerns that should stay local. Fix: expose the wrapper composition rather than more flags, so a package that needs an extra provider can wrap the shared one instead of asking for another option.

Symptom: changing the helper breaks dozens of tests at once. Diagnosis: exactly what shared infrastructure does — and why its own tests matter. Fix: make the change additively, run the whole workspace before merging, and if a breaking change is unavoidable, pair it with a codemod so the migration is mechanical and reviewable rather than a week of manual edits.

FAQ

Should the package be published, or stay private?

Private is almost always right. It exists to serve this workspace, its API changes with the workspace’s needs, and publishing it invites outside consumers whose upgrade timelines you do not control. Mark it "private": true and let the workspace protocol resolve it.

Does a test-utilities package need its own coverage threshold?

A threshold is less useful here than tests that exercise each helper’s contract, since the package is small and every line runs constantly through consumers. What matters is that a broken helper fails in its own suite first. A modest threshold is fine as a guard against untested additions, but do not chase a number.

How do factories in the shared package stay useful across domains?

By being small and composable rather than complete. A factory that builds a minimal valid object and accepts overrides serves every consumer; one that builds a fully-populated fixture for one application’s happy path serves exactly that application. The composable style is described in more depth in factory functions vs fixtures in Vitest.

What if two applications genuinely need different provider stacks?

Export the pieces as well as the assembled default. A consumer that needs a different stack composes the wrappers itself, while everyone else keeps using the single render. That keeps the common case one import long without forcing the unusual case into a flag that nobody else understands.