Skip to content

Commit bb44860

Browse files
refactor: tweaks (#32)
1 parent b1ae0bd commit bb44860

19 files changed

Lines changed: 351 additions & 33 deletions

.github/workflows/ci.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ jobs:
3232
run: bun run test:ci
3333

3434
- name: Upload coverage to Codecov
35-
if: matrix.react-version == '19.0.0'
3635
uses: codecov/codecov-action@v4
3736
with:
3837
token: ${{ secrets.CODECOV_TOKEN }}

README.md

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ npm install -D universal-test-renderer
2323
import { act } from "react";
2424
import { createRoot } from "universal-test-renderer";
2525

26-
test("example", () => {
26+
test("renders a component", async () => {
2727
const renderer = createRoot();
28-
act(() => {
28+
29+
// Use `act` in async mode to allow resolving all scheduled React updates
30+
await act(async () => {
2931
renderer.render(<div>Hello!</div>);
3032
});
3133

@@ -39,8 +41,149 @@ test("example", () => {
3941
});
4042
```
4143

44+
## API Reference
45+
46+
### `createRoot(options?)`
47+
48+
Creates a new test renderer instance.
49+
50+
```tsx
51+
const renderer = createRoot(options);
52+
```
53+
54+
Returns a `Root` object with:
55+
56+
- `render(element)` - Render a React element. Must be called within `act()`.
57+
- `unmount()` - Unmount and clean up. Must be called within `act()`.
58+
- `container` - A wrapper `HostElement` that contains the rendered element(s). Use this to query and inspect the rendered tree.
59+
60+
### `RootOptions`
61+
62+
| Option | Type | Description |
63+
| -------------------- | ---------------------------- | ----------------------------------------------------------------- |
64+
| `textComponents` | `string[]` | Element types that can contain text (for React Native simulation) |
65+
| `createNodeMock` | `(element) => object` | Create mock objects for refs |
66+
| `identifierPrefix` | `string` | Prefix for `useId()` generated IDs |
67+
| `isStrictMode` | `boolean` | Enable React Strict Mode |
68+
| `onCaughtError` | `(error, errorInfo) => void` | Called when Error Boundary catches an error |
69+
| `onUncaughtError` | `(error, errorInfo) => void` | Called for uncaught errors |
70+
| `onRecoverableError` | `(error, errorInfo) => void` | Called when React recovers from errors |
71+
72+
### `HostElement`
73+
74+
The rendered element wrapper with a DOM-like API:
75+
76+
| Property/Method | Description |
77+
| ------------------------------- | ---------------------------------------- |
78+
| `type` | Element type (e.g., `"div"`, `"span"`) |
79+
| `props` | Element props object |
80+
| `children` | Array of child elements and text strings |
81+
| `parent` | Parent element or `null` |
82+
| `toJSON()` | Convert to JSON for snapshots |
83+
| `queryAll(predicate, options?)` | Find all matching descendant elements |
84+
85+
## Querying Elements
86+
87+
Use `queryAll()` to find elements in the rendered tree:
88+
89+
```tsx
90+
const renderer = createRoot();
91+
await act(async () => {
92+
renderer.render(
93+
<div>
94+
<button data-testid="btn-1">First</button>
95+
<button data-testid="btn-2">Second</button>
96+
</div>,
97+
);
98+
});
99+
100+
// Find all buttons
101+
const buttons = renderer.container.queryAll((el) => el.type === "button");
102+
expect(buttons).toHaveLength(2);
103+
104+
// Find by props
105+
const btn1 = renderer.container.queryAll((el) => el.props["data-testid"] === "btn-1");
106+
expect(btn1[0].children).toContain("First");
107+
```
108+
109+
### Query Options
110+
111+
```tsx
112+
queryAll(predicate, {
113+
includeSelf: false, // Include the element itself in results
114+
matchDeepestOnly: false, // Only return deepest matches (exclude ancestors)
115+
});
116+
```
117+
118+
## React Native Simulation
119+
120+
Use `textComponents` to simulate React Native's text rendering rules:
121+
122+
```tsx
123+
import { createElement } from "react";
124+
125+
const renderer = createRoot({
126+
textComponents: ["Text", "RCTText"],
127+
});
128+
129+
// This works - text inside Text component
130+
await act(async () => {
131+
renderer.render(createElement("Text", null, "Hello!"));
132+
});
133+
134+
// This throws - text outside Text component
135+
await act(async () => {
136+
renderer.render(<View>Hello!</View>); // Error!
137+
});
138+
```
139+
140+
## Mocking Refs
141+
142+
Use `createNodeMock` to provide mock objects for refs:
143+
144+
```tsx
145+
const renderer = createRoot({
146+
createNodeMock: (element) => {
147+
if (element.type === "input") {
148+
return {
149+
focus: jest.fn(),
150+
value: "",
151+
};
152+
}
153+
return {};
154+
},
155+
});
156+
157+
await act(async () => {
158+
renderer.render(<input ref={inputRef} />);
159+
});
160+
161+
// inputRef.current is now the mock object
162+
inputRef.current.focus();
163+
```
164+
165+
## Error Handling
166+
167+
Handle React errors with custom callbacks:
168+
169+
```tsx
170+
const renderer = createRoot({
171+
onCaughtError: (error, errorInfo) => {
172+
// Called when an Error Boundary catches an error
173+
console.log("Caught:", error.message);
174+
console.log("Component stack:", errorInfo.componentStack);
175+
},
176+
onUncaughtError: (error, errorInfo) => {
177+
// Called for uncaught render errors
178+
},
179+
});
180+
```
181+
42182
## Key Differences from React Test Renderer
43183

44184
- Works at host component level only (no composite components)
45-
- More flexible reconciler configuration options
46-
- Uses `act` from the React package directly
185+
- Expost all reconciler configuration options
186+
187+
## License
188+
189+
MIT

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export default {
33
testEnvironment: "node",
44
testMatch: ["**/__tests__/**/*.ts?(x)", "**/?(*.)+(spec|test).ts?(x)"],
55
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
6+
coveragePathIgnorePatterns: ["/node_modules/", "/test-utils/"],
67
};

package.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
{
22
"name": "universal-test-renderer",
33
"version": "0.10.1",
4-
"description": "",
5-
"keywords": [],
6-
"author": "",
4+
"description": "A lightweight, JavaScript-only replacement for the deprecated React Test Renderer.",
5+
"keywords": [
6+
"react",
7+
"react-test-renderer",
8+
"testing",
9+
"test-renderer",
10+
"react-19",
11+
"jest"
12+
],
13+
"author": "Maciej Jastrzebski <mdjastrzebski@gmail.com>",
714
"license": "MIT",
815
"repository": {
916
"type": "git",

src/__tests__/create-node-mock.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { createRoot } from "../renderer";
55
import { renderWithAct } from "../test-utils/render";
66

77
beforeEach(() => {
8-
// @ts-expect-error global is not typed
98
global.IS_REACT_ACT_ENVIRONMENT = true;
109
});
1110

src/__tests__/host-element.test.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { beforeEach, expect, jest, test } from "@jest/globals";
22

33
import type { HostElement } from "../host-element";
4-
import { ReactWorkTag } from "../react-constants";
54
import { createRoot } from "../renderer";
5+
import { ReactWorkTag } from "../test-utils/react-constants";
66
import { getRootElement, renderWithAct } from "../test-utils/render";
77

88
beforeEach(() => {
9-
// @ts-expect-error global is not typed
109
global.IS_REACT_ACT_ENVIRONMENT = true;
1110
});
1211

src/__tests__/renderer.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { createRoot } from "../renderer";
55
import { renderWithAct } from "../test-utils/render";
66

77
beforeEach(() => {
8-
// @ts-expect-error global is not typed
98
global.IS_REACT_ACT_ENVIRONMENT = true;
109
});
1110

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { beforeEach, expect, jest, test } from "@jest/globals";
2+
import { Component, useEffect, useId } from "react";
3+
4+
import { createRoot } from "../renderer";
5+
import { renderWithAct } from "../test-utils/render";
6+
7+
beforeEach(() => {
8+
global.IS_REACT_ACT_ENVIRONMENT = true;
9+
});
10+
11+
test("isStrictMode option enables strict mode", async () => {
12+
let renderCount = 0;
13+
let effectCount = 0;
14+
15+
function Counter() {
16+
renderCount++;
17+
useEffect(() => {
18+
effectCount++;
19+
}, []);
20+
return <div>Count: {renderCount}</div>;
21+
}
22+
23+
const renderer = createRoot({ isStrictMode: true });
24+
await renderWithAct(renderer, <Counter />);
25+
26+
// In strict mode, components render twice and effects run twice
27+
expect(renderCount).toBe(2);
28+
expect(effectCount).toBe(2);
29+
});
30+
31+
test("identifierPrefix option prefixes useId values", async () => {
32+
let capturedId: string | undefined;
33+
34+
function ComponentWithId() {
35+
capturedId = useId();
36+
return <div id={capturedId}>Content</div>;
37+
}
38+
39+
const renderer = createRoot({ identifierPrefix: "test-prefix-" });
40+
await renderWithAct(renderer, <ComponentWithId />);
41+
42+
expect(capturedId).toBeDefined();
43+
expect(capturedId).toContain("test-prefix-");
44+
});
45+
46+
interface ErrorBoundaryProps {
47+
children: React.ReactNode;
48+
fallback: React.ReactNode;
49+
}
50+
51+
interface ErrorBoundaryState {
52+
hasError: boolean;
53+
}
54+
55+
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
56+
constructor(props: ErrorBoundaryProps) {
57+
super(props);
58+
this.state = { hasError: false };
59+
}
60+
61+
static getDerivedStateFromError(): ErrorBoundaryState {
62+
return { hasError: true };
63+
}
64+
65+
render() {
66+
if (this.state.hasError) {
67+
return this.props.fallback;
68+
}
69+
return this.props.children;
70+
}
71+
}
72+
73+
test("onCaughtError is called when error is caught by Error Boundary", async () => {
74+
const onCaughtError = jest.fn();
75+
76+
function ThrowingComponent(): React.ReactNode {
77+
throw new Error("Test caught error");
78+
}
79+
80+
const renderer = createRoot({ onCaughtError });
81+
await renderWithAct(
82+
renderer,
83+
<ErrorBoundary fallback={<div>Error caught</div>}>
84+
<ThrowingComponent />
85+
</ErrorBoundary>,
86+
);
87+
88+
expect(renderer.container).toMatchInlineSnapshot(`
89+
<>
90+
<div>
91+
Error caught
92+
</div>
93+
</>
94+
`);
95+
96+
expect(onCaughtError).toHaveBeenCalledTimes(1);
97+
expect(onCaughtError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
98+
expect((onCaughtError.mock.calls[0]?.[0] as Error).message).toBe("Test caught error");
99+
});

src/__tests__/suspense.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { createRoot } from "../renderer";
55
import { act, renderWithAct } from "../test-utils/render";
66

77
beforeEach(() => {
8-
// @ts-expect-error global is not typed
98
global.IS_REACT_ACT_ENVIRONMENT = true;
109
});
1110

src/__tests__/unmount.test.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { createRoot } from "../renderer";
44
import { renderWithAct, unmountWithAct } from "../test-utils/render";
55

66
beforeEach(() => {
7-
// @ts-expect-error global is not typed
87
global.IS_REACT_ACT_ENVIRONMENT = true;
98
});
109

@@ -18,10 +17,7 @@ test("unmount clears the rendered content", async () => {
1817
await unmountWithAct(renderer);
1918
expect(containerElement.children.length).toBe(0);
2019

21-
expect(() => {
22-
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
23-
renderer.container;
24-
}).toThrow("Can't access .container on unmounted test renderer");
20+
expect(() => renderer.container).toThrow("Cannot access .container on unmounted test renderer");
2521
});
2622

2723
test("unmount can be called multiple times safely", async () => {
@@ -36,21 +32,15 @@ test("unmount can be called multiple times safely", async () => {
3632
await unmountWithAct(renderer);
3733
expect(containerElement.children.length).toBe(0);
3834

39-
expect(() => {
40-
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
41-
renderer.container;
42-
}).toThrow("Can't access .container on unmounted test renderer");
35+
expect(() => renderer.container).toThrow("Cannot access .container on unmounted test renderer");
4336
});
4437

4538
test("unmount when nothing is rendered", async () => {
4639
const renderer = createRoot();
4740

4841
await expect(() => unmountWithAct(renderer)).resolves.not.toThrow();
4942

50-
expect(() => {
51-
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
52-
renderer.container;
53-
}).toThrow("Can't access .container on unmounted test renderer");
43+
expect(() => renderer.container).toThrow("Cannot access .container on unmounted test renderer");
5444
});
5545

5646
test("cannot render after unmount", async () => {

0 commit comments

Comments
 (0)