Hooks and act
We can now convert the Button
component used in the previous section into a functional component using the useState
hook:
import React from 'react'
import ReactDOM from 'react-dom'
let container = null
beforeEach(() => {
// setup a DOM element as a render target
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
// cleanup on exiting
ReactDOM.unmountComponentAtNode(container)
container.remove()
container = null
})
function Button() {
const [value, setValue] = React.useState(0)
return (
<div>
<div>
value: <span id="value">{value}</span>
</div>
<button onClick={() => setValue(value + 1)}>increment</button>
</div>
)
}
test('stateful button', () => {
ReactDOM.render(<Button />, container)
const value = document.getElementById('value')
expect(value.textContent).toBe('0')
const button = document.querySelector('button')
Simulate.click(button)
expect(value.textContent).toBe('1')
})
Please note that the test has not changed at all. We have refactored the whole component without changing the test! That's one of the goal of testing itself and it's made easy because we have tested the component behaviour from the external point of view (black-box testing), not from the internal one (white-box testing).
The next task is: show the current counter value in the tab title. To implement this feature we will use useEffect
hook that will change the document title every time the counter state changes
/* ######## setup code above ######## */
function Button() {
const [value, setValue] = React.useState(0)
+ React.useEffect(() => {
+ document.title = value
+ }, [value])
return (
<div>
<div>
value: <span id="value">{value}</span>
</div>
<button onClick={() => setValue(value + 1)}>increment</button>
</div>
)
}
test('stateful button', () => {
ReactDOM.render(<Button />, container)
const value = document.getElementById('value')
expect(value.textContent).toBe('0')
+ expect(document.title).toBe('0')
const button = document.querySelector('button')
Simulate.click(button)
expect(value.textContent).toBe('1')
})
The test now fails...
FAIL src/App.test.js
✕ stateful button (7ms)
● stateful button
expect(received).toBe(expected) // Object.is equality
Expected: "0"
Received: ""
46 |
47 | expect(value.textContent).toBe('0')
> 48 | expect(document.title).toBe('0')
| ^
49 |
50 | const button = document.querySelector('button')
51 |
at Object.toBe (src/App.test.js:48:26)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: 0.636s, estimated 1s
Why isn't the title changed? Let's speak about act
.
act
In order for components that use useEffect
and other hooks to work properly in a testing environment we have to use un utility called act
: import { act } from 'react-dom/test-utils
When writing UI tests, tasks like rendering, user events, or data fetching can be considered as “units” of interaction with a user interface. React provides a helper called
act()
that makes sure all updates related to these “units” have been processed and applied to the DOM before you make any assertions: react-documentation
Usage:
act(() => {
// anything that causes a component to render/rerender
})
// make assertions
we need to wrap every interactions and operations that cause a component to render or rerender inside act
to make it behave as it would in a real application
act(() => {
ReactDOM.render(<Button />, container)
})
Now we can fix the test using act
. In our failing example, we can wrap ReactDOM.render
and the click simulations into act
to see the test passing
/* ######## setup code above ######## */
function Button() {
const [value, setValue] = React.useState(0)
React.useEffect(() => {
document.title = value
}, [value])
return (
<div>
<div>
value: <span id="value">{value}</span>
</div>
<button onClick={() => setValue(value + 1)}>increment</button>
</div>
)
}
test('stateful button', () => {
- ReactDOM.render(<Button />, container)
+ act(() => {
+ ReactDOM.render(<Button />, container)
+ })
const value = document.getElementById('value')
expect(value.textContent).toBe('0')
expect(document.title).toBe('0')
const button = document.querySelector('button')
- Simulate.click(button)
+ act(() => {
+ Simulate.click(button)
+ })
expect(value.textContent).toBe('1')
+ expect(document.title).toBe('1')
})
If you run the button in a real application. the browser will show the component working as intended with the tab's title updating according to the button clicks.
act
must be used multiple times in case of multiple interactions, take a look at the code of the test if you want to add one more feature, like allow clicking up to a max
number.
//
// NEW TASK: allowing the counter only increase
// up to a given number
//
// example <Button max={2}/>
// after the counter has reach 2 the button should be disabled
//
we have been given a new feature to implement in our button.
/* ######## setup code above ######## */
- function Button() {
+ function Button({ max }) {
const [value, setValue] = React.useState(0)
React.useEffect(() => {
document.title = value
}, [value])
return (
<div>
<div>
value: <span id="value">{value}</span>
</div>
- <button onClick={() => setValue(value + 1)}>increment</button>
+ <button
+ disabled={value === max}
+ onClick={() => {
+ if (value < max) {
+ setValue(value + 1)
+ }
+ }}
+ >
+ increment
+ </button>
</div>
)
}
test('stateful button', () => {
+ const max = 2
act(() => {
- ReactDOM.render(<Button />, container)
+ ReactDOM.render(<Button max={max} />, container)
})
const value = document.getElementById('value')
expect(value.textContent).toBe('0')
expect(document.title).toBe('0')
const button = document.querySelector('button')
+ expect(button.hasAttribute('disabled')).toBe(false)
act(() => {
Simulate.click(button)
})
+ act(() => {
+ Simulate.click(button)
+ })
+ act(() => {
+ Simulate.click(button)
+ })
- expect(value.textContent).toBe('1')
+ expect(value.textContent).toBe('2')
+ expect(button.hasAttribute('disabled')).toBe(true)
})
Author: Jaga Santagostino