└── README.md /README.md: -------------------------------------------------------------------------------- 1 | These are my notes from the Egghead.io course ["React Testing Cookbook"](https://egghead.io/series/react-testing-cookbook). 2 | 3 | # 1 - Setting up dependencies 4 | 5 | * `npm install --save-dev mocha expect react-addon-test-utils` 6 | 7 | (Not `chai`, hmm...) 8 | 9 | # 2 - Running tests 10 | 11 | * In `package.json`, add `test` under `scripts`: 12 | 13 | ```sh 14 | mocha './src/**/*.spec.js' --compilers js:babel-core/register 15 | ``` 16 | 17 | * In a spec file, we use `mocha` and `expect`: 18 | 19 | ```js 20 | import expect from 'expect'; 21 | 22 | describe('emtpy', () => { 23 | it('should work', () => { 24 | expect(true).toEqual(true); 25 | }); 26 | }); 27 | ``` 28 | 29 | # 3 - Utility modules 30 | 31 | We have a hypothetical utility function (not React related) called `createId`, which generates unique IDs for a quote-keeper app. 32 | 33 | ```js 34 | describe('createId', () => { 35 | it('should convert a description into a unique id', () => { 36 | const actual = createId(123, 'Cool example'); 37 | const expected = '123-cool-example'; 38 | expect(actual).toEqual(expected); 39 | }); 40 | }); 41 | ``` 42 | 43 | # 4 - Intro to shallow rendering 44 | 45 | ```js 46 | import React from 'react'; 47 | import TestUtils from 'react-addon-test-utils'; 48 | import expect from 'expect'; 49 | 50 | const CoolComponent = ({greeting}) => ( 51 |
52 |

Greeting

53 |
{greeting} 54 |
55 | ); 56 | 57 | describe('CoolComponent', () => { 58 | it('should...', () => { 59 | // shallow rendering means only one component level deep 60 | const renderer = TestUtils.createRenderer(); 61 | 62 | // same as ReactDOM.render() 63 | renderer.render(); 64 | 65 | // object output of shallow render 66 | const output = renderer.getRenderOutput(); 67 | 68 | console.log(output); 69 | }); 70 | }); 71 | ``` 72 | 73 | # 5 - JSX error diffs 74 | 75 | ```js 76 | import React from 'react'; 77 | import TestUtils from 'react-addon-test-utils'; 78 | import expect from 'expect'; 79 | 80 | const CoolComponent = ({greeting}) => ( 81 |
82 |

Greeting

83 |
{greeting} 84 |
85 | ); 86 | 87 | describe('CoolComponent', () => { 88 | it('should render the greeting', () => { 89 | const renderer = TestUtils.createRenderer(); 90 | renderer.render(); 91 | const actual = renderer.getRenderOutput(); 92 | const expected = ( 93 |
94 |

Greeting

95 |
hello world
96 |
97 | ); 98 | expect(actual).toEqual(expected) 99 | }); 100 | }) 101 | ``` 102 | 103 | * What about when the test fails? We get a giant diff dump of internal react structure. We need better diffing of JSX output. 104 | 105 | We can get this with [expect-jsx](https://github.com/algolia/expect-jsx). 106 | 107 | Some things we can do with `expect-jsx`: `toEqualJSX`, `toNotEqualJSX`, `toIncludeJSX` (recursive children search). This last one means you don't have to tightly couple test-selectors to your HTML structure. 108 | 109 | ```sh 110 | npm install --save-dev expect-jsx 111 | ``` 112 | 113 | ```js 114 | import React from 'react'; 115 | import TestUtils from 'react-addon-test-utils'; 116 | import expect from 'expect'; 117 | import expectJSX from 'expect-jsx'; 118 | expect.extend(expectJSX); 119 | 120 | const CoolComponent = ({greeting}) => ( 121 |
122 |

Greeting

123 |
{greeting} 124 |
125 | ); 126 | 127 | describe('CoolComponent', () => { 128 | it('should render the greeting', () => { 129 | const renderer = TestUtils.createRenderer(); 130 | renderer.render(); 131 | const actual = renderer.getRenderOutput(); 132 | const expected =
hello world
; 133 | 134 | expect(actual).toIncludeJSX(expected); 135 | }); 136 | }) 137 | ``` 138 | 139 | # 6 - Element types with Shallow Rendering 140 | 141 | This is about using the `type` attribute of `getRenderOutput()`. We can assert that the rendered output is a certain type of tag. 142 | 143 | ```js 144 | import React from 'react'; 145 | import TestUtils from 'react-addon-test-utils'; 146 | import expect from 'expect'; 147 | import LikeCounter from './LikeCounter'; 148 | 149 | describe('LikeCounter', () => { 150 | it('should be a link', () => { 151 | const renderer = TestUtils.createRenderer(); 152 | renderer.render(); 153 | 154 | const actual = renderer.getRenderOutput().type; 155 | const expected = 'a'; 156 | expect(actual).toEqual(expected); 157 | 158 | }); 159 | }); 160 | 161 | ``` 162 | 163 | # 7 - className with shallow rendering 164 | 165 | We want to write tests to ensure our icons are rendering correctly. 166 | 167 | ```js 168 | import React from 'react'; 169 | import TestUtils from 'react-addon-test-utils'; 170 | import expect from 'expect'; 171 | import Icon from './Icon'; 172 | 173 | describe('Icon', () => { 174 | it('should render the icon', () => { 175 | const renderer = TestUtils.createRenderer(); 176 | renderer.render(); 177 | 178 | // `includes` is an ES6 String.prototype function 179 | const actual = renderer.getRenderOutput().props.className.includes('facebook'); 180 | const expected = true; 181 | 182 | expect(actual).toEqual(expected); 183 | 184 | }); 185 | }); 186 | ``` 187 | 188 | # 8 - Conditional className with shallow rendering 189 | 190 | Same idea as #7, but with a conditional on the component (`isActive=[bool]`). Test both true and false. 191 | 192 | ```js 193 | import React from 'react'; 194 | import TestUtils from 'react-addon-test-utils'; 195 | import expect from 'expect'; 196 | import LikeCounter from './LikeCounter'; 197 | 198 | describe('LikeCounter', () => { 199 | it('should show the like count as active', () => { 200 | const renderer = TestUtils.createRenderer(); 201 | renderer.render(); 202 | 203 | const actual = renderer.getRenderOutput().props.className.includes('LikeCounter--active'); 204 | const expected = true; 205 | expect(actual).toEqual(expected); 206 | }); 207 | 208 | it('should show the like count as inactive', () => { 209 | const renderer = TestUtils.createRenderer(); 210 | renderer.render(); 211 | 212 | const actual = renderer.getRenderOutput().props.className.includes('LikeCounter--active'); 213 | const expected = false; 214 | expect(actual).toEqual(expected); 215 | }); 216 | }); 217 | 218 | ``` 219 | 220 | # 9 - Reusing test boilerplate 221 | 222 | Let's refactor #8 using some shared logic in the describe block. He calls it a factory function. OK. 223 | 224 | ```js 225 | import React from 'react'; 226 | import TestUtils from 'react-addon-test-utils'; 227 | import expect from 'expect'; 228 | import LikeCounter from './LikeCounter'; 229 | 230 | describe('LikeCounter', () => { 231 | function renderLikeCounter(isActive) { 232 | const renderer = TestUtils.createRenderer(); 233 | renderer.render(); 234 | 235 | return renderer 236 | .getRenderOutput() 237 | .props 238 | .className 239 | .includes('LikeCounter--active'); 240 | } 241 | 242 | describe('isActive', () => { 243 | it('should show the like count as active', () => { 244 | expect(renderLikeCounter(true)).toEqual(true); 245 | }); 246 | 247 | it('should show the like count as inactive', () => { 248 | expect(renderLikeCounter(false)).toEqual(false); 249 | }); 250 | }); 251 | 252 | }); 253 | 254 | ``` 255 | 256 | # 10 - Children with shallow rendering 257 | 258 | ```js 259 | import React from 'react'; 260 | import TestUtils from 'react-addon-test-utils'; 261 | import expect from 'expect'; 262 | import expectJSX from 'expect-jsx'; 263 | expect.extend(expectJSX); 264 | import LikeCounter from './LikeCounter'; 265 | 266 | describe('LikeCounter', () => { 267 | it('should render like counts', () => { 268 | const renderer = TestUtils.createRenderer(); 269 | renderer.render(); 270 | 271 | // const children = renderer.getRenderOutput().props.children; 272 | // We could just keep chaining .props.children ... on and on 273 | // But this is ugly. How else to do it? (A: toIncludeJSX) 274 | 275 | const expected = '5 likes'; 276 | const actual = renderer.getRenderOutput(); 277 | expect(actual).toIncludeJSX(expected); 278 | }); 279 | }); 280 | 281 | ``` 282 | 283 | # 11 - The Redux Store - Multiple Actions 284 | 285 | In this sort-of-integration-test we dispatch multiple actions, and only make one assertion at the end to verify the final state. 286 | 287 | ```js 288 | import { store } from './store'; 289 | import expect from 'expect'; 290 | 291 | describe('store', () => { 292 | it('should work with a series of actions', () => { 293 | const actions = [ 294 | { 295 | type: 'ADD_QUOTE_BY_ID', 296 | payload: { 297 | text: 'The best way to cheer yourself up is to try to cheer someone else up.', 298 | author: 'Mark Twain', 299 | id: 1, 300 | likeCount: 24 301 | } 302 | }, 303 | { 304 | type: 'ADD_QUOTE_BY_ID', 305 | payload: { 306 | text: 'Whatever you are, be a good one.', 307 | author: 'Abraham Lincoln', 308 | id: 2, 309 | likeCount: 0 310 | } 311 | }, 312 | { 313 | type: 'REMOVE_QUOTE_BY_ID', 314 | payload: { id: 1 } 315 | }, 316 | { 317 | type: 'LIKE_QUOTE_BY_ID', 318 | payload: { id: 2 } 319 | }, 320 | { 321 | type: 'LIKE_QUOTE_BY_ID', 322 | payload: { id: 2 } 323 | }, 324 | { 325 | type: 'UNLIKE_QUOTE_BY_ID', 326 | payload: { id: 2 } 327 | }, 328 | { 329 | type: 'UPDATE_THEME_COLOR', 330 | payload: { color: '#777777' } 331 | } 332 | ]; 333 | 334 | actions.forEach(action => store.dispatch(action)); 335 | 336 | const actual = store.getState(); 337 | const expected = { 338 | quotes: [ 339 | { 340 | text: 'Whatever you are, be a good one.', 341 | author: 'Abraham Lincoln', 342 | id: 2, 343 | likeCount: 1 344 | } 345 | ], 346 | theme: { 347 | color: '#777777' 348 | } 349 | }; 350 | expect(actual).toEqual(expected); 351 | }); 352 | }); 353 | 354 | ``` 355 | 356 | # 12 - The Redux Store - Initial state 357 | 358 | In Redux, reducers must provide an initial/default state. 359 | 360 | ```js 361 | import { store } from './store'; 362 | import expect from 'expect'; 363 | 364 | describe('store', () => { 365 | it('should initialize', () => { 366 | const actual = store.getState(); 367 | const expected = { 368 | quotes: [], 369 | theme: { color: '#ffffff' } 370 | }; 371 | expect(actual).toEqual(expected); 372 | }); 373 | }); 374 | ``` 375 | 376 | # 13 - Redux Testing - Redux reducers 377 | 378 | ```js 379 | import expect from 'expect'; 380 | import themeReducer from './themeReducer'; 381 | 382 | describe('themeReducer', () => { 383 | function stateBefore() { 384 | return { 385 | color: '#ffffff' 386 | }; 387 | } 388 | 389 | const action = { 390 | type: 'UPDATE_THEME_COLOR', 391 | payload: { color: '#56ddff' } 392 | } 393 | 394 | it('should change the theme color', () => { 395 | const actual = themeReducer(stateBefore(), action) 396 | 397 | const actual = store.getState(); 398 | const expected = { color: '#56ddff' } 399 | expect(actual).toEqual(expected); 400 | }); 401 | }); 402 | 403 | ``` 404 | 405 | The use of `stateBefore` as a function is overkill here since it's just a single action. Let's try another reducer to go a bit further: 406 | 407 | 408 | ```js 409 | import expect from 'expect'; 410 | import quoteReducer from './quoteReducer'; 411 | 412 | describe('quoteReducer', () => { 413 | function stateBefore() { 414 | return [ 415 | { 416 | text: 'Lorem ipsum', 417 | author: 'Jane Doe', 418 | id: 1, 419 | likeCount: 7 420 | }, 421 | { 422 | text: 'Ullamco laboris nisi ut aliquip', 423 | author: 'John Smith', 424 | id: 2, 425 | likeCount: 0 426 | }, 427 | ] 428 | }; 429 | 430 | 431 | 432 | it('should add quotes by id', () => { 433 | const action = { 434 | type: 'ADD_QUOTE_BY_ID', 435 | payload: { 436 | text: 'This is a new quote', 437 | author: 'Someone awesome', 438 | id: 3, 439 | likeCount: 0 440 | } 441 | } 442 | 443 | const actual = quoteReducer(stateBefore(), action) 444 | const expected = [ 445 | { 446 | text: 'Lorem ipsum', 447 | author: 'Jane Doe', 448 | id: 1, 449 | likeCount: 7 450 | }, 451 | { 452 | text: 'Ullamco laboris nisi ut aliquip', 453 | author: 'John Smith', 454 | id: 2, 455 | likeCount: 0 456 | }, 457 | { 458 | text: 'This is a new quote', 459 | author: 'Someone awesome', 460 | id: 3, 461 | likeCount: 0 462 | } 463 | ]; 464 | expect(actual).toEqual(expected); 465 | }); 466 | 467 | it('should return prev state when trying to make likeCount negative', () => { 468 | const action = { 469 | type: 'UNLIKE_QUOTE_BY_ID', 470 | payload: { id: 2 } 471 | }; 472 | const actual = quoteReducer(stateBefore(), action); 473 | const expected = stateBefore(); 474 | expect(actual).toEqual(expected); 475 | }); 476 | }); 477 | 478 | ``` 479 | --------------------------------------------------------------------------------