Component tests in this project verify presentational logic, prop handling, and interactive behaviour without mounting a full browser or requiring network calls. This document explains how to set up a component test, how providers are supplied, and points to existing tests as examples.
Component tests use Vitest as the runner and React's createRoot from
react-dom/client to render components in a jsdom environment. Tests do not
use React Testing Library — they query the DOM directly with standard
querySelector and querySelectorAll.
Test files live alongside the components they test:
components/
stream-card.tsx
stream-card.test.tsx
header.tsx
header.test.tsx
The naming convention is <component-name>.test.tsx. Every test file has the
.test.tsx extension so Vitest discovers it.
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { act } from "react";
import { YourComponent } from "./your-component";
describe("YourComponent", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("renders the expected content", async () => {
await act(async () => {
root.render(<YourComponent />);
});
expect(container.textContent).toContain("Expected text");
});
});Key elements:
/* @vitest-environment jsdom */at the top — enables DOM APIs (document,window,HTMLElement) in Node.createRoot(container)— React 18's rendering API. Use it instead of the legacyrender()fromreact-dom.act()— wraps renders and user interactions so React can flush effects and state updates before assertions. Alwaysawait act(async () => { … }).- Cleanup in
afterEach— unmount the tree and remove the container so tests don't leak DOM nodes or event listeners.
Components that consume context (wallet, theme) need the corresponding provider wrapping them in tests. There are two approaches:
If the component only reads from a context, mock the hook it calls:
import { vi } from "vitest";
const mockUseWallet = vi.fn();
vi.mock("@/components/wallet-provider", () => ({
useWallet: () => mockUseWallet(),
}));
// In the test:
mockUseWallet.mockReturnValue({
address: "GABC…",
connected: true,
// … other wallet state
});Example: components/header.test.tsx mocks
usePathname from next/navigation.
If the component or its children call context setters (e.g., toggleTheme,
connect), wrap the test render in the real provider:
import { WalletProvider } from "@/components/wallet-provider";
await act(async () => {
root.render(
<WalletProvider>
<YourComponent />
</WalletProvider>
);
});The provider will initialise its own state. If you need to control what it provides, either:
- Call the context setter in the test (e.g., click the wallet button to trigger
connect()) - Pass initial state to the provider if it accepts props (the wallet and theme
providers do not — they read from the DOM and
windowon mount)
If the component receives all its data as props and does not call any context hooks, render it directly:
await act(async () => {
root.render(<StreamCard stream={mockStream} />);
});Example: components/stream-card.test.tsx
renders StreamCard with a mock StreamView prop. No providers.
To replace an import with a test double, use Vitest's vi.mock():
import { vi } from "vitest";
// Mock next/navigation
const mockUsePathname = vi.fn();
vi.mock("next/navigation", () => ({
usePathname: () => mockUsePathname(),
}));
// Mock a component that's not relevant to the test
vi.mock("@/components/wallet-button", () => ({
WalletButton: () => <div data-testid="wallet-button">Wallet</div>,
}));Place vi.mock() calls before the component import, at the top of the file
after the Vitest imports. The mock will intercept all imports of that module in
the test.
Restore mocks after each test to avoid leaking state:
afterEach(() => {
vi.restoreAllMocks();
});Tests query the DOM using standard querySelector methods:
const button = container.querySelector('button[aria-label="Refresh"]');
expect(button).not.toBeNull();
const links = container.querySelectorAll("a[href]");
expect(links.length).toBe(2);
expect(container.textContent).toContain("Expected string");For attribute checks:
const link = container.querySelector('a[href="/create"]');
expect(link?.getAttribute("aria-current")).toBe("page");Wrap clicks and input changes in act():
const button = container.querySelector("button");
await act(async () => {
button?.click();
});
const input = container.querySelector("input");
await act(async () => {
input!.value = "new value";
input!.dispatchEvent(new Event("input", { bubbles: true }));
});| Test file | What it demonstrates |
|---|---|
components/stream-card.test.tsx |
Simple prop-driven component, no providers, no mocks. Passes different StreamView props and asserts the rendered output. |
components/header.test.tsx |
Mocks next/navigation (usePathname) and child components (WalletButton, ThemeToggle). Renders with createRoot, queries nav links, simulates button clicks. |
components/theme-toggle.test.tsx |
Wraps the component in ThemeProvider, simulates clicks, reads localStorage and the DOM class to verify theme changes. |
hooks/use-stream-page.test.ts |
Tests a hook directly by rendering it inside a tiny harness component with act. Mocks lib/api.ts to control responses. |
hooks/use-create-stream-form.test.tsx |
Tests form validation, draft save/restore, and transaction submission by rendering the hook in a harness, stubbing localStorage, and mocking contract calls. |
Start with stream-card.test.tsx for a minimal example, then look at
header.test.tsx for provider/mock patterns.
Focus on:
- Prop handling: does the component render correctly for different prop values?
- Conditional rendering: does it show/hide elements based on props or state?
- User interactions: does clicking a button call the expected callback or update the UI?
- Accessibility: do ARIA attributes, roles, and labels appear correctly?
Do not test:
- Styling: CSS classes and layout are verified visually, not in unit tests.
- Integration with real backends: mock API calls and contract functions.
- Browser-specific behaviour: jsdom is not a real browser. Test keyboard navigation, focus trapping, and touch interactions manually.
Run all tests:
npm testRun tests for a specific file:
npm test stream-cardRun tests in watch mode (re-runs on file change):
npm test -- --watchRun tests with coverage:
npm test -- --coverageIf you see "Warning: An update to X inside a test was not wrapped in act(…)",
wrap the interaction that caused the update (render, click, state change) in
act().
jsdom provides localStorage by default in recent versions. If you see this
error, check that the test file has /* @vitest-environment jsdom */ at the top.
Mocks must be declared before the component import. This works:
vi.mock("next/navigation", () => ({ usePathname: vi.fn() }));
import { Header } from "./header";This does not:
import { Header } from "./header"; // already imported — mock is too late
vi.mock("next/navigation", () => ({ usePathname: vi.fn() }));Next.js async components (server components) cannot be tested this way. Test
their client children or the hooks they would call instead.
Component tests live alongside components with a .test.tsx extension. They use
Vitest, createRoot, and act() to render and interact with components in
jsdom. Providers are mocked or rendered explicitly as needed. Query the DOM
directly with querySelector. See components/stream-card.test.tsx for a
minimal example and components/header.test.tsx for a full setup with mocks and
providers.