Some notes...
Enzyme gives us several ways to render components for testing: using
shallow
, mount
, and static
. shallow
... is used to isolate one component for testing and ensure child components do not affect assertions. You can think of it as rendering "just" the component you want it to.
mount is "real" rendering that will actually render your component into a browser environment. If you are creating full React components (and not just stateless components), you will want to use
mount
to do testing on the lifecycle methods of your component. We are using jsdom to accomplish rendering in a browser-like environment, but you could just as easily run it in a browser of your choosing.static
... is used for analyzing the actual HTML output of a componenthttps://semaphoreci.com/community/tutorials/getting-started-with-tdd-in-react
Mocha is the test runner, or test "framework". It's the top-level tool in this hierarchy. Mocha is responsible for finding and loading test files, transpiling them, and running the test code itself: the
describe
and it
blocks that compose the tests.
Chai is the assertion library. It supplies the
expect
and assert
calls we'll use in the tests to verify everything is working correctly.
Sinon is a library for creating and inspecting spies. Spies let you mock and stub pieces of functionality in order to keep the tests laser-focused on the component under test.
Enzyme is a library for rendering and making assertions on React components. It's the only one of these 4 that is specific to React.
Here's how these all work together:
- You run
mocha
at the command line, with some arguments, - It finds your test files and transpiles them,
- It executes the tests, which are written in JavaScript (ES6 in our case), and
- Each test will
import
enzyme and chai, then use them to render components and make assertions
What to Test?
It must render: At the very least, make sure the component renders without error. This verifies there are no JSX syntax errors, that all variables are defined, etc. This could be as simple as verifying that the rendered output is not null.
Test the output: One step above "it renders" is "it renders the correct thing." Given a set of props, what output is expected? Does
Person
render its name and age, or does it render a name and "TODO: age coming in v2.1"?
Test the states: Every conditional should be accounted for. If the classNames are conditional (e.g. enabled/disabled, success/warning/error, etc.), make sure to test that the className-deciding logic is working well. Likewise for conditionally-rendered children: if the
Logout
button is only visible when the user is logged in, for instance, make sure to test for that.
Test the events: If the component can be interacted with, e.g. an
input
or button
with an onClick
or onChange
or onAnything
, test that the events work as expected and call the specified functions with the correct arguments, including binding this
, if it matters.
Test the edge cases: Anything that operates on an array could have boundary cases — an empty array, an array with 1 element, a paginated list that should truncate at 25 items, and so on. Try out every edge case you can think of, and make sure they all work correctly.
...
Test 2: Testing the Container State
...
describe('BeerListContainer', () => {
...
it('should start with an empty list', () => {
const wrapper = shallow(<BeerListContainer/>);
expect(wrapper.state('beers')).to.equal([]);
});
});
....
Wait, it failed with another error:
AssertionError: expected [] to equal []
This is because we used
.equal
, which tests for object equality with the ===
operator. Two empty arrays are not the exact same object, therefore they're not equal.
If we use
eql
instead, the test will pass. In components.spec.js
, change that expectation to this:expect(wrapper.state('beers')).to.eql([]);
The test is now passing.
Test 3: Adding an Item
...
describe('BeerListContainer', () => {
...
it('adds items to the list', () => {
const wrapper = shallow(<BeerListContainer/>);
wrapper.instance().addItem('Sam Adams');
expect(wrapper.state('beers')).to.eql(['Sam Adams']);
});
});
...
Now, we can update
state
from inside addItem
. Change addItem
to look like this:export class BeerListContainer extends Component {
...
addItem(name) {
this.setState({
beers: [].concat(this.state.beers).concat([name])
});
}
...
}
Now, the test is passing.
The way we updated the array might look unfamiliar: doing it this way ensures that we don't mutate the existing state. Avoiding mutations on
state
is a good habit to get into, especially if you use or plan to use Redux. It ensures that the rendered view is always in sync with the current state.
Using a library like Immutable.js makes it easier to write immutable code like this....
Test 4: Passing Down the Function
Whenever we add a new prop to a component, it's a really good idea to create a PropTypes definition for it. You can read more about why PropTypes are important, but in a nutshell, propTypes let you define the expected props and their types, and React will give you a console warning if you forget to pass a required prop or pass the wrong type.
Now, add the test to
components.spec.js
:describe('BeerListContainer', () => {
...
it('passes addItem to InputArea', () => {
const wrapper = shallow(<BeerListContainer/>);
const inputArea = wrapper.find(InputArea);
const addItem = wrapper.instance().addItem;
expect(inputArea.prop('onSubmit')).to.eql(addItem);
});
});
...
To make the test pass, modify the
render
method of BeerListContainer
to pass the onSubmit
prop to InputArea
:export class BeerListContainer extends Component {
...
render() {
return (
<div>
<InputArea onSubmit={this.addItem}/>
<BeerList/>
</div>
);
}
}
Test 5: Verifying the Binding
Let's just make sure that the function passed to
InputArea
is still working. This might seem a bit redundant, but add this test:describe('BeerListContainer', () => {
...
it('passes a bound addItem function to InputArea', () => {
const wrapper = shallow(<BeerListContainer/>);
const inputArea = wrapper.find(InputArea);
inputArea.prop('onSubmit')('Sam Adams');
expect(wrapper.state('beers')).to.eql(['Sam Adams']);
});
});
and, it fails.
Cannot read property 'setState' of undefined
This can be difficult when using ES6 classes with React: the instance methods, like
addItem
here, are not automatically bound to the instance.- bind the function once, in the constructor
Modify the constructor of
BeerListComponent
(in components.js
) to read like this:export class BeerListContainer extends Component {
constructor(props) {
super(props);
this.state = {
beers: []
};
this.addItem = this.addItem.bind(this);
}
...
}
now our test passes.
Test 7: Accepting Input
Now, let's wire up the
input
box to accept changes. Write the test:describe('InputArea', () => {
...
it('should accept input', () => {
const wrapper = shallow(<InputArea/>);
const input = wrapper.find('input');
input.simulate('change', {target: { value: 'Resin' }});
expect(wrapper.state('text')).to.equal('Resin');
expect(input.prop('value')).to.equal('Resin');
});
});
We use
input.simulate
here to fire the onChange
event with the given object as an argument.
....
export class InputArea extends Component {
constructor(props) {
super(props);
this.state = {
text: ''
};
this.setText = this.setText.bind(this);
}
setText(event) {
this.setState({text: event.target.value});
}
render() {
return (
<div>
<input value={this.state.text} onChange={this.setText}/>
<button>Add</button>
</div>
);
}
}
...
Way back in the beginning I mentioned that we'll need full rendering (instead of shallow) for the input handling. Now is the time to make that change. Update the test to call
mount
instead of shallow
:describe('InputArea', () => {
...
it('should accept input', () => {
const wrapper = mount(<InputArea/>);
...
All tests should be passing once again.
Test 8: Enabling the Add Button
import { spy } from 'sinon';
describe('InputArea', () => {
...
it('should call onSubmit when Add is clicked', () => {
const addItemSpy = spy();
const wrapper = shallow(<InputArea onSubmit={addItemSpy}/>);
wrapper.setState({text: 'Octoberfest'});
const addButton = wrapper.find('button');
addButton.simulate('click');
expect(addItemSpy.calledOnce).to.equal(true);
expect(addItemSpy.calledWith('Octoberfest')).to.equal(true);
});
});
...
export class InputArea extends Component {
constructor(props) {
super(props);
this.state = {
text: ''
};
this.setText = this.setText.bind(this);
this.handleClick = this.handleClick.bind(this);
}
...
handleClick() {
this.props.onSubmit(this.state.text);
}
render() {
return (
<div>
<input value={this.state.text} onChange={this.setText}/>
<button onClick={this.handleClick}>Add</button>
</div>
);
}
}
Tests 9-11: Rendering the List
describe('BeerList', () => {
it('should render zero items', () => {
const wrapper = shallow(<BeerList items={[]}/>);
expect(wrapper.find('li')).to.have.length(0);
});
it('should render undefined items', () => {
const wrapper = shallow(<BeerList items={undefined}/>);
expect(wrapper.find('li')).to.have.length(0);
});
it('should render some items', () => {
const items = ['Sam Adams', 'Resin', 'Octoberfest'];
const wrapper = shallow(<BeerList items={items}/>);
expect(wrapper.find('li')).to.have.length(3);
});
});
...
export class BeerList extends Component {
render() {
return this.props.items ?
(<ul>
{this.props.items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>)
: null;
}
}
BeerList.propTypes = {
items: React.PropTypes.array.isRequired
};
Test 12: Rendering the Items
describe('BeerListContainer', () => {
...
it('renders the items', () => {
const wrapper = mount(<BeerListContainer/>);
wrapper.instance().addItem('Sam Adams');
wrapper.instance().addItem('Resin');
expect(wrapper.find('li').length).to.equal(2);
});
}
export class BeerListContainer extends Component {
...
render() {
return (
<div>
<InputArea onSubmit={this.addItem}/>
<BeerList items={this.state.beers}/>
</div>
);
}
}
No comments:
Post a Comment