Testing Stateful Components

Both of the React components we’ve built so far are stateless: their render output is determined entirely by the props they receive, allowing us to express them as a single function. This has the advantage of simplicity. But a carousel is stateful: it needs to keep track of which slide it’s currently showing. In this section, we’ll take a TDD approach to building a Carousel component with internal state.

Start with a stub implementation of the component. Since functional components can’t have state, make it a subclass of React.PureComponent:

 // src/Carousel.js
 import​ React ​from​ ​'react'​;
 
 class​ Carousel ​extends​ React.PureComponent {
  render() {
 return​ <div />;
  }
 }
 
 export​ ​default​ Carousel;

Using React.PureComponent instead of React.Component tells React that our component doesn’t need to be re-rendered unless its props or state change. It’s a good practice to keep components pure whenever possible, both for performance and for conceptual simplicity.

Add a skeletal test suite:

 // src/tests/Carousel.test.js
 import​ React ​from​ ​'react'​;
 import​ { shallow } ​from​ ​'enzyme'​;
 import​ Carousel ​from​ ​'../Carousel'​;
 
 describe(​'Carousel'​, () => {
 let​ wrapper;
 
  beforeEach(() => {
  wrapper = shallow(<Carousel />);
  });
 
  it(​'renders a <div>'​, () => {
  expect(wrapper.type()).toBe(​'div'​);
  });
 });

Now let’s outline some requirements for this component:

  • The carousel keeps track of the current slide with a number, slideIndex.

  • slideIndex is initially 0, meaning that the first slide is shown.

  • Clicking the “Prev” button decreases slideIndex, wrapping around to the index of the last slide when it would go below 0.

  • Clicking the “Next” button increases slideIndex, wrapping around to 0 when it would go above the index of the last slide.

  • The carousel takes an array named slides and renders the slide indicated by slideIndex.

Translating these requirements into tests will help us figure out exactly how to implement them.

Testing State

Enzyme’s API for working with state is analogous to its API for working with props. Call wrapper.state(key) to get the piece of state corresponding to that key:

 // src/tests/Carousel.test.js
 ...
»it(​'has an initial `slideIndex` of 0'​, () => {
» expect(wrapper.state(​'slideIndex'​)).toBe(0);
»});
 ...

To satisfy this test, you need Carousel to attach a state object to itself when it’s instantiated. One way to do that would be to add a constructor(). However, you can do better using the “class properties” syntax, which let you define instance properties as if they were variables scoped inside of a class block:

 // src/Carousel.js
 ...
 class​ Carousel ​extends​ React.PureComponent {
» state = {
» slideIndex: 0,
» };
 
  render() {
 return​ <div />;
  }
 }
 ...

This syntax isn’t supported out-of-the-box by @babel/preset-react or @babel/preset-env, but it’s become enormously popular in the React community. To add it to the project, install the @babel/plugin-proposal-class-properties plugin:

 $ ​​npm​​ ​​install​​ ​​--save-dev​​ ​​@babel/[email protected]
 + @babel/[email protected]

That addition yields the final package.json for the chapter:

 {
 "name"​: ​"test-driven-carousel"​,
 "version"​: ​"1.0.0"​,
 "description"​: ​""​,
 "main"​: ​"index.js"​,
 "scripts"​: {
 "test"​: ​"jest"​,
 "lint"​: ​"eslint . && prettier-eslint --list-different **/*.js"​,
 "format"​: ​"prettier-eslint --write **/*.js"
  },
 "keywords"​: [],
 "author"​: ​""​,
 "license"​: ​"ISC"​,
 "devDependencies"​: {
 "@babel/core"​: ​"^7.2.0"​,
 "@babel/plugin-proposal-class-properties"​: ​"^7.1.0"​,
 "@babel/preset-env"​: ​"^7.2.0"​,
 "@babel/preset-react"​: ​"^7.0.0"​,
 "babel-core"​: ​"^7.0.0-bridge.0"​,
 "babel-eslint"​: ​"^10.0.1"​,
 "babel-jest"​: ​"^23.6.0"​,
 "enzyme"​: ​"^3.8.0"​,
 "enzyme-adapter-react-16"​: ​"^1.7.1"​,
 "eslint"​: ​"^5.10.0"​,
 "eslint-plugin-jest"​: ​"^22.1.2"​,
 "eslint-plugin-react"​: ​"^7.11.1"​,
 "jest"​: ​"^23.6.0"​,
 "prettier-eslint-cli"​: ​"^4.7.1"​,
 "react-dom"​: ​"^16.4.2"
  },
 "dependencies"​: {
 "prop-types"​: ​"^15.7.2"​,
 "react"​: ​"^16.4.2"
  }
 }

Now add a plugins entry to .babelrc.js. Plugins listed there are combined with those provided by the presets:

 module.exports = {
  presets: [​'@babel/preset-react'​, ​'@babel/preset-env'​],
» plugins: [​'@babel/plugin-proposal-class-properties'​],
 };

With this added Babel plugin, Carousel’s initial state should work as intended. Now to make the component interactive!

Testing Event Handlers

To give the user a way to change the slideIndex, the Carousel component needs some buttons:

 // src/tests/Carousel.test.js
 ...
»import​ CarouselButton ​from​ ​'../CarouselButton'​;
 
 describe(​'Carousel'​, () => {
» ...
» it(​'renders a CarouselButton labeled "Prev"'​, () => {
» expect(
» wrapper
» .find(CarouselButton)
» .at(0)
» .prop(​'children'​)
» ).toBe(​'Prev'​);
» });
»
» it(​'renders a CarouselButton labeled "Next"'​, () => {
» expect(
» wrapper
» .find(CarouselButton)
» .at(1)
» .prop(​'children'​)
» ).toBe(​'Next'​);
» });
 });

find() uses the CarouselButton component itself as a selector. The string ’CarouselButton’ would also work here, but string selectors are less reliable, since they depend on a detail of the target component (displayName) that’s otherwise hidden from you.

Since find() returns multiple results, at(index) is used to get a shallow wrapper around an individual button.

prop(’children’) returns what was passed as the children prop to the CarouselButton. Note that text() only works for DOM elements, not for components.

Jump to the implementation and add the buttons to the render tree:

 // src/Carousel.js
 import​ React ​from​ ​'react'​;
»import​ CarouselButton ​from​ ​'./CarouselButton'​;
 
 class​ Carousel ​extends​ React.PureComponent {
  ...
  render() {
»return​ (
» <div>
» <CarouselButton>Prev</CarouselButton>
» <CarouselButton>Next</CarouselButton>
» </div>
» );
  }
 }
 ...

Now we need to simulate click events on the “Prev” and “Next” buttons. Rather than relying on the relative order of the buttons in the render tree for every test, add data- attributes to make our test selectors more self-explanatory. The names of the attributes will have no effect on the functionality of the app. So, render the “Prev” button with data-action="prev" and the “Next” button with data-action="next", making it a breeze to target them with find(selector):

 // src/tests/Carousel.test.js
 ...
»it(​'decrements `slideIndex` when Prev is clicked'​, () => {
» wrapper.setState({ slideIndex: 1 });
» wrapper.find(​'[data-action="prev"]'​).simulate(​'click'​);
» expect(wrapper.state(​'slideIndex'​)).toBe(0);
»});
»
»it(​'increments `slideIndex` when Next is clicked'​, () => {
» wrapper.setState({ slideIndex: 1 });
» wrapper.find(​'[data-action="next"]'​).simulate(​'click'​);
» expect(wrapper.state(​'slideIndex'​)).toBe(2);
»});
 ...

The simulate(’click’) calls trigger the onClick handler attached to the target node.

Now add the data- attrs (as props) to the buttons, along with click handlers:

 // src/Carousel.js
 ...
»handlePrevClick = () => {
»this​.setState(({ slideIndex }) => ({ slideIndex: slideIndex - 1 }));
»};
»
»handleNextClick = () => {
»this​.setState(({ slideIndex }) => ({ slideIndex: slideIndex + 1 }));
»};
 
 render() {
 return​ (
» <div>
» <CarouselButton data​-​action=​"prev"​ onClick=​{​​this​.handlePrevClick​}​>
» Prev
» </CarouselButton>
» <CarouselButton data​-​action=​"next"​ onClick=​{​​this​.handleNextClick​}​>
» Next
» </CarouselButton>
» </div>
  );
 }
 ...

The click handlers are defined using the same class property syntax used for state, which automatically binds them to the component instance. To put a finer point on it: new copies of state, handlePrevClick(), and handleNextClick() are created for every instance of Carousel.

Previously, we’ve used setState() with a state update object as its argument. Here, it’s given a callback instead. The callback receives the original state and returns an update object. This is a helpful approach for avoiding timing issues: setState() is asynchronous, so it’s possible in principle for multiple state changes to be queued up. If, for instance, a “Prev” click and a “Next” click both registered before the state changes flushed (unlikely but theoretically possible), the correct result would be for the two slideIndex changes to cancel each other out, which is exactly what the two callbacks would do.

With that, the tests should be green. Sharp-eyed readers will notice that the click handlers don’t yet handle the case where slideIndex would go too low or too high and need to wrap around. In order to do that, Carousel needs to know how many slides there are. Which brings us to our final Carousel feature: rendering the slides.

Manipulating State in Tests

So far, the carousel is just a pair of buttons. What it really needs is some slides. A simple approach to this is to give the Carousel instance an array of data as a prop called slides, then pass the data from slides[slideIndex] as props to a CarouselSlide.

Replace the existing beforeEach block to set the slides prop on the Carousel instance, then add a (failing) test for the slide-passing behavior:

 // src/tests/Carousel.test.js
 import​ React ​from​ ​'react'​;
 import​ { shallow } ​from​ ​'enzyme'​;
 import​ Carousel ​from​ ​'../Carousel'​;
 import​ CarouselButton ​from​ ​'../CarouselButton'​;
»import​ CarouselSlide ​from​ ​'../CarouselSlide'​;
 
 describe(​'Carousel'​, () => {
 let​ wrapper;
 
»const​ slides = [
» {
» imgUrl: ​'https://example.com/slide1.png'​,
» description: ​'Slide 1'​,
» attribution: ​'Uno Pizzeria'​,
» },
» {
» imgUrl: ​'https://example.com/slide2.png'​,
» description: ​'Slide 2'​,
» attribution: ​'Dos Equis'​,
» },
» {
» imgUrl: ​'https://example.com/slide3.png'​,
» description: ​'Slide 3'​,
» attribution: ​'Three Amigos'​,
» },
» ];
»
» beforeEach(() => {
» wrapper = shallow(<Carousel slides=​{​slides​}​ />);
» });
 
  ...
 
» it(​'renders the current slide as a CarouselSlide'​, () => {
»let​ slideProps;
» slideProps = wrapper.find(CarouselSlide).props();
» expect(slideProps).toEqual(slides[0]);
» wrapper.setState({ slideIndex: 1 });
» slideProps = wrapper.find(CarouselSlide).props();
» expect(slideProps).toEqual(slides[1]);
» });
 });

The new test uses a method called props() instead of prop(). This method returns an object containing all of the node’s props.

Recall that toEqual() does a deep comparison of two objects, whereas toBe() does a strict equality check. Strict equality would fail here; there’s no way to replace all of a component instance’s props with a different object, even if we wanted to. Instead, what we’re checking is: “Does this component have all of the props from slides[0], and no other props?”

Enzyme’s setState() method works analogously to setProps(), merging the given object into the component’s existing state.

Then update the render method in Carousel to satisfy the test:

 // src/Carousel.js
 import​ React ​from​ ​'react'​;
 import​ CarouselButton ​from​ ​'./CarouselButton'​;
»import​ CarouselSlide ​from​ ​'./CarouselSlide'​;
 
 class​ Carousel ​extends​ React.PureComponent {
  state = {
  slideIndex: 0,
  };
 
  handlePrevClick = () => {
 this​.setState(({ slideIndex }) => ({ slideIndex: slideIndex - 1 }));
  };
  handleNextClick = () => {
 this​.setState(({ slideIndex }) => ({ slideIndex: slideIndex + 1 }));
  };
 
  render() {
»const​ { slides, ...rest } = ​this​.props;
»return​ (
» <div ​{​...rest​}​>
» <CarouselSlide ​{​...slides​[​this.state.slideIndex​]}​ />
» <CarouselButton data​-​action=​"prev"​ onClick=​{​​this​.handlePrevClick​}​>
» Prev
» </CarouselButton>
» <CarouselButton data​-​action=​"next"​ onClick=​{​​this​.handleNextClick​}​>
» Next
» </CarouselButton>
» </div>
» );
  }
 }
 
 export​ ​default​ Carousel;

The ...slides[this.state.slideIndex] spread passes every key-value pair in the object into the <CarouselSlide>, satisfying our test.

Having slides as an array also allows us to solve the slideIndex “overflow” problem: in the Next click handler, you can use slides.length as an upper bound and wrap to 0 when the new index would collide with that bound. Conversely, in the Prev click handler, you can wrap from 0 to the max value of slides.length - 1. Write some tests to make this concrete:

 import​ React ​from​ ​'react'​;
 import​ { shallow } ​from​ ​'enzyme'​;
 import​ Carousel ​from​ ​'../Carousel'​;
 import​ CarouselButton ​from​ ​'../CarouselButton'​;
 import​ CarouselSlide ​from​ ​'../CarouselSlide'​;
 
 describe(​'Carousel'​, () => {
 let​ wrapper;
 
 const​ slides = [
  {
  imgUrl: ​'https://example.com/slide1.png'​,
  description: ​'Slide 1'​,
  attribution: ​'Uno Pizzeria'​,
  },
  {
  imgUrl: ​'https://example.com/slide2.png'​,
  description: ​'Slide 2'​,
  attribution: ​'Dos Equis'​,
  },
  {
  imgUrl: ​'https://example.com/slide3.png'​,
  description: ​'Slide 3'​,
  attribution: ​'Three Amigos'​,
  },
  ];
 
  beforeEach(() => {
  wrapper = shallow(<Carousel slides=​{​slides​}​ />);
  });
 
  it(​'renders a <div>'​, () => {
  expect(wrapper.type()).toBe(​'div'​);
  });
 
  it(​'has an initial `slideIndex` of 0'​, () => {
  expect(wrapper.state(​'slideIndex'​)).toBe(0);
  });
 
  it(​'renders a CarouselButton labeled "Prev"'​, () => {
  expect(
  wrapper
  .find(CarouselButton)
  .at(0)
  .prop(​'children'​)
  ).toBe(​'Prev'​);
  });
 
  it(​'renders a CarouselButton labeled "Next"'​, () => {
  expect(
  wrapper
  .find(CarouselButton)
  .at(1)
  .prop(​'children'​)
  ).toBe(​'Next'​);
  });
 
» describe(​'with a middle slide selected'​, () => {
» beforeEach(() => {
» wrapper.setState({ slideIndex: 1 });
» });
»
» it(​'decrements `slideIndex` when Prev is clicked'​, () => {
» wrapper.find(​'[data-action="prev"]'​).simulate(​'click'​);
» expect(wrapper.state(​'slideIndex'​)).toBe(0);
» });
»
» it(​'increments `slideIndex` when Next is clicked'​, () => {
» wrapper.setState({ slideIndex: 1 });
» wrapper.find(​'[data-action="next"]'​).simulate(​'click'​);
» expect(wrapper.state(​'slideIndex'​)).toBe(2);
» });
» });
»
» describe(​'with the first slide selected'​, () => {
» it(​'wraps `slideIndex` to the max value when Prev is clicked'​, () => {
» wrapper.setState({ slideIndex: 0 });
» wrapper.find(​'[data-action="prev"]'​).simulate(​'click'​);
» expect(wrapper.state(​'slideIndex'​)).toBe(slides.length - 1);
» });
» });
»
» describe(​'with the last slide selected'​, () => {
» it(​'wraps `slideIndex` to the min value when Next is clicked'​, () => {
» wrapper.setState({ slideIndex: slides.length - 1 });
» wrapper.find(​'[data-action="next"]'​).simulate(​'click'​);
» expect(wrapper.state(​'slideIndex'​)).toBe(0);
» });
» });
 
  it(​'renders the current slide as a CarouselSlide'​, () => {
 let​ slideProps;
  slideProps = wrapper.find(CarouselSlide).props();
  expect(slideProps).toEqual(slides[0]);
  wrapper.setState({ slideIndex: 1 });
  slideProps = wrapper.find(CarouselSlide).props();
  expect(slideProps).toEqual(slides[1]);
  });
 });

The original click handler tests are now in a describe() block to make their initial condition explicit, and two new describe() blocks have been added below it for the edge cases.

Now all you have to do is update the handlers:

 // src/Carousel.js
 ...
 handlePrevClick = () => {
»const​ { slides } = ​this​.props;
»this​.setState(({ slideIndex }) => ({
» slideIndex: (slideIndex + slides.length - 1) % slides.length,
» }));
 };
 
 handleNextClick = () => {
»const​ { slides } = ​this​.props;
»this​.setState(({ slideIndex }) => ({
» slideIndex: (slideIndex + 1) % slides.length,
» }));
 };
 ...

The remainder operator (%) makes it possible to deal with the click handler edge cases succinctly.

One last thing: you’ll need to declare propTypes in order to make the linter happy. This time, use the static keyword to declare them as a static property within the class definition:

 import​ React ​from​ ​'react'​;
»import​ PropTypes ​from​ ​'prop-types'​;
 import​ CarouselButton ​from​ ​'./CarouselButton'​;
 import​ CarouselSlide ​from​ ​'./CarouselSlide'​;
 
 class​ Carousel ​extends​ React.PureComponent {
»static​ propTypes = {
» slides: PropTypes.arrayOf(PropTypes.shape(CarouselSlide.propTypes))
» .isRequired,
» };
 
  state = {
  slideIndex: 0,
  };
 
  handlePrevClick = () => {
 const​ { slides } = ​this​.props;
 this​.setState(({ slideIndex }) => ({
  slideIndex: (slideIndex + slides.length - 1) % slides.length,
  }));
  };
 
  handleNextClick = () => {
 const​ { slides } = ​this​.props;
 this​.setState(({ slideIndex }) => ({
  slideIndex: (slideIndex + 1) % slides.length,
  }));
  };
 
  render() {
 const​ { slides, ...rest } = ​this​.props;
 return​ (
  <div ​{​...rest​}​>
  <CarouselSlide ​{​...slides​[​this.state.slideIndex​]}​ />
  <CarouselButton data​-​action=​"prev"​ onClick=​{​​this​.handlePrevClick​}​>
  Prev
  </CarouselButton>
  <CarouselButton data​-​action=​"next"​ onClick=​{​​this​.handleNextClick​}​>
  Next
  </CarouselButton>
  </div>
  );
  }
 }
 
 export​ ​default​ Carousel;

These prop types say, “slides must be an array of objects that each have the same shape as the propTypes declared by CarouselSlide.”

With that, this chapter’s Carousel is complete! Put a bow on it with a commit:

 :sparkles: Initial implementation of Carousel component
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.223.172.132