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 silent undefined.

  • A small useUser hook (no reducers, no ceremony) with login, logout, updateProfile.

  • A thin UserProvider (optionally accepts initialState 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. The auth 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 use userContextMocks for full control over values and to assert interactions (e.g., verifying userContextMocks.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.

0
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

Luis Gustavo Ganimi
Luis Gustavo Ganimi