Overview
In this 5th post in this series we are going to look at writing tests for more complex asynchronous call patterns that we added in the 4th post in this series.
NOTE: the starting point code for this post is located here: https://github.com/RyanAtViceSoftware/react-testing-best-practices/tree/step6-adding-complex-async-calls
How Requirements Have Changed
Now that our requirements have changed we have to update our existing test to the new requirements. There are several things that are now different in the behavior of our app including:
- We no longer fetch posts when the page first loads
- We now wait till the user clicks
Get Posts
button before we fetch posts - We expect the user to provide a User Name
- We will call
/users?username=<UserName>
to get the user’s details - We will then call
/posts?userid=<userId>
and pass the returned userid from our previous call to/users
Fixing the “Loading Indicator” Test
First thing we need to do is add code that clicks the Get Posts
button as shown below.
it('Then it shows a loading indicator', () => {
http.get = sinon.stub();
http.get
.withArgs('/posts')
.returns(Promise.resolve(getDummyPosts()));
const app = mount(<App/>);
const button = app.find('button');
expect(button.getElements().length)
.toBeTruthy();
button.simulate('click');
expect(app.getElements().length).toBeTruthy();
const loadingIndicator = app.find('h3');
expect(loadingIndicator.getElements().length)
.toBeTruthy();
expect(
loadingIndicator
.getElements()[0]
.props.children
).toBe('Loading...');
});
Here we
- Query our button from the VDom using
app.find('button')
- Verify that our query returned something using
expect(app.getElements().length).toBeTruthy();
- Click our button using
button.simulate('click');
Rerunning this test shows the following failure.
This makes sense if you think about how we changed our code. We’ve wired up our button as shown below.
<button onClick={this.getPosts}>Get Posts</button>
Now clicking our button will call getPosts
which is shown below.
getPosts(e) {
e.preventDefault();
this.setState({ fetching: true });
getUserByUserName(this.state.username)
.then(user => user.length && user[0].id)
.then(getPostsByUserId)
.then(posts => this.setState({
posts: posts,
fetching: false,
error: null
}))
.catch(e => {
this.setState({
error: e.message,
fetching: false
});
});
}
Our error indicates that line 22 is not returning a promise and if we look at our http
mocking code we will see that we aren’t mocking /users
yet so it makes sense that we’d not be getting a promise in the code under test. So let’s add mocking to return users and see what happens.
it('Then it shows a loading indicator', () => {
http.get = sinon.stub();
const username = 'Bret';
http.get
.withArgs('/posts')
.returns(Promise.resolve(getDummyPosts()));
http.get
.withArgs('/users', {
params: {
username
}
})
.returns(Promise.resolve(getDummyUser()));
const app = mount(<App/>);
const userNameInput = app.find('input');
expect(app.getElements().length).toBeTruthy();
userNameInput.simulate('change', { target: { value: username}});
const button = app.find('button');
expect(button.getElements().length)
.toBeTruthy();
button.simulate('click');
expect(app.getElements().length).toBeTruthy();
const loadingIndicator = app.find('h3');
expect(loadingIndicator.getElements().length)
.toBeTruthy();
expect(
loadingIndicator
.getElements()[0]
.props.children
).toBe('Loading...');
});
Rerunning our test show that it is now passing as shown below.
How it works is that we
- Added a mock for
/users
that expects the user name to be passed on the query string as shown belowhttp.get .withArgs('/users', { params: { username } }) .returns(Promise.resolve(getDummyUser()));
- Added a user name to our text input using Enzyme’s
simulate
method as shown belowconst userNameInput = app.find('input'); expect(app.getElements().length).toBeTruthy(); userNameInput.simulate('change', { target: { value: username}});
- Added a mock for
Fixing Our “Posts Get Written To the Screen” Test
Our other failing tests will obviously need the same updates to get it consistent with the new requirements. So we can copy the code from the above test into it as shown below.
describe('When we have posts on the server ',
() => {
describe('And we enter a username into our Username textbox',
() => {
describe('And we click the Get Posts button',
() => {
it('Then we get posts written to the screen',
done => {
http.get = sinon.stub();
const username = 'Bret';
http.get
.withArgs('/posts')
.returns(Promise.resolve(getDummyPosts()));
http.get
.withArgs('/users', {
params: {
username
}
})
.returns(Promise.resolve(getDummyUser()));
const app = mount(<App/>);
const userNameInput = app.find('input');
expect(app.getElements().length).toBeTruthy();
userNameInput.simulate('change', { target: { value: username}});
const button = app.find('button');
expect(button.getElements().length)
.toBeTruthy();
button.simulate('click');
expect(app.getElements().length).toBeTruthy();
setTimeout(() => {
app.update();
const posts = app.find('li');
expect(posts.getElements().length)
.toBe(3);
done();
});
});
});
});
});
});
And now when we run our test they pass as shown below.
Let’s Refactor!
Now that we have our tests passing let’s clean up our test code. We have the following problems.
DRY
Our first problem is that our tests have repeated code so let’s refactor them to share that code.
DRY Part 1 – Extracting Common Code
First let’s pull the shared code out into shared functions as shown below.
function mountApp() {
http.get = sinon.stub();
const username = 'Bret';
http.get
.withArgs('/posts')
.returns(Promise.resolve(getDummyPosts()));
http.get
.withArgs('/users', {
params: {
username
}
})
.returns(Promise.resolve(getDummyUser()));
const app = mount(<App/>);
return Promise.resolve({app, http, username});
}
function addUserNameAndClickGetPosts({app, http, username}) {
const userNameInput = app.find('input');
expect(app.getElements().length).toBeTruthy();
userNameInput.simulate('change', { target: { value: username}});
const button = app.find('button');
expect(button.getElements().length)
.toBeTruthy();
button.simulate('click');
expect(app.getElements().length).toBeTruthy();
return Promise.resolve({app, http});
}
What we’ve done here is
- Break our test setup code into two functions
mountApp
andaddUserNameAndClickGetPosts
- We have each of our function return the things needed by the next steps in the tests using
Promise.resolve
DRY Part 2 – Updating Loading Indicator
The reason we’ve done this is a style preference that we’ve settled on over time. I’ll review why and the benefits in a bit but for now let’s see how we can consume this code in our Loading Indicator test as shown below.
it('Then it shows a loading indicator', done => {
mountApp()
.then(addUserNameAndClickGetPosts)
.then(({app}) => {
const loadingIndicator = app.find('h3');
expect(loadingIndicator.getElements().length)
.toBeTruthy();
expect(
loadingIndicator
.getElements()[0]
.props.children
).toBe('Loading...');
})
.then(() => done())
.catch(done.fail);
});
This is purely a style preference but the first benefit I like of this approach is that it allows for you to compose your tests of reusable steps that you can chain together using the Promise .then
method. I think this makes the test really readable especially when mixed with named functions that allow hoisting. Combining these two approaches allows you to have the first few lines of your test be very descriptive about what the tests does and then you can dive into the function to find the details. I personally like having this table of contents at the top of my test functions.
A few things to note about the new code:
- We’ve made our test asynchronous by adding
done
as a parameter to our function - We’ve added a
.then(() => done())
after our test code which will call done after the test code runs with no exceptions allowing Jest to know our test passed - We’ve added a
.catch(done.fail)
to our test so that if an exception is thrown it won’t be swallowed giving a false positive and will instead fail the test
NOTE: we are calling .then(() => done())
and not .then(done)
because if we accidentally pass an argument to Jest’s done
function the test will blow up with a very confusing error and hours will be lost and tears will be shed.
Let’s improve our test a little more to take advantage of Jest’s support for promises as shown below.
it('Then it shows a loading indicator', async () => {
return mountApp()
.then(addUserNameAndClickGetPosts)
.then(({app}) => {
const loadingIndicator = app.find('h3');
expect(loadingIndicator.getElements().length)
.toBeTruthy();
expect(
loadingIndicator
.getElements()[0]
.props.children
).toBe('Loading...');
});
});
The things we have changed here are:
- On line 1 we changed our test function to be an async arrow function,
async () =>
, instead of a function that takes adone
callback function - On line 2 we are returning the promise that is created by our
mountApp()
function - We removed the
.then(() => done())
and the.catch(done.fail)
parts of our promise chain
The test will work exactly the same as before but here we are saving some typing by using Jest’s promise support which
- Will fail the tests if the promise chain
reject
‘s due to an explicitreject
call or due to an exception being thrown - Will pass the test if the promise chain completes successfully
- Allow us not to worry about accidentally passing an argument to
done
and loosing half a day as described in the note above
DRY Part 3 – Updating Get Posts
Let’s refactor our Get Posts test.
describe('When we have posts on the server ',
() => {
it('Then we get posts written to the screen',
async () => {
return mountApp()
.then(addUserNameAndClickGetPosts)
.then(({app}) => {
setTimeout(() => {
app.update();
const posts = app.find('li');
expect(posts.getElements().length)
.toBe(3);
});
});
});
});
Now our tests are way shorter and easier to follow and our shared code has been refactored into reusable steps that we can compose in other tests using promise chains.
NOTE: one other benefit of using promise chains is that each step will move the code in that step to the next tick of Javascript’s event loop which will save having to wrap code in setTimeout
calls some of the time.
Let’s Make Our Tests Clear About The changes
We’ve cleaned up the shared code and our tests are passing but we still have a major problem. Our tests are still communicating the old requirements in the test output and in the describe
and it
parameters so let’s get those updated as our last step. Let’s update the code as shown below.
Here we have
- Inserted a new
describe('When fill in a username and click Get Posts ',
- Updated our previous
Then we have posts...
to beAnd we have posts...
- Inserted a new
And this makes our test output look like below.
This is extremely important as the point of the tests is to test the behavior of the system and to make it easy for other developers to know when tests fail if they have broken something or if the test simply needs to be updated to new requirements (or behaviors) as we did here. If the behavior the tests expects isn’t clear then the developer will automatically think that the tests needs to be updated as the behavior described won’t match the current system behavior. At a minimum lot’s of time could be wasted and all this could be easily avoided by keeping the test behaviors updated in the describe
and it
calls.
In future posts we are going to look at applying these approaches to our server code.
NOTE: the final code from this part of the series is available here: https://github.com/RyanAtViceSoftware/react-testing-best-practices/tree/step7-adding-tests-for-complex-async-calls