Skip to content

Commit dd27bd9

Browse files
Sebastian Silbermanneps1lon
Sebastian Silbermann
authored andcommitted
Add support for testing updates granularly
1 parent 151f144 commit dd27bd9

9 files changed

+318
-138
lines changed

src/__tests__/act-compat.js

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import * as React from 'react'
2+
import {render, fireEvent, screen} from '../'
3+
import {actIfEnabled} from '../act-compat'
4+
5+
beforeEach(() => {
6+
global.IS_REACT_ACT_ENVIRONMENT = true
7+
})
8+
9+
test('render calls useEffect immediately', async () => {
10+
const effectCb = jest.fn()
11+
function MyUselessComponent() {
12+
React.useEffect(effectCb)
13+
return null
14+
}
15+
await render(<MyUselessComponent />)
16+
expect(effectCb).toHaveBeenCalledTimes(1)
17+
})
18+
19+
test('findByTestId returns the element', async () => {
20+
const ref = React.createRef()
21+
await render(<div ref={ref} data-testid="foo" />)
22+
expect(await screen.findByTestId('foo')).toBe(ref.current)
23+
})
24+
25+
test('fireEvent triggers useEffect calls', async () => {
26+
const effectCb = jest.fn()
27+
function Counter() {
28+
React.useEffect(effectCb)
29+
const [count, setCount] = React.useState(0)
30+
return <button onClick={() => setCount(count + 1)}>{count}</button>
31+
}
32+
const {
33+
container: {firstChild: buttonNode},
34+
} = await render(<Counter />)
35+
36+
effectCb.mockClear()
37+
// eslint-disable-next-line testing-library/no-await-sync-events -- TODO: Remove lint rule.
38+
await fireEvent.click(buttonNode)
39+
expect(buttonNode).toHaveTextContent('1')
40+
expect(effectCb).toHaveBeenCalledTimes(1)
41+
})
42+
43+
test('calls to hydrate will run useEffects', async () => {
44+
const effectCb = jest.fn()
45+
function MyUselessComponent() {
46+
React.useEffect(effectCb)
47+
return null
48+
}
49+
await render(<MyUselessComponent />, {hydrate: true})
50+
expect(effectCb).toHaveBeenCalledTimes(1)
51+
})
52+
53+
test('cleans up IS_REACT_ACT_ENVIRONMENT if its callback throws', async () => {
54+
global.IS_REACT_ACT_ENVIRONMENT = false
55+
56+
await expect(() =>
57+
actIfEnabled(() => {
58+
throw new Error('threw')
59+
}),
60+
).rejects.toThrow('threw')
61+
62+
expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
63+
})
64+
65+
test('cleans up IS_REACT_ACT_ENVIRONMENT if its async callback throws', async () => {
66+
global.IS_REACT_ACT_ENVIRONMENT = false
67+
68+
await expect(() =>
69+
actIfEnabled(async () => {
70+
throw new Error('thenable threw')
71+
}),
72+
).rejects.toThrow('thenable threw')
73+
74+
expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
75+
})
76+
77+
test('state update from microtask does not trigger "missing act" warning', async () => {
78+
let triggerStateUpdateFromMicrotask
79+
function App() {
80+
const [state, setState] = React.useState(0)
81+
triggerStateUpdateFromMicrotask = () => setState(1)
82+
React.useEffect(() => {
83+
// eslint-disable-next-line jest/no-conditional-in-test
84+
if (state === 1) {
85+
Promise.resolve().then(() => {
86+
setState(2)
87+
})
88+
}
89+
}, [state])
90+
return state
91+
}
92+
const {container} = await render(<App />)
93+
94+
await actIfEnabled(() => {
95+
triggerStateUpdateFromMicrotask()
96+
})
97+
98+
expect(container).toHaveTextContent('2')
99+
})

src/__tests__/act.js

+16-88
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,26 @@
11
import * as React from 'react'
2-
import {act, render, fireEvent, screen} from '../'
2+
import {act, render} from '../'
33

44
beforeEach(() => {
55
global.IS_REACT_ACT_ENVIRONMENT = true
66
})
77

8-
test('render calls useEffect immediately', async () => {
9-
const effectCb = jest.fn()
10-
function MyUselessComponent() {
11-
React.useEffect(effectCb)
12-
return null
13-
}
14-
await render(<MyUselessComponent />)
15-
expect(effectCb).toHaveBeenCalledTimes(1)
16-
})
17-
18-
test('findByTestId returns the element', async () => {
19-
const ref = React.createRef()
20-
await render(<div ref={ref} data-testid="foo" />)
21-
expect(await screen.findByTestId('foo')).toBe(ref.current)
22-
})
23-
24-
test('fireEvent triggers useEffect calls', async () => {
25-
const effectCb = jest.fn()
26-
function Counter() {
27-
React.useEffect(effectCb)
28-
const [count, setCount] = React.useState(0)
29-
return <button onClick={() => setCount(count + 1)}>{count}</button>
30-
}
31-
const {
32-
container: {firstChild: buttonNode},
33-
} = await render(<Counter />)
34-
35-
effectCb.mockClear()
36-
// eslint-disable-next-line testing-library/no-await-sync-events -- TODO: Remove lint rule.
37-
await fireEvent.click(buttonNode)
38-
expect(buttonNode).toHaveTextContent('1')
39-
expect(effectCb).toHaveBeenCalledTimes(1)
40-
})
41-
42-
test('calls to hydrate will run useEffects', async () => {
43-
const effectCb = jest.fn()
44-
function MyUselessComponent() {
45-
React.useEffect(effectCb)
46-
return null
47-
}
48-
await render(<MyUselessComponent />, {hydrate: true})
49-
expect(effectCb).toHaveBeenCalledTimes(1)
50-
})
51-
52-
test('cleans up IS_REACT_ACT_ENVIRONMENT if its callback throws', async () => {
53-
global.IS_REACT_ACT_ENVIRONMENT = false
54-
55-
await expect(() =>
56-
act(() => {
57-
throw new Error('threw')
58-
}),
59-
).rejects.toThrow('threw')
60-
61-
expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
62-
})
63-
64-
test('cleans up IS_REACT_ACT_ENVIRONMENT if its async callback throws', async () => {
65-
global.IS_REACT_ACT_ENVIRONMENT = false
66-
67-
await expect(() =>
68-
act(async () => {
69-
throw new Error('thenable threw')
70-
}),
71-
).rejects.toThrow('thenable threw')
72-
73-
expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
74-
})
75-
76-
test('state update from microtask does not trigger "missing act" warning', async () => {
77-
let triggerStateUpdateFromMicrotask
78-
function App() {
79-
const [state, setState] = React.useState(0)
80-
triggerStateUpdateFromMicrotask = () => setState(1)
81-
React.useEffect(() => {
82-
// eslint-disable-next-line jest/no-conditional-in-test
83-
if (state === 1) {
84-
Promise.resolve().then(() => {
85-
setState(2)
86-
})
87-
}
88-
}, [state])
8+
test('does not work outside IS_REACT_ENVIRONMENT like React.act', async () => {
9+
let setState
10+
function Component() {
11+
const [state, _setState] = React.useState(0)
12+
setState = _setState
8913
return state
9014
}
91-
const {container} = await render(<App />)
92-
93-
await act(() => {
94-
triggerStateUpdateFromMicrotask()
95-
})
15+
await render(<Component />)
9616

97-
expect(container).toHaveTextContent('2')
17+
global.IS_REACT_ACT_ENVIRONMENT = false
18+
await expect(async () => {
19+
await act(() => {
20+
setState(1)
21+
})
22+
}).toErrorDev(
23+
'Warning: The current testing environment is not configured to support act(...)',
24+
{withoutStack: true},
25+
)
9826
})

src/__tests__/auto-cleanup-skip.js

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as React from 'react'
33
let render
44
beforeAll(() => {
55
process.env.RTL_SKIP_AUTO_CLEANUP = 'true'
6+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
67
const rtl = require('../')
78
render = rtl.render
89
})

src/__tests__/end-to-end.js

+130-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import * as React from 'react'
2-
import {render, waitForElementToBeRemoved, screen, waitFor} from '../'
1+
let React, cleanup, render, screen, waitFor, waitForElementToBeRemoved
32

43
describe.each([
54
['real timers', () => jest.useRealTimers()],
@@ -9,10 +8,25 @@ describe.each([
98
'it waits for the data to be loaded in a macrotask using %s',
109
(label, useTimers) => {
1110
beforeEach(() => {
11+
jest.resetModules()
12+
global.IS_REACT_ACT_ENVIRONMENT = true
13+
process.env.RTL_SKIP_AUTO_CLEANUP = '0'
14+
1215
useTimers()
16+
17+
React = require('react')
18+
;({
19+
cleanup,
20+
render,
21+
screen,
22+
waitFor,
23+
waitForElementToBeRemoved,
24+
} = require('..'))
1325
})
1426

15-
afterEach(() => {
27+
afterEach(async () => {
28+
await cleanup()
29+
global.IS_REACT_ACT_ENVIRONMENT = false
1630
jest.useRealTimers()
1731
})
1832

@@ -83,10 +97,25 @@ describe.each([
8397
'it waits for the data to be loaded in many microtask using %s',
8498
(label, useTimers) => {
8599
beforeEach(() => {
100+
jest.resetModules()
101+
global.IS_REACT_ACT_ENVIRONMENT = true
102+
process.env.RTL_SKIP_AUTO_CLEANUP = '0'
103+
86104
useTimers()
105+
106+
React = require('react')
107+
;({
108+
cleanup,
109+
render,
110+
screen,
111+
waitFor,
112+
waitForElementToBeRemoved,
113+
} = require('..'))
87114
})
88115

89-
afterEach(() => {
116+
afterEach(async () => {
117+
await cleanup()
118+
global.IS_REACT_ACT_ENVIRONMENT = false
90119
jest.useRealTimers()
91120
})
92121

@@ -167,10 +196,25 @@ describe.each([
167196
'it waits for the data to be loaded in a microtask using %s',
168197
(label, useTimers) => {
169198
beforeEach(() => {
199+
jest.resetModules()
200+
global.IS_REACT_ACT_ENVIRONMENT = true
201+
process.env.RTL_SKIP_AUTO_CLEANUP = '0'
202+
170203
useTimers()
204+
205+
React = require('react')
206+
;({
207+
cleanup,
208+
render,
209+
screen,
210+
waitFor,
211+
waitForElementToBeRemoved,
212+
} = require('..'))
171213
})
172214

173-
afterEach(() => {
215+
afterEach(async () => {
216+
await cleanup()
217+
global.IS_REACT_ACT_ENVIRONMENT = false
174218
jest.useRealTimers()
175219
})
176220

@@ -218,3 +262,84 @@ describe.each([
218262
})
219263
},
220264
)
265+
266+
describe.each([
267+
// ['real timers', () => jest.useRealTimers()],
268+
['fake legacy timers', () => jest.useFakeTimers('legacy')],
269+
// ['fake modern timers', () => jest.useFakeTimers('modern')],
270+
])('testing intermediate states using %s', (label, useTimers) => {
271+
beforeEach(() => {
272+
jest.resetModules()
273+
global.IS_REACT_ACT_ENVIRONMENT = false
274+
process.env.RTL_SKIP_AUTO_CLEANUP = '0'
275+
276+
useTimers()
277+
278+
React = require('react')
279+
;({
280+
cleanup,
281+
render,
282+
screen,
283+
waitFor,
284+
waitForElementToBeRemoved,
285+
} = require('..'))
286+
})
287+
288+
afterEach(async () => {
289+
await cleanup()
290+
jest.useRealTimers()
291+
global.IS_REACT_ACT_ENVIRONMENT = true
292+
})
293+
294+
const fetchAMessageInAMicrotask = () =>
295+
Promise.resolve({
296+
status: 200,
297+
json: () => Promise.resolve({title: 'Hello World'}),
298+
})
299+
300+
function ComponentWithMicrotaskLoader() {
301+
const [fetchState, setFetchState] = React.useState({fetching: true})
302+
303+
React.useEffect(() => {
304+
if (fetchState.fetching) {
305+
fetchAMessageInAMicrotask().then(res => {
306+
return res.json().then(data => {
307+
setFetchState({todo: data.title, fetching: false})
308+
})
309+
})
310+
}
311+
}, [fetchState])
312+
313+
if (fetchState.fetching) {
314+
return <p>Loading..</p>
315+
}
316+
317+
return (
318+
<div data-testid="message">Loaded this message: {fetchState.todo}</div>
319+
)
320+
}
321+
322+
test('waitFor', async () => {
323+
await render(<ComponentWithMicrotaskLoader />)
324+
325+
// TODO: How to assert on the intermediate state?
326+
await expect(
327+
waitFor(() => {
328+
expect(screen.getByText('Loading..')).toBeInTheDocument()
329+
}),
330+
).rejects.toThrowError(/Unable to find an element/)
331+
332+
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
333+
})
334+
335+
test('findBy', async () => {
336+
await render(<ComponentWithMicrotaskLoader />)
337+
338+
// TODO: How to assert on the intermediate state?
339+
await expect(screen.findByText('Loading..')).rejects.toThrowError(
340+
/Unable to find an element/,
341+
)
342+
343+
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
344+
})
345+
})

0 commit comments

Comments
 (0)