Skip to content

Commit e96a3bf

Browse files
committed
feat: add renderAsync for testing async React Server Components
1 parent f32bd1b commit e96a3bf

4 files changed

Lines changed: 508 additions & 1 deletion

File tree

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,54 @@ test('handles server exceptions', async () => {
366366
> to declaratively mock API communication in your tests instead of stubbing
367367
> `window.fetch`, or relying on third-party adapters.
368368
369+
### Async Server Components
370+
371+
If you need to test `async function` components (React Server Components) or
372+
components that use React 19's `use()` API, use `renderAsync`:
373+
374+
```jsx
375+
import {renderAsync, screen} from '@testing-library/react'
376+
377+
// Async server component
378+
async function Greeting({userId}) {
379+
const user = await db.getUser(userId)
380+
return <h1>Hello {user.name}</h1>
381+
}
382+
383+
test('renders async server component', async () => {
384+
await renderAsync(<Greeting userId="1" />)
385+
expect(screen.getByRole('heading')).toHaveTextContent('Hello Alice')
386+
})
387+
```
388+
389+
`renderAsync` pre-resolves `async function` components in the element tree
390+
before passing them to React's client-side renderer, and wraps the result in a
391+
Suspense boundary with `act()` so that components using `use()` with promises
392+
also work:
393+
394+
```jsx
395+
function UserProfile({userPromise}) {
396+
const user = React.use(userPromise)
397+
return <div>{user.name}</div>
398+
}
399+
400+
test('renders component using use()', async () => {
401+
await renderAsync(
402+
<UserProfile userPromise={Promise.resolve({name: 'Alice'})} />,
403+
)
404+
expect(screen.getByText('Alice')).toBeInTheDocument()
405+
})
406+
```
407+
408+
`renderAsync` returns the same result as `render`, except `rerender` is async:
409+
410+
```jsx
411+
const {rerender} = await renderAsync(<Greeting userId="1" />)
412+
await rerender(<Greeting userId="2" />)
413+
```
414+
415+
Server-only APIs (cookies, headers, etc.) must be mocked in your test setup.
416+
369417
### More Examples
370418
371419
> We're in the process of moving examples to the

src/__tests__/renderAsync.js

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
import * as React from 'react'
2+
import {renderAsync, screen} from '../'
3+
4+
const isReact19 = React.version.startsWith('19.')
5+
6+
const testGateReact19 = isReact19 ? test : test.skip
7+
8+
async function AsyncHelloWorld() {
9+
return <div data-testid="hello">Hello World</div>
10+
}
11+
12+
async function AsyncGreeting({name}) {
13+
return <div data-testid="greeting">Hello {name}</div>
14+
}
15+
16+
async function AsyncDataLoader() {
17+
const data = await Promise.resolve({items: ['a', 'b', 'c']})
18+
return (
19+
<ul data-testid="list">
20+
{data.items.map(item => (
21+
<li key={item}>{item}</li>
22+
))}
23+
</ul>
24+
)
25+
}
26+
27+
async function AsyncChild() {
28+
return <span data-testid="child">Child Content</span>
29+
}
30+
31+
async function AsyncParent() {
32+
return (
33+
<div data-testid="parent">
34+
<AsyncChild />
35+
</div>
36+
)
37+
}
38+
39+
async function AsyncDeeplyNested() {
40+
return (
41+
<div data-testid="level-1">
42+
<AsyncLevel2 />
43+
</div>
44+
)
45+
}
46+
47+
async function AsyncLevel2() {
48+
return (
49+
<div data-testid="level-2">
50+
<AsyncLevel3 />
51+
</div>
52+
)
53+
}
54+
55+
async function AsyncLevel3() {
56+
return <div data-testid="level-3">Deep Content</div>
57+
}
58+
59+
async function AsyncSiblings() {
60+
return (
61+
<div data-testid="siblings">
62+
<AsyncGreeting name="Alice" />
63+
<AsyncGreeting name="Bob" />
64+
</div>
65+
)
66+
}
67+
68+
function SyncWrapper({children}) {
69+
return <div data-testid="wrapper">{children}</div>
70+
}
71+
72+
async function AsyncWithSyncWrapper() {
73+
return (
74+
<SyncWrapper>
75+
<AsyncChild />
76+
</SyncWrapper>
77+
)
78+
}
79+
80+
async function AsyncWithFragment() {
81+
return (
82+
<>
83+
<div data-testid="frag-a">A</div>
84+
<div data-testid="frag-b">B</div>
85+
</>
86+
)
87+
}
88+
89+
async function AsyncThatThrows() {
90+
throw new Error('Server component error')
91+
}
92+
93+
describe('renderAsync', () => {
94+
test('renders a simple async component', async () => {
95+
await renderAsync(<AsyncHelloWorld />)
96+
expect(screen.getByTestId('hello')).toHaveTextContent('Hello World')
97+
})
98+
99+
test('renders an async component with props', async () => {
100+
await renderAsync(<AsyncGreeting name="Alice" />)
101+
expect(screen.getByTestId('greeting')).toHaveTextContent('Hello Alice')
102+
})
103+
104+
test('renders an async component that awaits data', async () => {
105+
await renderAsync(<AsyncDataLoader />)
106+
const list = screen.getByTestId('list')
107+
expect(list.children).toHaveLength(3)
108+
expect(list).toHaveTextContent('abc')
109+
})
110+
111+
test('resolves nested async components', async () => {
112+
await renderAsync(<AsyncParent />)
113+
expect(screen.getByTestId('parent')).toBeInTheDocument()
114+
expect(screen.getByTestId('child')).toHaveTextContent('Child Content')
115+
})
116+
117+
test('resolves deeply nested async components', async () => {
118+
await renderAsync(<AsyncDeeplyNested />)
119+
expect(screen.getByTestId('level-1')).toBeInTheDocument()
120+
expect(screen.getByTestId('level-2')).toBeInTheDocument()
121+
expect(screen.getByTestId('level-3')).toHaveTextContent('Deep Content')
122+
})
123+
124+
test('resolves sibling async components', async () => {
125+
await renderAsync(<AsyncSiblings />)
126+
expect(screen.getByTestId('siblings').children).toHaveLength(2)
127+
})
128+
129+
test('resolves async component passed as children to sync wrapper', async () => {
130+
await renderAsync(<AsyncWithSyncWrapper />)
131+
expect(screen.getByTestId('wrapper')).toBeInTheDocument()
132+
expect(screen.getByTestId('child')).toHaveTextContent('Child Content')
133+
})
134+
135+
test('resolves async components inside fragments', async () => {
136+
await renderAsync(<AsyncWithFragment />)
137+
expect(screen.getByTestId('frag-a')).toHaveTextContent('A')
138+
expect(screen.getByTestId('frag-b')).toHaveTextContent('B')
139+
})
140+
141+
test('works with sync components (passthrough)', async () => {
142+
function SyncComponent() {
143+
return <div data-testid="sync">Sync Content</div>
144+
}
145+
await renderAsync(<SyncComponent />)
146+
expect(screen.getByTestId('sync')).toHaveTextContent('Sync Content')
147+
})
148+
149+
test('works with plain HTML elements', async () => {
150+
await renderAsync(<div data-testid="plain">Plain</div>)
151+
expect(screen.getByTestId('plain')).toHaveTextContent('Plain')
152+
})
153+
154+
test('supports rerender with async components', async () => {
155+
const {rerender} = await renderAsync(<AsyncGreeting name="Alice" />)
156+
expect(screen.getByTestId('greeting')).toHaveTextContent('Hello Alice')
157+
158+
await rerender(<AsyncGreeting name="Bob" />)
159+
expect(screen.getByTestId('greeting')).toHaveTextContent('Hello Bob')
160+
})
161+
162+
test('propagates errors from async components', async () => {
163+
await expect(renderAsync(<AsyncThatThrows />)).rejects.toThrow(
164+
'Server component error',
165+
)
166+
})
167+
168+
test('supports render options (container)', async () => {
169+
const container = document.createElement('div')
170+
document.body.appendChild(container)
171+
172+
await renderAsync(<AsyncHelloWorld />, {container})
173+
174+
expect(container.querySelector('[data-testid="hello"]')).toHaveTextContent(
175+
'Hello World',
176+
)
177+
178+
document.body.removeChild(container)
179+
})
180+
181+
test('supports wrapper option', async () => {
182+
const Wrapper = ({children}) => (
183+
<div data-testid="test-wrapper">{children}</div>
184+
)
185+
186+
await renderAsync(<AsyncHelloWorld />, {wrapper: Wrapper})
187+
188+
expect(screen.getByTestId('test-wrapper')).toBeInTheDocument()
189+
expect(screen.getByTestId('hello')).toHaveTextContent('Hello World')
190+
})
191+
192+
test('async component with mixed sync and async children', async () => {
193+
function SyncChild() {
194+
return <span data-testid="sync-child">Sync</span>
195+
}
196+
197+
async function MixedParent() {
198+
return (
199+
<div data-testid="mixed">
200+
<SyncChild />
201+
<AsyncChild />
202+
</div>
203+
)
204+
}
205+
206+
await renderAsync(<MixedParent />)
207+
expect(screen.getByTestId('mixed')).toBeInTheDocument()
208+
expect(screen.getByTestId('sync-child')).toHaveTextContent('Sync')
209+
expect(screen.getByTestId('child')).toHaveTextContent('Child Content')
210+
})
211+
212+
test('returns standard render result properties', async () => {
213+
const {container, baseElement, debug, unmount, asFragment} =
214+
await renderAsync(<AsyncHelloWorld />)
215+
216+
expect(container).toBeInstanceOf(HTMLElement)
217+
expect(baseElement).toBe(document.body)
218+
expect(typeof debug).toBe('function')
219+
expect(typeof unmount).toBe('function')
220+
expect(typeof asFragment).toBe('function')
221+
expect(asFragment()).toBeInstanceOf(DocumentFragment)
222+
})
223+
})
224+
225+
describe('renderAsync with use()', () => {
226+
testGateReact19(
227+
'renders component that calls use() with a promise prop',
228+
async () => {
229+
function UseDataLoader({dataPromise}) {
230+
const data = React.use(dataPromise)
231+
return <div data-testid="use-data">{data.message}</div>
232+
}
233+
234+
await renderAsync(
235+
<UseDataLoader
236+
dataPromise={Promise.resolve({message: 'loaded via use'})}
237+
/>,
238+
)
239+
expect(screen.getByTestId('use-data')).toHaveTextContent(
240+
'loaded via use',
241+
)
242+
},
243+
)
244+
245+
testGateReact19(
246+
'renders component using use() with list data',
247+
async () => {
248+
function UseFetchComponent({itemsPromise}) {
249+
const data = React.use(itemsPromise)
250+
return (
251+
<ul data-testid="use-list">
252+
{data.items.map(item => (
253+
<li key={item}>{item}</li>
254+
))}
255+
</ul>
256+
)
257+
}
258+
259+
await renderAsync(
260+
<UseFetchComponent
261+
itemsPromise={Promise.resolve({items: ['x', 'y', 'z']})}
262+
/>,
263+
)
264+
const list = screen.getByTestId('use-list')
265+
expect(list.children).toHaveLength(3)
266+
},
267+
)
268+
269+
testGateReact19(
270+
'renders async parent with use()-based child',
271+
async () => {
272+
function UseChild({textPromise}) {
273+
const data = React.use(textPromise)
274+
return <span data-testid="use-child">{data.text}</span>
275+
}
276+
277+
async function AsyncParentWithUseChild() {
278+
const title = await Promise.resolve('Async Title')
279+
return (
280+
<div data-testid="async-use-parent">
281+
<h1>{title}</h1>
282+
<UseChild textPromise={Promise.resolve({text: 'from use'})} />
283+
</div>
284+
)
285+
}
286+
287+
await renderAsync(<AsyncParentWithUseChild />)
288+
expect(screen.getByTestId('async-use-parent')).toBeInTheDocument()
289+
expect(screen.getByTestId('use-child')).toHaveTextContent('from use')
290+
},
291+
)
292+
293+
testGateReact19('renders use() with context', async () => {
294+
const ThemeContext = React.createContext('light')
295+
296+
function ThemeReader() {
297+
const theme = React.use(ThemeContext)
298+
return <div data-testid="theme">{theme}</div>
299+
}
300+
301+
const Wrapper = ({children}) => (
302+
<ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
303+
)
304+
305+
await renderAsync(<ThemeReader />, {wrapper: Wrapper})
306+
expect(screen.getByTestId('theme')).toHaveTextContent('dark')
307+
})
308+
309+
testGateReact19('supports rerender with use() components', async () => {
310+
function UseGreeting({namePromise}) {
311+
const name = React.use(namePromise)
312+
return <div data-testid="use-greeting">Hello {name}</div>
313+
}
314+
315+
const {rerender} = await renderAsync(
316+
<UseGreeting namePromise={Promise.resolve('Alice')} />,
317+
)
318+
expect(screen.getByTestId('use-greeting')).toHaveTextContent('Hello Alice')
319+
320+
await rerender(
321+
<UseGreeting namePromise={Promise.resolve('Bob')} />,
322+
)
323+
expect(screen.getByTestId('use-greeting')).toHaveTextContent('Hello Bob')
324+
})
325+
326+
testGateReact19(
327+
'renders use() component wrapped in explicit Suspense',
328+
async () => {
329+
function SlowComponent({dataPromise}) {
330+
const data = React.use(dataPromise)
331+
return <div data-testid="slow">{data.value}</div>
332+
}
333+
334+
const dataPromise = Promise.resolve({value: 'resolved'})
335+
336+
await renderAsync(
337+
<React.Suspense fallback={<div>Loading...</div>}>
338+
<SlowComponent dataPromise={dataPromise} />
339+
</React.Suspense>,
340+
)
341+
expect(screen.getByTestId('slow')).toHaveTextContent('resolved')
342+
},
343+
)
344+
})

0 commit comments

Comments
 (0)