Writing Custom Render Helpers with Providers
Real components rarely render on their own. They need a query client, a router, a theme, an internationalisation provider, perhaps a feature-flag context and the application’s own state providers. Without a shared helper, every test file assembles that stack by hand, slightly differently each time — one file forgets the theme, another shares a query client across tests, a third wraps the router in the wrong order — and failures appear that have nothing to do with the component under test. A single renderWithProviders helper fixes this: it builds a fresh provider stack for each test, accepts options for the handful of things tests need to vary, and returns everything a test needs to interact and assert. This guide builds that helper step by step, covering isolation, overrides, routing, user-event setup and typing. It belongs to Testing Library best practices.
Root Cause Analysis
Shared state across tests is the most damaging problem a render helper can have. A query client created at module scope carries its cache from one test to the next, so a test that expects a loading state finds cached data instead — but only when it runs after another test that fetched the same key. The failure depends on test order, which is the defining trait of a hard-to-diagnose flaky test. A helper that creates every stateful provider inside the call removes the whole category.
Inconsistent stacks are the second problem. When each file assembles providers itself, the order and configuration drift from production. A component that reads the router inside a theme provider in production may be rendered with the theme outside the router in tests; a query client in tests may retry failed requests three times while production does not. Tests then pass for configurations that never ship, or fail for ones that do.
The third is rigidity. A helper that cannot be configured forces tests to bypass it whenever they need a different route, user or flag, and the duplicated setup returns. A good helper exposes a small set of options with production-like defaults, and nothing more.
Reproducible Setup
A test utility module that re-exports Testing Library, so test files import everything from one place.
// test/render.tsx
import { render, type RenderOptions } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
type Options = Omit<RenderOptions, 'wrapper'> & {
route?: string;
flags?: Partial<Flags>;
user?: User | null;
basket?: Line[];
};
export function renderWithProviders(ui: React.ReactElement, opts: Options = {}) {
const { route = '/', flags = {}, user = testUser(), basket = [], ...renderOptions } = opts;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity }, mutations: { retry: false } },
});
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>
<ThemeProvider theme={lightTheme}>
<FlagsProvider value={{ ...defaultFlags, ...flags }}>
<AuthProvider initialUser={user}>
<BasketProvider initial={basket}>{children}</BasketProvider>
</AuthProvider>
</FlagsProvider>
</ThemeProvider>
</MemoryRouter>
</QueryClientProvider>
);
}
return { user: userEvent.setup(), queryClient, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}
export * from '@testing-library/react';
Implementation
Step 1 — Create stateful providers inside the call. The query client above is built per render, with retries off so error states appear immediately and deterministically.
Step 2 — Return a user-event instance. Tests destructure it alongside queries, which removes the repeated userEvent.setup() line and ensures setup happens before render.
test('adds a product to the basket', async () => {
const { user } = renderWithProviders(<ProductCard sku="MUG" />);
await user.click(screen.getByRole('button', { name: 'Add to basket' }));
expect(await screen.findByRole('status')).toHaveTextContent('Mug added');
});
Step 3 — Expose options for what tests genuinely vary. Route, flags, user and initial state cover most needs; anything rarer can use render directly.
test('guests are asked to sign in before checkout', async () => {
renderWithProviders(<CheckoutPage />, { route: '/checkout', user: null });
expect(screen.getByRole('link', { name: 'Sign in to continue' })).toBeInTheDocument();
});
test('the new basket drawer is shown when the flag is on', () => {
renderWithProviders(<Header />, { flags: { basketDrawer: true } });
expect(screen.getByRole('button', { name: 'Open basket' })).toBeInTheDocument();
});
Step 4 — Return the query client for cache assertions. Tests that care about invalidation can inspect it directly without reaching into module state.
test('saving invalidates the profile query', async () => {
const { user, queryClient } = renderWithProviders(<ProfileForm />);
const spy = vi.spyOn(queryClient, 'invalidateQueries');
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(spy).toHaveBeenCalledWith({ queryKey: ['profile'] }));
});
Step 5 — Make the helper the default import. Point an alias such as @test/render at the module and add a lint rule that forbids importing render from @testing-library/react directly in component tests. The helper then becomes the path of least resistance rather than a convention.
// eslint.config.js (excerpt)
{ files: ['src/**/*.test.tsx'],
rules: { 'no-restricted-imports': ['error', { paths: [{ name: '@testing-library/react',
importNames: ['render'], message: 'Use renderWithProviders from @test/render.' }] }] } }
Step 6 — Add a renderHookWithProviders twin. Hooks need the same stack; reusing the Wrapper builder keeps the two in sync.
export function renderHookWithProviders<T>(hook: () => T, opts: Options = {}) {
const { Wrapper, queryClient } = buildWrapper(opts);
return { queryClient, ...renderHook(hook, { wrapper: Wrapper }) };
}
Step 7 — Keep defaults close to production. Every default the helper chooses is an assumption baked into hundreds of tests, so choose them deliberately. The default user should be an ordinary signed-in customer rather than an administrator, because tests written against an all-powerful user miss permission bugs. Default flags should match what production currently serves, not “everything on”, or tests will quietly depend on unreleased features. The default locale and time zone should match the most common production setting, with options to change them for formatting tests. When a default changes — a flag rolls out fully, a new required provider appears — change it in the helper once, run the suite, and treat the failures as a map of the components the change affects.
Document the helper in the testing guide the team already reads: what it builds, which options exist, and when to bypass it. A few lines there prevent the slow return of hand-assembled provider stacks, which tends to happen whenever someone new joins and copies the first test file they open rather than the helper everyone else uses.
Step 8 — Time the helper. Because every component test pays its cost, a slow provider — a theme that loads fonts, a flag client that initialises a network SDK — multiplies across the suite. Measure a trivial render with and without each provider occasionally, and replace any expensive one with a lightweight test double that keeps the same interface.
Verification
Move the new QueryClient(...) call to module scope and run the suite in random order with --sequence.shuffle. Tests that assert loading states should start failing intermittently — the order dependence the helper prevents. Move it back and run the shuffle several times; results must be identical each run.
npx vitest run --sequence.shuffle --reporter=dot
npx vitest run --sequence.shuffle --reporter=dot
# identical pass counts on every run
Also compare the helper’s provider order and configuration with the application’s root component. A short test that renders the helper’s wrapper and the production root around the same probe and compares what the probe reads — theme, locale, flag defaults — catches drift between the two stacks.
Troubleshooting
Symptom: “No QueryClient set” or similar missing-provider errors. Diagnosis: the test imported render directly instead of the helper. Fix: switch the import and add the lint rule from Step 5.
Symptom: navigation in the component does nothing observable. Diagnosis: MemoryRouter changes location internally with nothing rendered for the new path. Fix: render a Routes tree in the test, or add a LocationDisplay probe to the wrapper that prints the current path.
Symptom: error-state tests take several seconds. Diagnosis: queries retry with backoff. Fix: set retry: false in the helper’s query client, as shown.
Symptom: the helper’s type rejects a render option. Diagnosis: the options type omits too much. Fix: extend RenderOptions minus wrapper, so container, baseElement and hydrate pass through.
FAQ
Should the helper start the MSW server?
No — start the server once in the setup file, and let tests register per-test handlers. Mixing network setup into the render helper hides which responses a test depends on.
Can the helper support Vue or Svelte?
The same pattern applies: build plugins, stores and routers per call and pass them through the framework’s Testing Library global or context options. See testing Vue 3 components with Testing Library.
What if a test needs a provider the helper does not include?
Wrap the element under test in it before passing it to the helper. If several tests need it, add an option; if one does, leave it local.
Should the helper wait for initial data?
No. Returning immediately lets tests assert loading states; tests that need loaded content use findBy queries.
Is one helper enough for a large monorepo?
Usually one per application, built from shared pieces. Put the provider builders in an internal test-utilities package and let each app compose its own helper, so apps can differ in their stacks without copying the isolation logic.
Related
- Back to Testing Library Best Practices
- Testing context providers in isolation — testing the providers the helper composes.
- Testing React Query hooks with a fresh cache — why the client is created per test.
- Diagnosing unable-to-find-element failures — failures a missing provider can cause.