-
Notifications
You must be signed in to change notification settings - Fork 0
upworthy/testing-presentation
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
import React from 'react'; /** * Testing! * * Let's imagine the following feature: * * <button>Like this post on Fakebook</button> * * When we click that button, we should: * 1. Send a LIKE request to Fakebook. * 2. If the request succeeds, we'll update application state to track * this. Perhaps we'll use this state to display a thank-you * message later. * * We'll do this in React and Redux, and learn about _how_ to * test as well as _what_ to test along the way. * * So let's get started! To begin with, we'll set up our Redux * store to track whether or not the user has liked this page yet. * * Because reducers are simple functions, we can test them in * isolation. We don't need to involve Redux at this stage. */ const LIKED_PAGE = "LIKED_PAGE"; const INIT_STATE = { liked: false }; const reducer = (state = INIT_STATE, action) => { switch (action.type) { case LIKED_PAGE: return { liked: true }; default: return state; } }; /** * Easy peasy. So how do we test this? We'll use Mocha to set up * our tests. Mocha provides the `describe` and `it` methods we * use to structure our tests. * * `describe` and `it` are injected by mocha, so we don't need * to import them. * * https://mochajs.org/ */ /* global describe, it */ describe('A test', () => { /** * `describe` takes a label for your test block. It can be * whatever you want, but usually the name of your module * or component is a good choice. */ describe('can be nested', () => { /** * You can nest `describe` blocks if it helps organize your tests. */ it('will have some assertations', () => { /** * `it` also takes a label. It might be a description of the * expected behavior under test. These are often written in * BDD style: `it("should ...")`. */ }); }); }); /** * That test didn't make any assertations. We haven't talked about them yet. * Mocha doesn't ship with an assertation library, so you have to supply your * own. We use Chai, because it's similar to RSpec's BDD-style assertations. * * http://chaijs.com */ import { expect } from 'chai'; /** * Now we can test our reducer using `describe`, `it`, and `expect`. */ describe('reducer', () => { /** * Usually Redux fires off the init action for us. * For this test we'll have to pass something manually. * It doesn't really matter what we pass, we just want * to simulate instantiating a reducer with no existing state. */ it('should initialize with liked = false', () => { const state = reducer(undefined, { type: "ANYTHING" }); expect(state).to.eql({ liked: false }); }); /** * A single `describe` block often contains multiple assertations. Here we * test that our LIKE_PAGE action works as expected. Now we've covered all * the branches of our reducer. */ it('should set liked = true', () => { const oldState = { liked: false }; const newState = reducer(oldState, { type: LIKED_PAGE }); expect(newState).to.eql({ liked: true }); }); }); /** * That's a little more readable. From here on out we'll use Chai. * Let's think about our component now. We haven't talked about * what the Fakebook API looks like, so how about we start with what * we do know: clicking the button should update our app state. * * But how do we test the behavior of React components? * Meet Enzyme. It's a project from AirBnB. * * http://airbnb.io/enzyme/ */ import { shallow, mount } from 'enzyme'; /** * You'll probably use Enzyme's `shallow` method most often. It's * sort of like `React.createComponent`, in that it instantiates a * component and its backing vdom. But just like `React.createComponent` * needs a corresponding `ReactDOM.render()` to put the newly-created * component on the page, `shallow` doesn't actually mount the component * anywhere. * * If you need to actually mount the component (for instance, to verify * behavior related to `componentDidMount`) you can use `mount` instead. * * There's also a Chai plugin that adds Enzyme-specific matchers, so * let's go ahead and add that now. */ import chai from 'chai'; import chaiEnzyme from 'chai-enzyme'; chai.use(chaiEnzyme()); /** * Now we can use `shallow` and chai-enzyme's `html` matcher to * test this component. */ const FBButton = () => <button>Like this post on Fakebook!</button>; describe('<FBButton />', () => { it('renders a button', () => { const wrapper = shallow(<FBButton />); const html = "<button>Like this post on Fakebook!</button>"; expect(wrapper).to.have.html(html); }); }); /** * Hmm. that's not a very useful test. For starters we're just testing that * React is doing its job, which is redundant. Second, if we ever updated * the wording on that button, our test would break. * * A more useful test would verify the behavior of the button. We know what * should happen when it's clicked, and we'd like to know if we accidentally * break that behavior later. * * Let's start with something easy. Since we're using react-redux, it's safe to * assume we'll be using `connect()` to inject our click handler into this * component's props as a callback. */ const FBButton3 = ({ onClick }) => <button onClick={onClick}>Like this post on Fakebook!</button>; /** * Using `connect` we'd map a callback into that component's props: * * const likePage = () => ({ type: LIKED_PAGE }); * * export default connect({ likePage })(FBButton); * * But in order to test our _connected_ component, we'd have to import * react-redux into our test. It would be preferable isolate our component as * much as possible while testing. Think about it this way: The only thing our * basic, unconnected component knows is that it should accept an `onClick` * prop and call it in response to a click event. If a "dumb" component * doesn't care what its callbacks do or where they come from, then its tests * don't need to care either. * * This separation makes it very easy to test dumb components. We can pass * arbitrary functions as our callbacks. They don't need to be connected to * app state at all. */ describe("<FBButton3 />", () => { it('calls this.props.onClick', () => { let clicked = false; const onClick = () => clicked = true; const wrapper = shallow(<FBButton3 onClick={onClick} />); wrapper.find('button').simulate('click'); expect(clicked).to.be.true; }); }); /** * We've already tested our reducer separately. So when we replace that * generic callback with an action creator in our live code, we can be * confident that everything will work as expected. * * But sometimes we need a smarter stand-in for those callbacks. Sometimes * we'll want to test that our callbacks are being passed specific parameters. * Sometimes our components will expect an event handler to return a specific * value, and we'll need to simulate that. * * Enter test doubles. A test double is like a stunt double for a function. * It can replace an existing function and records any calls to it, so that * you can validate that your code was called when expected and with the * right arguments. * * Sinon is our test double library: http://sinonjs.org/ * * And yes, there's a Chai plugin for Sinon. So let's import that as well. */ import { stub } from 'sinon'; import sinonChai from 'sinon-chai'; chai.use(sinonChai); describe("<FBButton3 /> with a test double", () => { it('calls this.props.onClick', () => { /** * stub() creates a test double. With no args, it will * just return a double that records calls and arguments. */ const onClick = stub(); const wrapper = shallow(<FBButton3 onClick={onClick} />); wrapper.find('button').simulate('click'); /** * We can then ask our double if it was ever called! */ expect(onClick).to.have.been.called; }); }); /** * There's a lot more we can do with test doubles, as we'll see. * But let's take a step back and imagine our Fakebook API works * as follows: * * 1. We import the Fakebook module as `FB`. * 2. We can call `FB.like()` to send a `like` event to the remote * Fakebook API. * 3. `FB.like()` returns true or false depending on whether that request * succeeded. * * There are a number of complications here. First, `FB.like` hits an external * endpoint. Our tests definitely shouldn't be making AJAX calls to 3rd-party * servers. * * Second, our application depends on the response from `FB.like()`. We don't * want to update state incorrectly if the request fails! But if we also * can't make remote calls, how do we replicate the response behavior that the * rest of our app still depends on? * * Test doubles to the rescue. * * In addition to a bare `stub()`, we can stub an existing method. In this * case, we want to stub out `FB.like()` so that our tests don't make a real * AJAX request. */ import FB from './fakebook'; /** * With `FB.like()` stubbed out, it's safe to simulate a click in this next * test. No AJAX call will be performed. * */ describe('<FBButton3 /> with a stubbed dependency', () => { it("calls our test double instead of the original FB.like()", () => { /** * `stub()` can also take an object, and the name of the method to replace. */ stub(FB, 'like'); const wrapper = shallow(<FBButton3 onClick={FB.like} />); wrapper.find('button').simulate('click'); expect(FB.like).to.have.been.called; /** * Important! when you're stubbing an existing method, ALWAYS restore it * as soon as you're done with the stub. */ FB.like.restore(); }); }); /** * We're almost there! Now we just need to wrap our `likePage` action in a * conditional. Remember, we want to make sure `FB.like()` succeeds first * before we update our app state. * * Again, because we're using react-redux, we can assume that our action * creator will be passed in as a callback: * * connect({ likePage })(FBButton); * * And just like last time, this means we can pass an arbitrary function to * the basic component. There's no need to complicate this test with app * state or Redux. */ const FBButton4 = ({ likePage }) => { const onClick = () => { if (FB.like()) { likePage(); } }; return <button onClick={onClick}>Like this on Fakebook!</button>; }; describe('<FBButton4 />', () => { it('will call our action because FB.like returns `true`', () => { /** * Here we'll use both a bare stub _and_ stub an existing method. The * first stub lets us assert on our expected behavior: namely, that the * `likePage` action creator is getting called. */ const likePage = stub(); const wrapper = shallow(<FBButton4 likePage={likePage} />); /** * The second test double stubs out unwanted side effects: FB.like() * shouldn't actually hit the network while we're testing. But we need * it to pretend it did in order for our component to work correctly. */ stub(FB, 'like').returns(true); wrapper.find('button').simulate('click'); expect(likePage).to.have.been.called; FB.like.restore(); // Remember what I said about restoring doubles! }); it("won't update state if `FB.like()` returns false", () => { /** * This time we'll stub `FB.like()` to return false and assert that * our action creator was _not_ called. */ const likePage = stub(); const wrapper = shallow(<FBButton4 likePage={likePage} />); stub(FB, 'like').returns(false); wrapper.find('button').simulate('click'); expect(likePage).not.to.have.been.called; FB.like.restore(); // Seriously! Forgetting this will mess you up. }); }); /** * One thing to note is that at no point during our tests did we import Redux * or React-Redux. However, we've fully tested our reducer and our components. * That's great! It means our application is well decoupled. Our components * don't need to know about app state at all. * * The best piece of testing advice I can give you is this: if you're having * trouble writing a test, think about changing your code to make it easier to * test instead of making your test more complicated. Decoupling our * components from app state and passing in generic callbacks definitely makes * testing easier, because our components end up with far fewer dependencies. * * We've barely scratched the surface with test doubles. Doubles are pretty * powerful and you can assert on all sorts of things: which arguments were * passed; how many times a double was called; you can even create a double * that calls through the original method instead of replacing it outright. * * Likewise, Chai and its plugins make it easy to write concise tests and make * all kinds of assertations: whether a string matches a regexp, if an array * contains a specific value, or if a React component contains certain child * nodes to name just a few examples. You'll have to read the docs to find * out more :) * * One last thing: if you run `npm start` you can visit http://localhost:8080 * to see all the tests in this file actually pass in the browser. Try out * some other doubles and matchers! * * Happy testing! 🗿 🍹 */
About
Testing Lunch & Learn
Resources
Stars
Watchers
Forks
Releases
No releases published
Packages 0
No packages published