Testing Context Made Simple: SOLID Provider Pattern with Vitest + RTL


Target audience: senior developers looking for a repeatable pattern for Context/Provider, with hook tests, reusable mocks, clean component tests, and taking advantage of TypeScript features.
Primary goal: Give senior engineers a copy‑pasteable Context/Provider pattern with Vitest + RTL that keeps code thin, tests fast, and mocks reusable. You’ll build a minimal User
context, test the hook and a real consumer in two styles (mocked context module and real Provider), and leave with a template you can apply to any domain (theme, feature flags, permissions, A/B, session).
What you’ll build
A generic
createCtx
that centralizes error handling and prevents silentundefined
.A small
useUser
hook (no reducers, no ceremony) withlogin
,logout
,updateProfile
.A thin
UserProvider
(optionally acceptsinitialState
for integration tests).A simple consumer (
ProfileMenu
) wired to the context.A testing toolbox:
setupUserContextForTest
(centralized mocks),renderWithProvider
(compose real providers)
Suggested structure
src/
utils/createCtx.ts
hooks/useUser.ts
contexts/UserContext.ts
providers/UserProvider.tsx
components/ProfileMenu.tsx
api/auth.ts // mocked
test/
utils/TestProviderFactory.tsx
providers/UserProvider.ts
hooks/useUser.test.ts
components/ProfileMenu.test.tsx
Implementation
👉 Source code: the full project used in this article is available here: github.com/luguin444/user-provider-test. If this pattern helps you, consider leaving a ⭐ and adapting it to your team’s context modules.
src/utils/createCtx.ts
Why:
createContext
centralizes context creation, enforces usage inside a Provider, has custom error handling and reduces repetitive boilerplate when creating new contexts.
import { createContext, useContext } from "react";
interface Args {
errorMessage?: string;
}
export const createCtx = <T extends object | null>({
errorMessage = "useCtx must be inside a Provider with a value",
}: Args = {}) => {
const ctx = createContext<T | undefined>(undefined);
const useCtx = () => {
const context = useContext(ctx);
if (context === undefined) {
throw new Error(errorMessage);
}
return context;
};
return [useCtx, ctx.Provider] as const;
};
src/contexts/UserContext.ts
Why: Provides a strongly-typed link between the hook and React Context, preventing misuse and ensuring type safety.
import { createCtx } from "../utils/createCtx";
import { useUser } from "../hooks/useUser";
export type UserContext = ReturnType<typeof useUser>;
export const [useUserContext, UserContextProvider] = createCtx<UserContext>({
errorMessage: "useUserContext must be used within <UserProvider>",
});
src/providers/UserProvider.tsx
Why: Keeps the provider thin and predictable, allowing easy injection; it also supports an
initialState
to help in tests.
import { FC, ReactNode } from "react";
import { UserContextProvider } from "../contexts/UserContext";
import { useUser, UseUserInitialState } from "../hooks/useUser";
export const UserProvider: FC<{ children: ReactNode; initialState?: UseUserInitialState }> = ({ children, initialState }) => {
const value = useUser(initialState);
return <UserContextProvider value={value}>{children}</UserContextProvider>;
};
src/hooks/useUser.ts
Why: Encapsulates authentication state and operations in a single place, improving maintainability and testability. The goal is not have a complex authentication system, bu
import { useState } from "react";
import * as auth from "../api/auth";
export type AuthStatus = "authenticated" | "guest";
export interface User {
id: string;
name: string;
email: string;
}
export interface UseUserInitialState {
user?: User | null;
token?: string | null;
}
export const useUser = (initial?: UseUserInitialState) => {
const [user, setUser] = useState<User | null>(initial?.user ?? null);
const [token, setToken] = useState<string | null>(initial?.token ?? null);
const status: AuthStatus = user && token ? "authenticated" : "guest";
const isAuthenticated = status === "authenticated";
const login = async (email: string, password: string) => {
const { token, user } = await auth.login(email, password);
setToken(token);
setUser(user);
return { token, user };
};
const logout = async () => {
await auth.logout();
setToken(null);
setUser(null);
};
const updateProfile = async (patch: Partial<User>) => {
if (!user) return;
const updated = await auth.updateProfile({ ...user, ...patch } as User);
setUser(prev => ({ ...prev!, ...updated }));
return updated;
};
return { user, token, status, isAuthenticated, login, logout, updateProfile };
};
src/components/ProfileMenu.tsx
Why: Demonstrates how consumers use the context, serving as a reference for other components.
import { useUserContext } from "../contexts/UserContext";
export default function ProfileMenu() {
const { user, isAuthenticated, login, logout } = useUserContext();
console.log("Rendering ProfileMenu, isAuthenticated:", isAuthenticated);
if (!isAuthenticated) {
return (
<button onClick={() => login("dev@example.com", "pwd")} data-testid="signin-btn">
Sign in
</button>
);
}
return (
<div>
<span data-testid="username">{user!.name}</span>
<button onClick={logout} data-testid="signout-btn">Sign out</button>
</div>
);
}
src/api/auth.ts
Why: Decouples API calls from business logic, making it easy to mock in unit tests. For this article we deliberately do not call a real backend API; the
auth
module is a minimal stub so we keep the scope on the Provider and its tests, avoiding unnecessary setup
import type { User } from "../hooks/useUser";
export async function login(email: string, _password: string): Promise<{ token: string; user: User }> {
return {
token: "fake-token",
user: { id: "1", name: email.split("@")[0] || "User", email },
};
}
export async function logout(): Promise<void> {
return;
}
export async function updateProfile(user: User): Promise<Partial<User>> {
return { name: user.name, email: user.email };
}
Tests (Vitest + React Testing Library)
src/test/providers/TestProviderFactory.tsx
Why: Provides a robust
renderWithProvider
utility so a component can be wrapped by any number of providers, while passing initial state per provider as needed — avoiding repetition and keeping tests declarative. The order is important.
import { FC, ReactNode, ReactElement } from "react";
import { UserProvider } from "../../providers/UserProvider";
import { render } from "@testing-library/react";
const providerMapping = {
user: UserProvider,
};
type ProviderKey = keyof typeof providerMapping;
interface TestProviderFactoryProps {
children: ReactNode;
providers?: ProviderKey[];
initialState?: { [K in ProviderKey]?: any };
}
const TestProviderFactory: FC<TestProviderFactoryProps> = ({ children, providers = ["user"], initialState }) => {
const Composed = providers.reduce<FC<{ children: ReactNode }>>(
(Acc, key) => {
const P = providerMapping[key];
const state = initialState?.[key];
return ({ children }) => (
<P {...(state ? { initialState: state } : {})}>
<Acc>{children}</Acc>
</P>
);
},
({ children }) => <>{children}</>
);
return <Composed>{children}</Composed>;
};
export function renderWithProvider(ui: ReactElement, opts?: Omit<TestProviderFactoryProps, "children">) {
return render(<TestProviderFactory {...(opts || {})}>{ui}</TestProviderFactory>);
}
src/test/providers/UserProvider.ts
Why: Avoids duplicate mock boilerplate by centralizing setup in a single helper. You keep full flexibility to read and override the mock between tests and assert calls (e.g.,
toHaveBeenCalled
). We use this pattern in the ProfileMenu test below.
import { ReactNode } from "react";
import { vi } from "vitest";
import type { UserContext } from "../../contexts/UserContext";
export let userContextMocks: UserContext = createDefaultMocks();
export function setupUserContextForTest(overrides?: Partial<UserContext>) {
beforeEach(() => mockUserContext(overrides));
afterEach(() => resetUserContextMocks());
}
function createDefaultMocks(): UserContext {
return {
user: null,
token: null,
status: "guest",
isAuthenticated: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
} as unknown as UserContext;
}
function mockUserContext(overrides: Partial<UserContext> = {}) {
userContextMocks = { ...createDefaultMocks(), ...overrides } as UserContext;
vi.mock("../../contexts/UserContext", () => ({
useUserContext: vi.fn(() => userContextMocks),
UserContextProvider: ({ children }: { children: ReactNode }) => children,
}));
}
function resetUserContextMocks() {
userContextMocks = createDefaultMocks();
}
src/test/hooks/useUser.test.ts
Why: It’s easier and faster to test the hook directly than going through the Provider; you avoid tree setup and still cover the public API. We also benefit from TypeScript’s dynamic typing with
ReturnType
. Theauth
module is mocked, so even if it called a backend the test would remain deterministic and pass.
import { useUser } from "../../hooks/useUser";
import { renderHook } from "@testing-library/react";
import { act } from "react";
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../../api/auth", () => ({
login: vi.fn(async (email: string) => ({ token: "t-123", user: { id: "u1", name: "Dev", email } })),
logout: vi.fn(async () => {}),
updateProfile: vi.fn(async (u: any) => ({ name: u.name, email: u.email })),
}));
const auth = await import("../../api/auth");
describe("useUser", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("starts as guest", () => {
const { result } = renderHook(() => useUser());
expect(result.current.status).toBe("guest");
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
expect(result.current.token).toBeNull();
});
it("login authenticates and sets user/token", async () => {
const { result } = renderHook(() => useUser());
await act(async () => {
await result.current.login("dev@example.com", "pwd");
});
expect(auth.login).toHaveBeenCalled();
expect(result.current.status).toBe("authenticated");
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user?.email).toBe("dev@example.com");
expect(result.current.token).toBe("t-123");
});
it("logout clears state", async () => {
const { result } = renderHook(() => useUser({ user: { id: "1", name: "N", email: "n@n" }, token: "t" }));
await act(async () => {
await result.current.logout();
});
expect(auth.logout).toHaveBeenCalled();
expect(result.current.status).toBe("guest");
expect(result.current.user).toBeNull();
expect(result.current.token).toBeNull();
});
it("updateProfile merges partial", async () => {
const { result } = renderHook(() => useUser({ user: { id: "1", name: "Old", email: "old@e" }, token: "t" }));
await act(async () => {
await result.current.updateProfile({ name: "New" });
});
expect(auth.updateProfile).toHaveBeenCalled();
expect(result.current.user?.name).toBe("New");
});
});
src/test/components/ProfileMenu.test.tsx
Why: Here we use
setupUserContextForTest
to centralize mocks and avoid duplication across component suites, keeping test files much cleaner. Although this example wraps only a single provider, the pattern scales to many. We also useuserContextMocks
for full control over values and to assert interactions (e.g., verifyinguserContextMocks.logout
was called).
import { screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { setupUserContextForTest, userContextMocks } from "../providers/UserProvider";
import { renderWithProvider } from "../providers/TestProviderFactory";
import ProfileMenu from "../../components/ProfileMenu";
describe("ProfileMenu", () => {
setupUserContextForTest();
it("renders Sign in when guest", () => {
renderWithProvider(<ProfileMenu />, { providers: ['user'] });
expect(screen.getByTestId("signin-btn")).toBeInTheDocument();
});
it("shows username and calls logout when authenticated", () => {
userContextMocks.isAuthenticated = true;
userContextMocks.user = { id: "1", name: "Test Name", email: "l@x" };
renderWithProvider(<ProfileMenu />, { providers: ['user'] });
expect(screen.getByTestId("username")).toHaveTextContent("Test Name");
screen.getByTestId("signout-btn").click();
expect(userContextMocks.logout).toHaveBeenCalled();
});
});
Conclusion
A thin Context + Provider with a hook at the core gives you clarity, speed, and repeatability. Centralizing error handling in createCtx
, keeping logic in useUser
, and testing smartly (mocked context for units, real Provider for light integration) scales from demo to production without ceremony.
Have questions or edge cases (SSR, MSW, multi-context composition)? Drop them in issues — happy to extend the post with an advanced appendix.
Subscribe to my newsletter
Read articles from Luis Gustavo Ganimi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
