Testing Styled Components

Let’s check how our tests are doing, starting with Carousel:

 $ ​​npx​​ ​​jest​​ ​​src/tests/Carousel.test.js
  FAIL src/tests/Carousel.test.js
  Carousel
  ✓ renders a <div> (5ms)
  ✓ has an initial `slideIndex` of 0 (1ms)
  ✓ renders a CarouselButton labeled "Prev" (1ms)
  ✓ renders a CarouselButton labeled "Next"
  ✕ renders the current slide as a CarouselSlide (12ms)
  with a middle slide selected
  ✓ decrements `slideIndex` when Prev is clicked (4ms)
  ✓ increments `slideIndex` when Next is clicked (1ms)
  with the first slide selected
  ✓ wraps `slideIndex` to the max value when Prev is clicked
  with the last slide selected
  ✓ wraps `slideIndex` to the min value when Next is clicked (1ms)
 
  ● Carousel › renders the current slide as a CarouselSlide
 
  expect(received).toEqual(expected)
 
  Expected value to equal:
  {"attribution": "Uno Pizzeria", "description": "Slide 1",
  "imgUrl": "https://example.com/slide1.png"}
  Received:
  {"Img": ...}

Currently, the test implicitly assumes that CarouselSlide only has the props it receives from Carousel. That assumption no longer holds now that CarouselSlide has default props. Let’s update the test to account for those:

 // src/tests/Carousel.test.js
 ...
 it(​'renders the current slide as a CarouselSlide'​, () => {
 let​ slideProps;
  slideProps = wrapper.find(CarouselSlide).props();
  expect(slideProps).toEqual({
  ...CarouselSlide.defaultProps,
  ...slides[0],
  });
  wrapper.setState({ slideIndex: 1 });
  slideProps = wrapper.find(CarouselSlide).props();
  expect(slideProps).toEqual({
  ...CarouselSlide.defaultProps,
  ...slides[1],
  });
 })...

Now move on to the CarouselSlide tests:

 $ ​​npx​​ ​​jest​​ ​​src/tests/CarouselSlide.test.js
  FAIL src/tests/CarouselSlide.test.js
  ● CarouselSlide › renders an <img> and a <figcaption> as children
 
  expect(received).toBe(expected) // Object.is equality
 
  Expected: "img"
  Received: {"$$typeof": Symbol(react.forward_ref), "attrs": [], ...}
 
  Difference:
 
  Comparing two different types of values. Expected string but received
  object.
 
  20 |
  21 | it('renders an <img> and a <figcaption> as children', () => {
  >​​ ​​22​​ ​​|​​ ​​expect(wrapper.childAt(0).type()).toBe(​​'img'​​);
  |
  ^

This is a longwinded way of saying that the first child of the CarouselSlide wrapper element is no longer an <img> element. Instead, it’s an instance of the Img component. That component does render an <img> element, but since this is a shallow test of CarouselSlide, the behavior of other components is treated as an unknown.

The problem is we now have two components defined in CarouselSlide.js: CarouselSlide, and the locally defined Img component. We need to expose Img so we can reference the component in tests. For reasons that will become clear in the next section, let’s expose Img by making it a prop. That is, CarouselSlide will take a prop named Img, and the existing Img component will be the default value of that prop. This will give us our final CarouselSlide.js for the chapter:

 import​ React ​from​ ​'react'​;
 import​ PropTypes ​from​ ​'prop-types'​;
 import​ styled ​from​ ​'styled-components'​;
 
 const​ DefaultImg = styled.img​`
  object-fit: cover;
  width: 100%;
  height: ​${props =>
 typeof​ props.imgHeight === ​'number'
  ? ​`​${props.imgHeight}​px`
  : props.imgHeight}​;
 `​;
 
 const​ CarouselSlide = ({
  Img,
  imgUrl,
  imgHeight,
  description,
  attribution,
  ...rest
 }) => (
  <figure ​{​...rest​}​>
  <Img src=​{​imgUrl​}​ imgHeight=​{​imgHeight​}​ />
  <figcaption>
  <strong>​{​description​}​</strong> ​{​attribution​}
  </figcaption>
  </figure>
 );
 
 CarouselSlide.propTypes = {
Img: PropTypes.elementType,
  imgHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  imgUrl: PropTypes.string.isRequired,
  description: PropTypes.node.isRequired,
  attribution: PropTypes.node,
 };
 
 CarouselSlide.defaultProps = {
  Img: DefaultImg,
  imgHeight: 500,
 };
 
 export​ ​default​ CarouselSlide;

PropTypes.elementType, newly added in [email protected], validates that the prop is a valid argument to React.createElement: either the name of a DOM element (such as "div"), or a component.

Exposing the Img component provides a direct solution to the failing type() test:

 // src/tests/CarouselSlide.test.js
 ...
»it(​'renders props.Img and a <figcaption> as children'​, () => {
» expect(wrapper.childAt(0).type()).toBe(CarouselSlide.defaultProps.Img);
» expect(wrapper.childAt(1).type()).toBe(​'figcaption'​);
»});
 ...

With that fix in place, there’s one remaining CarouselSlide test failure:

 $ ​​npx​​ ​​jest
  PASS src/tests/CarouselButton.test.js
  FAIL src/tests/CarouselSlide.test.js
  ● CarouselSlide › passes `imgUrl` through to the <img>
 
  Method “props” is only meant to be run on a single node. 0 found instead.
 
  28 | wrapper.setProps({ imgUrl });
  29 | const img = wrapper.find('img');
  >​​ ​​30​​ ​​|​​ ​​expect(img.prop(​​'src'​​)).toBe(imgUrl);
  | ^

The error message is Enzyme’s way of telling us that wrapper.find(’img’) didn’t match anything. Recall that we’re using shallow rendering, which means that find() only looks at the React tree that CarouselSlide’s render() method returns. Before this chapter, render() returned a tree that contained an <img> element. Now it doesn’t. Instead, it returns a component whose render() method returns an <img> element.

The fix is the same as the type() test—substitute the Img component for ’img’:

 // src/tests/CarouselSlide.test.js
 ...
 it(​'passes `imgUrl` through to props.Img'​, () => {
 const​ imgUrl = ​'https://example.com/image.png'​;
  wrapper.setProps({ imgUrl });
»const​ img = wrapper.find(CarouselSlide.defaultProps.Img);
  expect(img.prop(​'src'​)).toBe(imgUrl);
 });
 ...

Now the CarouselSlide tests are all passing, and the component’s coverage is as good as ever. But the Img component itself has no coverage. Although the component is very simple, it’s always a good idea to use tests to validate assumptions. Right now, there are two important assumptions here:

  1. The imgUrl prop is passed down to an Img instance as the src prop
  2. The Img component renders an <img> element with the given src prop

Because we’re interested in the DOM output of the component, we should use Enzyme’s mount() to test these assumptions instead of shallow():

 // src/tests/CarouselSlide.test.js
 import​ React ​from​ ​'react'​;
»import​ { shallow, mount } ​from​ ​'enzyme'​;
 import​ CarouselSlide ​from​ ​'../CarouselSlide'​;
 ...
»describe(​'Img'​, () => {
»let​ mounted;
»const​ imgUrl = ​'https://example.com/default.jpg'​;
»
» beforeEach(() => {
»const​ Img = CarouselSlide.defaultProps.Img;
» mounted = mount(
» <Img src=​{​imgUrl​}​ imgHeight=​{​500​}​ />
» );
» });
»
» it(​'renders an <img> with the given src'​, () => {
» expect(mounted.containsMatchingElement(<img src=​{​imgUrl​}​ />)).toBe(​true​);
» });
»});

Like shallow(), mount() takes a React tree, renders it, and returns a wrapper that lets you make queries about that tree. Unlike shallow(), mount() fully renders the tree to the DOM. The Enzyme wrapper’s containsMatchingElement() method conveniently answers all of our questions about the component in one go. For more information on mount(), check out the official docs.[61]

A word of caution about mount(): by providing the entire DOM tree rendered by a component, including DOM elements produced by nested components, mount() ignores the principle that components should be tested in isolation (see Mantra: Test One Piece at a Time). In this case, though, the component being tested comes from a third-party library. What goes on under the hood isn’t really our concern; we just want to know that it creates the markup its API promised. Used sparingly, mount() tests like this one can be a healthy supplement to shallow() tests, particularly when using React components from another project.

Now your tests cover everything we need to know about the markup that CarouselSlide renders. The only thing left to test is the styles themselves. Commit your work before moving on:

 :white_check_mark: Update tests for styled-components

Making Assertions About Styles

To make assertions about the styles that styled-components generates, you’ll need the Jest styled-components plugin.[62] Install jest-styled-components with npm:

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

Then you’ll need to bring in the plugin before running your tests:

 import​ Adapter ​from​ ​'enzyme-adapter-react-16'​;
 import​ { configure } ​from​ ​'enzyme'​;
»import​ ​'jest-styled-components'​;
 
 configure({ adapter: ​new​ Adapter() });

Now you have a new assertion at your disposal, toHaveStyleRule(). Add a test to the describe(’Img’) block to make sure that the expected styled.img styles are coming through:

 // src/tests/CarouselSlide.test.js
 ...
 describe(​'Img'​, () => {
  ...
» it(​'has the expected static styles'​, () => {
» expect(mounted).toHaveStyleRule(​'width'​, ​'100%'​);
» expect(mounted).toHaveStyleRule(​'object-fit'​, ​'cover'​);
» });
 });

Those tests should all come through green. Now add another test to confirm that the imgHeight prop works as expected. Since the default value is numeric, try a string value:

 // src/tests/CarouselSlide.test.js
 ...
 describe(​'Img'​, () => {
  ...
» it(​'uses imgHeight as the height style property'​, () => {
» expect(mounted).toHaveStyleRule(​'height'​, ​'500px'​);
» mounted.setProps({ imgHeight: ​'calc(100vh - 100px)'​ });
» expect(mounted).toHaveStyleRule(​'height'​, ​'calc(100vh - 100px)'​);
» });
 });

Once again, all tests should be green. Time for a commit:

 :white_check_mark: Add style tests for the <img>

Now let’s try out one of styled-components’ coolest features: extending styled components. If you pass an existing styled component to styled(), it’ll return a new component with the styles from the original component plus the new styles. The new styles take precedence, so this is a convenient way to override existing styles and avoid the specificity wars that are endemic in large CSS projects.

Give it a try:

 // src/tests/CarouselSlide.test.js
 import​ React ​from​ ​'react'​;
 import​ { shallow } ​from​ ​'enzyme'​;
 import​ CarouselSlide ​from​ ​'../CarouselSlide'​;
»import​ styled ​from​ ​'styled-components'​;
 ...
 describe(​'Img'​, () => {
  ...
» it(​'allows styles to be overridden'​, () => {
»const​ TestImg = styled(CarouselSlide.defaultProps.Img)​`
» width: auto;
» height: auto;
» object-fit: fill;
» `​;
»
» mounted = mount(
» <CarouselSlide
» Img=​{​TestImg​}
» imgUrl=​{​imgUrl​}
» description=​"This prop is required"
» />
» );
»
» expect(mounted.find(TestImg)).toHaveStyleRule(​'width'​, ​'auto'​);
» expect(mounted.find(TestImg)).toHaveStyleRule(​'height'​, ​'auto'​);
» expect(mounted.find(TestImg)).toHaveStyleRule(​'object-fit'​, ​'fill'​);
» });
 });

Now you can see why Img is exposed as a prop. Before, the only way for the CarouselSlide consumer to alter the styles on the <img> element (aside from height) would’ve been to craft a CSS rule that targets it with an img element selector. But with the Img prop, the consumer can take the default component, extend it with new styles, and replace the original. Not only does that make it easy to override the styles, it also gives them a hook for inserting event handlers, DOM attributes, or additional markup. One prop, infinite extensibility.

As it stands, someone using Carousel who wants to modify <img> would have to set Img individually on each slide data object. It’d be convenient to be able to set a single prop on Carousel for modifying Img across the board, similar to the defaultImgHeight prop.

Let’s switch back into TDD mode. Add some tests for both the as-yet-undefined defaultImg prop and the existing defaultImgHeight prop:

 // src/tests/Carousel.test.js
 ...
 describe(​'Carousel'​, () => {
  ...
» it(​'passes defaultImg and defaultImgHeight to the CarouselSlide'​, () => {
»const​ defaultImg = () => ​'test'​;
»const​ defaultImgHeight = 1234;
» wrapper.setProps({ defaultImg, defaultImgHeight });
» expect(wrapper.find(CarouselSlide).prop(​'Img'​)).toBe(defaultImg);
» expect(wrapper.find(CarouselSlide).prop(​'imgHeight'​)).toBe(
» defaultImgHeight
» );
» });
»
» it(​'allows individual slides to override Img and imgHeight'​, () => {
»const​ Img = () => ​'test'​;
»const​ imgHeight = 1234;
» wrapper.setProps({ slides: [{ ...slides[0], Img, imgHeight }] });
» expect(wrapper.find(CarouselSlide).prop(​'Img'​)).toBe(Img);
» expect(wrapper.find(CarouselSlide).prop(​'imgHeight'​)).toBe(imgHeight);
» });
 });

Then move to the implementation:

 // src/Carousel.js
 ...
 export​ ​default​ ​class​ Carousel ​extends​ React.PureComponent {
 static​ propTypes = {
» defaultImg: CarouselSlide.propTypes.Img,
  defaultImgHeight: CarouselSlide.propTypes.imgHeight,
  slides: PropTypes.arrayOf(PropTypes.shape(CarouselSlide.propTypes))
  .isRequired,
  };
 
 static​ defaultProps = {
» defaultImg: CarouselSlide.defaultProps.Img,
  defaultImgHeight: CarouselSlide.defaultProps.imgHeight,
  };
  ...
  render() {
»const​ { defaultImg, defaultImgHeight, slides, ...rest } = ​this​.props;
 return​ (
» <div ​{​...rest​}​>
» <CarouselSlide
» Img=​{​defaultImg​}
» imgHeight=​{​defaultImgHeight​}
»{​...slides​[​this.state.slideIndex​]}
» />
» <CarouselButton data​-​action=​"prev"​ onClick=​{​​this​.handlePrevClick​}​>
» Prev
» </CarouselButton>
» <CarouselButton data​-​action=​"next"​ onClick=​{​​this​.handleNextClick​}​>
» Next
» </CarouselButton>
» </div>
  );
  }
 }

And commit:

 :sparkles: Add prop for extending the <img> component

At this point, let’s pause and reflect on what it means to have adequate test coverage for styles. In this section, we created a toHaveStyleRule() assertion for every style rule. For dynamic rules like height, that’s useful: any time props are converted into something the user can see, it’s worth making sure that conversion works as expected. But for static rules, toHaveStyleRule() is close to being a truism: “Test that x is x.”

Still, it’d be nice to have some kind of sanity check when restyling components. What if you had a tool that automatically generated a diff of the component’s styles before and after, allowing you to review and confirm that your changes reflect your intent? In the next section, you’ll learn how to do just that using a Jest feature called snapshots.

Taking Jest Snapshots

When you think of testing, you probably picture a list of assertions about your code, a sort of checklist of its functionality. Up to this point, all of the tests in this book have fit that description. But sometimes a picture is worth a thousand words. Or in this case, a piece of content generated by your code can be worth a thousand assertions. Seeing a diff of that content can bring your attention to problems you might never have thought to write an assertion for.

For our purposes, the content we’re interested in snapshotting is the DOM generated by CarouselSlide, along with the styles generated by styled-components. To do that, you’ll need to install one more package, enzyme-to-json:

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

enzyme-to-json takes the trees from Enzyme wrappers and converts them to the JSON format used for Jest snapshot testing, a process known as serialization. To tell Jest to use the package, declare it in the Jest config:

 module.exports = {
  setupTestFrameworkScriptFile: ​'./src/tests/jestSetup.js'​,
» snapshotSerializers: [​'enzyme-to-json/serializer'​],
 };

If you are using Wallaby.js, be sure that you restart it to bring in the updated Jest config.

Now add a test with a new assertion (courtesy of the jest-styled-components plugin), toMatchSnapshot():

 // src/tests/CarouselSlide.test.js
 ...
 describe(​'CarouselSlide'​, () => {
  ...
» it(​'renders correctly'​, () => {
» wrapper.setProps({
» description: ​'Description'​,
» attribution: ​'Attribution'​,
» });
» expect(wrapper).toMatchSnapshot();
» });
 });
 ...

Then try running it:

 $ ​​npx​​ ​​jest
  PASS src/tests/CarouselButton.test.js
  PASS src/tests/Carousel.test.js
  PASS src/tests/CarouselSlide.test.js
  › 1 snapshot written.
 
 Snapshot Summary
  › 1 snapshot written from 1 test suite.

Jest created a new directory, src/tests/__snapshots__, with one file:

 // src/tests/__snapshots__/CarouselSlide.test.js.snap
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[​`CarouselSlide renders correctly 1`​] = ​`
 <figure>
  <CarouselSlide__DefaultImg
  imgHeight={500}
  src="https://example.com/default.jpg"
  />
  <figcaption>
  <strong>
  Description
  </strong>
 
  Attribution
  </figcaption>
 </figure>
 `​;

This provides a nice, clear picture of what CarouselSlide (shallowly) renders. Try taking a snapshot of the Img component, too:

 // src/tests/CarouselSlide.test.js
 ...
 describe(​'Img'​, () => {
  ...
» it(​'renders correctly'​, () => {
» expect(mounted.find(​'img'​)).toMatchSnapshot();
» });
 });

Run the test and you’ll see another snapshot in the same file:

 // src/tests/__snapshots__/CarouselSlide.test.js.snap
 ...
 exports[​`Img renders correctly 1`​] = ​`
 .c0 {
  object-fit: cover;
  width: 100%;
  height: 500px;
 }
 
 <img
  className="c0"
  src="https://example.com/default.jpg"
 />
 `​;

Beautiful! Now you can see that the generated markup is just as expected, and that the <img> element receives a className that confers the expected styles. (As is always the case with styled-components, the particular class name—c0 here—is arbitrary.)

These two snapshots are an excellent substitute for several of the existing tests about CarouselSlide’s markup. Removing those rather rigid tests will help make it easier to change the component’s markup in the future: instead of making changes to multiple failing tests, you’ll only have to confirm that the new snapshots reflect your intent.

Pruning the tests that are redundant with the snapshots yields the final CarouselSlide.test.js for this chapter:

 import​ React ​from​ ​'react'​;
 import​ { shallow, mount } ​from​ ​'enzyme'​;
 import​ CarouselSlide ​from​ ​'../CarouselSlide'​;
 
 describe(​'CarouselSlide'​, () => {
 let​ wrapper;
 
  beforeEach(() => {
  wrapper = shallow(
  <CarouselSlide
  imgUrl=​"https://example.com/default.jpg"
  description=​"Default test image"
  />
  );
  });
 
  it(​'renders correctly'​, () => {
  wrapper.setProps({
  description: ​'Description'​,
  attribution: ​'Attribution'​,
  });
  expect(wrapper).toMatchSnapshot();
  });
 
  it(​'passes other props through to the <figure>'​, () => {
 const​ style = {};
 const​ onClick = () => {};
 const​ className = ​'my-carousel-slide'​;
  wrapper.setProps({ style, onClick, className });
  expect(wrapper.prop(​'style'​)).toBe(style);
  expect(wrapper.prop(​'onClick'​)).toBe(onClick);
  expect(wrapper.prop(​'className'​)).toBe(className);
  });
 });
 
 describe(​'Img'​, () => {
 let​ mounted;
 const​ imgUrl = ​'https://example.com/default.jpg'​;
 
  beforeEach(() => {
 const​ Img = CarouselSlide.defaultProps.Img;
  mounted = mount(
  <Img src=​{​imgUrl​}​ imgHeight=​{​500​}​ />
  );
  });
 
  it(​'renders correctly'​, () => {
  expect(mounted.find(​'img'​)).toMatchSnapshot();
  });
 
  it(​'uses imgHeight as the height style property'​, () => {
  expect(mounted).toHaveStyleRule(​'height'​, ​'500px'​);
  mounted.setProps({ imgHeight: ​'calc(100vh - 100px)'​ });
  expect(mounted).toHaveStyleRule(​'height'​, ​'calc(100vh - 100px)'​);
  });
 });

From now on, every time you run your tests, Jest will generate new snapshots to compare to the old ones. If the two are identical, the toMatchSnapshot() assertion passes. But what happens if they’re different? Try changing, say, the object-fit style rule from cover to contain:

 $ ​​npx​​ ​​jest
  PASS src/tests/CarouselButton.test.js
  PASS src/tests/Carousel.test.js
  FAIL src/tests/CarouselSlide.test.js
  ● Img › renders correctly
 
  expect(value).toMatchSnapshot()
 
  Received value does not match stored snapshot "Img renders correctly 1".
 
  - Snapshot
  + Received
 
  @@ -1,8 +1,8 @@
  .c0 {
  width: 100%;
  - object-fit: cover;
  + object-fit: contain;
  }
 
  <img
  className="c0"
  src="https://example.com/default.jpg"
 
  43 |
  44 | it('renders correctly', () => {
  >​​ ​​45​​ ​​|​​ ​​expect(mounted.find(​​'img'​​)).toMatchSnapshot();
  | ^
  46 | });
  47 |
  48 | it('uses imgHeight as the height style property', () => {
 
  at Object.toMatchSnapshot (src/tests/CarouselSlide.test.js:45:33)
 
  › 1 snapshot failed.
 Snapshot Summary
  › 1 snapshot failed from 1 test suite. Inspect your code changes or run
  `npx jest -u` to update them.

When a toMatchSnapshot() assertion fails, Jest treats that as a test failure and shows you a diff of the snapshot. The snapshot on disk remains unchanged. To confirm that your change is intentional, you run the tests again with the -u (short for --updateSnapshot) flag, causing all snapshots to be overwritten.

This snapshot process may feel strange at first. Unit tests are normally automated. Snapshot testing, by contrast, requires human intervention: on its own, the machine can’t determine whether the test should pass or not. That brings human error into the equation. In practice, this downside is mitigated by the fact that snapshots are version controlled. If a pull request contains an unwanted change that’s reflected in a snapshot that the author carelessly updated, everyone reviewing that pull request will see the snapshot diff and have a chance to raise a red flag.

Speaking of version control, it’s time to make your final commit for this chapter:

 :white_check_mark: Add snapshot tests for CarouselSlide

This concludes our tour of styled-components and testing. We only got around to styling one element, the <img>, so there’s lots left to do! As an exercise for this chapter, try adding some finishing touches to the carousel. Play around with different styles for the caption, the buttons, and the overall layout. When you feel satisfied with your work, be sure to add a snapshot test for each element, then breathe a sigh of relief that your styles are safe from regressions.

..................Content has been hidden....................

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