React 16.8 introduced hooks, including a new test function called 'act' which helps better describe what your components do. These slides show how you can use it effectively in your code.
2. Testing React hooks with ‘act’ @d_ir
• Independent software consultant
• aka a contractor
• I’m a generalist / polyglot. Currently doing Ruby on Rails
for Idean
• I care a lot about quality, people, TDD
• I organise Queer Code London
Hello! I’m Daniel
3. Testing React hooks with ‘act’ @d_ir
• React 16.8 introduced hooks, including the useEffect
hook which is used for running side effects in your
component, just like componentDidMount would have
done.
• If you write tests that instrument the useEffect hook, you’ll
get a warning when you run tests about using act.
• That’s because React prefers guard-rail driven
development and it wants to warn you about ‘better’
ways to do things
The story so far… (1 of 2)
4. Testing React hooks with ‘act’ @d_ir
• What we used to do flushing promises should now be
done by using act.
• There are two forms of act: sync and async. Async is for
side effects. The sync form batches up calls to setState
which forces a specific class of errors (that you probably
won’t run into often).
• Async act is absolutely necessary to get rid of warnings
but you’ll need to use the alpha release of React 16.9 to
use it.
The story so far… (2 of 2)
5. Testing React hooks with ‘act’ @d_ir
export class AppointmentFormLoader extends React.Component {
constructor(props) {
super(props);
this.state = { availableTimeSlots: [] }
}
async componentDidMount() {
const result = await window.fetch('/availableTimeSlots', {
method: 'GET',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }
})
this.setState({
availableTimeSlots: await result.json()
})
}
render() { ... }
}
Using
cDM to
fetch
data
7. Testing React hooks with ‘act’ @d_ir
• A spy should always use at least two tests:
1. One test to assert the parameter values that are
passed
2. One test to check the return value is used correctly
Using spies to test dependencies
9. Testing React hooks with ‘act’ @d_ir
import React from 'react';
import ReactDOM from 'react-dom';
import 'whatwg-fetch';
import { AppointmentFormLoader } from '../src/
AppointmentFormLoader';
describe('AppointmentFormLoader', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
jest.spyOn(window, 'fetch');
});
it('fetches data when component is mounted', () => {
ReactDOM.render(<AppointmentFormLoader />, container);
expect(window.fetch).toHaveBeenCalledWith(
'/availableTimeSlots',
expect.objectContaining({
method: 'GET',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }
})
);
});
});
Testing a
fetch call,
pre hooks
10. Testing React hooks with ‘act’ @d_ir
● AppointmentFormLoader › fetches data when
component is mounted
expect(jest.fn()).toHaveBeenCalledWith(expected)
Expected mock function to have been called with:
["/availableTimeSlots", ObjectContaining
{"credentials": "same-origin", "headers":
{"Content-Type": "application/json"}, "method":
"GET"}]
But it was not called.
Running the
previous test
in React
16.8…
One solution is to use the useLayoutEffect hook instead, but...
11. Testing React hooks with ‘act’ @d_ir
it('fetches data when component is mounted', async () => {
ReactDOM.render(<AppointmentFormLoader />, container);
await new Promise(setTimeout);
expect(window.fetch).toHaveBeenCalledWith(
'/availableTimeSlots',
expect.objectContaining({
method: 'GET',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }
})
);
});
Flushing promises
12. Testing React hooks with ‘act’ @d_ir
console.error node_modules/react-dom/cjs/react-dom.development.js:
546
Warning: An update to AppointmentFormLoader inside a test was not
wrapped in act(...).
When testing, code that causes React state updates should be
wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see
in the browser. Learn more at https://fb.me/react-wrap-tests-with-act
in AppointmentFormLoader
But... guard rails
13. Testing React hooks with ‘act’ @d_ir
import { act } from 'react-dom/test-utils';
it('fetches data when component is mounted', () => {
act(() => {
ReactDOM.render(<AppointmentFormLoader />, container);
});
expect(window.fetch).toHaveBeenCalledWith(...);
});
Using act for the previous tests, in React 16.8
The test passes! But unfortunately, this doesn't fix the warning...
14. Testing React hooks with ‘act’ @d_ir
it('fetches data when component is mounted', async () => {
await act(async () => {
ReactDOM.render(<AppointmentFormLoader />, container);
});
expect(window.fetch).toHaveBeenCalledWith(...);
});
Using act for the previous tests, in React 16.9
To suppress the warning we have to use the async version of act.
The sync version still produces the warning
15. Testing React hooks with ‘act’ @d_ir
export const App = () => {
let [counter, setCounter] = useState(0);
return <button onClick={() => setCounter(counter + 1)}
>{counter}</button>;
}
it("should increment a counter", () => {
// we attach the element to document.body to ensure
events work
document.body.appendChild(container);
ReactDOM.render(<App />, container);
const button = container.childNodes[0];
for (let i = 0; i < 3; i++) {
button.dispatchEvent(new MouseEvent(
"click", { bubbles: true }));
}
expect(button.innerHTML).toBe("3");
});
So what
is sync
act
useful
for?
(1 of 2)
Adapted from https://github.com/threepointone/react-act-examples
16. Testing React hooks with ‘act’ @d_ir
act(() => {
for (let i = 0; i < 3; i++) {
button.dispatchEvent(new MouseEvent(
"click", { bubbles: true }));
}
});
expect(button.innerHTML).toBe(3);
// this fails, it's actually "1"!
So what
is sync
act
useful
for?
(2 of 2)
Adapted from https://github.com/threepointone/react-act-examples
18. Testing React hooks with ‘act’ @d_ir
describe('submitting indicator', () => {
it('displays indicator when form is submitting', async () => {
ReactDOM.render(
<CustomerForm {...validCustomer} />, container);
act(() => {
ReactTestUtils.Simulate.submit(form('customer'));
});
await act(async () => {
expect(element('span.submittingIndicator')).not.toBeNull();
});
});
});
The first test
This ensures the expectation executes before your fetch call
19. Testing React hooks with ‘act’ @d_ir
describe('submitting indicator', () => {
it('initially does not display the submitting indicator', () => {
ReactDOM.render(<CustomerForm {...validCustomer} />, container);
expect(element('.submittingIndicator')).toBeNull();
});
it('hides indicator when form has submitted', async () => {
ReactDOM.render(<CustomerForm {...validCustomer} />, container);
await act(async () => {
ReactTestUtils.Simulate.submit(form('customer'));
});
expect(element('.submittingIndicator')).toBeNull();
});
});
The second and third tests
20. Testing React hooks with ‘act’ @d_ir
• Async act (React 16.9.0-alpha.0) is needed whenever
your tests have side effects (e.g. call fetch, animation etc)
• Sync act is used to batch up state mutations to ensure
that you use the updater form of your tests
• But that relies on you always using act
• Testing libraries like Enzyme and react-testing-library are
still building in support for act.
• But you shouldn’t be afraid of rolling your own.
Recap
21. Testing React hooks with ‘act’ @d_ir
• Write lots of short tests that test very small things
• Testing dependencies with spies always requires at least
two tests
• Use arrange-act-assert form for your tests
• If your tests are hard to write, that's a sign that your
production code can be simplified
Some tips for writing better tests
22. Testing React hooks with ‘act’ @d_ir
My book is available on Amazon or
on packtpub.com.
496 pages of sheer goodness,
teaching TDD from the ground up.
It covers this plus much more,
including testing animation using
the same useEffect technique.
Or get in contact:
Email daniel@conciselycrafted.com
Twitter @d_ir
Want to learn more?
23. Testing React hooks with ‘act’ @d_ir
Slides will be online soon--I'll tweet them
Or get in contact:
Email daniel@conciselycrafted.com
Twitter @d_ir
Thanks!