shallow is a React component testing library that runs components without a real DOM by simulating the small parts of React rendering and hooks needed for shallow tests, allowing you to test components like pure functions.
Because shallow does not mount into jsdom (or other DOM implementations), it avoids a lot of work that DOM-based tools do: creating DOM nodes, running DOM queries, handling browser-like APIs, React DOM reconciliation, and cleanup between tests. It just executes the component function, walks the returned React elements, and records a lightweight tree.
So this kind of test can be faster, especially for component logic tests where you only need to assert:
- Which child components render
- What props they receive
- What text is produced
- What happens when a callback prop is triggered
- How hooks/state affect output
Use shallowHook() to test custom hooks directly without a visible UI tree.
The caveat is that it is not equivalent to real DOM rendering. It will not catch issues involving actual browser behavior, accessibility semantics, layout, focus, form behavior, portals, real effects, event propagation, or React DOM integration. So it's usually best as a faster unit-test layer, not a total replacement for React Testing Library (RTL) tests.
Based on basic local test runs on the same React components and almost identical unit tests (RTL v.s. shallow), there were the results:
| Test style | Samples | Average | Median | Min | Max |
|---|---|---|---|---|---|
| React Testing Library | 11 | 768ms | 695ms | 650ms | 1.13s |
@hdong/shallow |
11 | 397ms | 359ms | 331ms | 661ms |
That is roughly a 1.9x faster average runtime, or a 48% reduction in test time.
Note: real world usages may vary, feel free to share your own data points and we can update this.
pnpm add -D @hdong/shallowThis package expects react and vitest to be installed in your project.
Register the custom matchers from a Vitest setup file:
// setupTest.ts
import { registerMatchers } from "@hdong/shallow/vitest";
registerMatchers();Then point Vitest at that file:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
globals: true,
setupFiles: ["./setupTest.ts"],
},
});shallow does not need jsdom or another DOM-like environment. Setting
environment: "node" keeps shallow-only tests from paying DOM environment setup
cost, especially in projects that otherwise default Vitest to jsdom.
If you use Vitest globals, add the global types to your TypeScript config:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}// button.tsx
type ButtonProps = {
children?: ReactNode;
onClick?: () => void;
};
export function Button({ onClick, children }: ButtonProps) {
return <button onClick={onClick}>{children}</button>;
}
// user-actions.tsx
import { Button } from "./button.tsx";
export function UserActions({ name, onEdit }: { name: string; onEdit: (name: string) => void }) {
return <Button onClick={() => onEdit(name)}>Edit profile</Button>;
}
// user-actions.test.tsx
import type { ReactNode } from "react";
import { shallow } from "@hdong/shallow";
import { Button } from "./button.tsx";
import { UserActions } from "user-actions.tsx";
test("calls an action from a shallow child", () => {
const onEdit = vi.fn();
const output = shallow(UserActions).render({ name: "Ada", onEdit });
output.find(Button).click();
expect(onEdit).toHaveBeenCalledWith("Ada");
});Runs a hook inside a hidden harness component and returns the latest hook result without rendering UI. Use this for fast unit tests of custom hooks and state logic.
import { shallowHook } from "@hdong/shallow";
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
return { count, increment: () => setCount((value) => value + 1) };
}
test("increments", () => {
const { result, rerender } = shallowHook(() => useCounter());
result.current.increment();
rerender();
expect(result.current.count).toBe(1);
});Returns:
result.current— latest value returned byuseHookoutput— shallow output for the harness (usually empty; usenot.toBeRendered()afterunmount())rerender()— re-run the hook (call after state updates from hook actions)unmount()— tear down the harness
Call rerender() after invoking functions that update hook state (for example
increment()), so result.current reflects the next render.
Creates a test API for a React component.
const { render, mock, debug } = shallow(Component, {
defaultProps: {},
wrapper: Wrapper,
});Renders the root component and returns an output object.
The output supports:
find(...)/findAll(...)— locate nodes by type, criteria, or JSX query (see below)text()rerender(nextProps)unmount()nodes()
Found nodes also support find(...), findAll(...), props(), text(), nodes(), trigger(...), and click().
debug(foundNode) prints that node and its subtree the same way as debug(output).
Returns the first matching node. Throws if none are found.
output.find(Button);
output.find("button");Narrows the match with extra criteria on the same node:
output.find(Button, { text: "Save" });
output.find("button", { name: "submit" });Returns every match as an array (use .length, indexing, etc.):
expect(output.findAll("div")).toHaveLength(2);
const rows = output.findAll(TodoRow);
rows[1].trigger("onSelect");Same criteria as find(type, options), but returns all matches:
output.findAll("button", { name: "submit" });Locate by props, text, test id, and more without passing the type as a separate argument:
output.find({ testId: "menu-button" });
output.find({ type: Button, props: { children: "Save" } });You can also pass a JSX element. Shallow treats it like a criteria object built from the element’s type and props:
const textbox = output.find(<div role="textbox" />);Under the hood, find does not mount or render that JSX. The element is only a query description. When the argument is not a string or component type, shallow spreads the React element into find criteria, so the host tag becomes type and attributes become a partial props match:
// These are equivalent when the tree actually renders a matching <div>:
output.find(<div role="textbox" />);
output.find("div", { role: "textbox" });
output.find({ type: "div", role: "textbox" });Use the same tag and attributes you expect in the shallow tree. If the component renders <input role="textbox" />, query with <input role="textbox" /> (or output.find("input", { role: "textbox" })), not <div role="textbox" />.
Works with custom components too — props on the JSX element are matched with the same partial prop rules as find({ type, props }):
output.find(<Button type="submit">Save</Button>);Criteria:
type— component or host typeprops— partial prop matchid—idattributetestId—data-testidtext— rendered text (stringorRegExp)labelText—aria-label,aria-labelledby,labelprop, or rendered textclassName—classNametoken matchrole—roleprop or host type namename—nameprop oraria-labeloptional— onfindonly, returnundefinedinstead of throwing when nothing matches
output.find("section").find("button", { name: "submit" }).click();Creates mock behavior for a component or function-like dependency.
const api = shallow(Component);
api.mock(Child).component((props) => <span>{props.children}</span>);
api.mock(useCurrentUser).return({ name: "Ada" });Shallow mocks keep their own state. Vitest cleanup such as
afterEach(() => vi.restoreAllMocks()), afterEach(() => vi.resetAllMocks()),
or calling mock.restore() / mock.reset() on a vi.fn() does not clear
shallow's mock registry, component mock implementations, or recorded shallow
output history. Create a fresh shallow(Component) API per test, or call
api.mock(target).reset() for shallow mocks that must be cleared.
After registerMatchers() runs, these matchers are available:
Output (outer rendered element):
toBeRendered()toRenderText(expected)/toHaveText(expected)— text within the outer elementtoHaveName(name)toHaveTestId(testId)toHaveId(id)toHaveLabel(label)—aria-label,aria-labelledby,labelprop, or rendered texttoHaveRole(role)toHaveClass(className)—classNametokentoBeElement(type)— host tag ("button") or component (Button)toBeEnabled()/toBeDisabled()—disabledprop on the outer elementtoBeChecked()/toBeUnchecked()—checkedprop on the outer elementtoHaveValue(value)—valueprop on the outer elementtoHaveProps(propName, value)— a single prop on the outer element
Output (anywhere in tree):
toRender(type, expectedProps?)toRenderLabelText(type, expected)
Use expect(output).not.toBeRendered() after unmount() when the tree should be empty.
Component node:
toHaveProps(expectedProps)— partial prop match on a found node
The npm package is built with tsup.
pnpm install
pnpm test:run
pnpm run build
pnpm pack --dry-run