Skip to content

h-dong/shallow

Repository files navigation

@hdong/shallow

npm version license weekly downloads bundle size Publish to npm

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.

Performance

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.

Some Non-scientific Comparison Metrics

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.

Installation

pnpm add -D @hdong/shallow

This package expects react and vitest to be installed in your project.

Vitest Setup

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"]
  }
}

Usage

// 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");
});

API

shallowHook(useHook)

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 by useHook
  • output — shallow output for the harness (usually empty; use not.toBeRendered() after unmount())
  • 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.

shallow(Component, options?)

Creates a test API for a React component.

const { render, mock, debug } = shallow(Component, {
  defaultProps: {},
  wrapper: Wrapper,
});

render(props?, options?)

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).

find(type)

Returns the first matching node. Throws if none are found.

output.find(Button);
output.find("button");

find(type, options)

Narrows the match with extra criteria on the same node:

output.find(Button, { text: "Save" });
output.find("button", { name: "submit" });

findAll(type)

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");

findAll(type, options)

Same criteria as find(type, options), but returns all matches:

output.findAll("button", { name: "submit" });

find(criteria)

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" } });

find(<Element ... />)

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 type
  • props — partial prop match
  • idid attribute
  • testIddata-testid
  • text — rendered text (string or RegExp)
  • labelTextaria-label, aria-labelledby, label prop, or rendered text
  • classNameclassName token match
  • rolerole prop or host type name
  • namename prop or aria-label
  • optional — on find only, return undefined instead of throwing when nothing matches

Chaining

output.find("section").find("button", { name: "submit" }).click();

mock(target)

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.

Vitest Matchers

After registerMatchers() runs, these matchers are available:

Output (outer rendered element):

  • toBeRendered()
  • toRenderText(expected) / toHaveText(expected) — text within the outer element
  • toHaveName(name)
  • toHaveTestId(testId)
  • toHaveId(id)
  • toHaveLabel(label)aria-label, aria-labelledby, label prop, or rendered text
  • toHaveRole(role)
  • toHaveClass(className)className token
  • toBeElement(type) — host tag ("button") or component (Button)
  • toBeEnabled() / toBeDisabled()disabled prop on the outer element
  • toBeChecked() / toBeUnchecked()checked prop on the outer element
  • toHaveValue(value)value prop on the outer element
  • toHaveProps(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

Development

The npm package is built with tsup.

pnpm install
pnpm test:run
pnpm run build
pnpm pack --dry-run

About

A library for testing react components like they are pure functions.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors