Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Building React apps with test-driven developmen...

Building React apps with test-driven development (TDD)

Learn how to use test-driven development to build React apps, with a minimalist approach.
Presented at LeedsJS - https://www.meetup.com/LeedsJS/

Avatar for Daniel Irvine

Daniel Irvine

January 31, 2018
Tweet

More Decks by Daniel Irvine

Other Decks in Programming

Transcript

  1. ReactTDD.com Daniel Irvine I’m aiming to… • teach you the

    coding techniques I use when building React applications,
 • convince you that strict Test-Driven Development (TDD) is worth using, and
 • show you how programming from first principles is rewarding, educational and promotes high-quality code.
  2. ReactTDD.com Daniel Irvine Ingredient lists • ½ pound green peas

    •1 cabbage lettuce • 1 onion • 1 tablespoon chopped mint • ½ teaspoon salt • puff pastry [{ "name": "green peas", "amount": 0.5, "measure": "pound" }, { "name": "cabbage lettuce", "amount": 1 }, { "name": "onion", "amount": 1 }, { "name": "chopped mint", "amount": 1, "measure": "tablespoon" }, { "name": "salt", "amount": 0.5, "measure": "teaspoon" }, { "name": "puff pastry" }]
  3. The function we want to craft… ingredientLister.js
 
 export function

    formatIngredientRequirement(
 ingredientFinder, measureFinder, ingredient) { // ? }
  4. ReactTDD.com Daniel Irvine ingredientListerSpec.js - making a start import {

    formatIngredientRequirement } from '~/ingredientLister' it('formats a single whole item', () => { const actual = format({name: 'onion', amount: 1}) expect(actual).toEqual('1 onion') }) function format(item) { return formatIngredientRequirement( ingredientFinder, measureFinder, item) }
  5. ReactTDD.com Daniel Irvine Testing with Jasmine it('formats multiple whole items',

    () => { const actual = format({name: 'onion', amount: 2}) expect(actual).toEqual('2 onions') }) function format(item) { return formatIngredientRequirement( ingredientFinder, measureFinder, item) }
  6. ReactTDD.com Daniel Irvine Testing with Jasmine it('formats multiple whole items',

    () => { const ingredientDetail = {singular: 'onion', plural: 'onions'} ingredientFinder = _ => ingredientDetail const actual = format({name: 'onion', amount: 2}) expect(actual).toEqual('2 onions’) }) function format(item) { return formatIngredientRequirement( ingredientFinder, measureFinder, item) }
  7. Make both tests pass export function formatIngredientRequirement(
 ingredientFinder, measureFinder, ingredient)

    {
 if(ingredient.amount === 1) {
 return `1 ${ingredient.name}`
 } else {
 const detail = ingredientFinder(ingredient.name)
 return `${ingredient.amount} ${detail.plural}`
 }
 }
  8. DRYing up tests import { formatIngredientRequirement } from '~/ingredientLister'
 


    describe('formatIngredientRequirement', () => {
 
 let ingredientFinder
 let measureFinder
 
 it('formats a single whole item', () => {
 // ?
 })
 
 it('formats multiple whole items', () => {
 // ?
 })
 
 function format(item) {
 return formatIngredientRequirement(
 ingredientFinder, measureFinder, item)
 }
 })
  9. ReactTDD.com Daniel Irvine Functions {name: 'onion', 
 amount: 2} onion

    {singular: 'onion', plural: 'onions' } 2 onions
  10. ReactTDD.com Daniel Irvine Functions {name: 'onion', 
 amount: 2} onion

    {singular: 'onion', plural: 'onions' } teaspoon {singular: 'teaspoon', plural: ‘teaspoons' } 2 onions
  11. Repeat until you end up with this… export function formatIngredientRequirement(


    ingredientFinder, measureFinder, ingredient) {
 if(ingredient.measure) {
 return formatIngredientWithMeasure(measureFinder, ingredient)
 } else {
 return formatWholeIngredient(ingredientFinder, ingredient)
 }
 }
 
 function formatIngredientWithMeasure(measureFinder, ingredient) {
 const measure = measureFinder(ingredient.measure)
 if(ingredient.amount === 1) {
 return `1 ${measure.singular} ${ingredient.name}`
 } else {
 return `${ingredient.amount} ${measure.plural} ${ingredient.name}`
 }
 }
 
 function formatWholeIngredient(ingredientFinder, ingredient) {
 if(ingredient.amount === 1) {
 return `1 ${ingredient.name}`
 } else {
 const ingredientDetail = ingredientFinder(ingredient.name)
 return `${ingredient.amount} ${ingredientDetail.plural}`
 }
 }
  12. An example React component import React from 'react'
 import ReactDOM

    from 'react'
 import Recipe from './recipe'
 
 export default class RecipeList extends React.Component {
 constructor(props) {
 super(props)
 }
 
 render() {
 return <div id='recipeList'>
 <ul>
 {this.props.recipes.map(this.renderRecipe)}
 </ul>
 </div>
 } renderRecipe(recipe) {
 return <li key={recipe}>{recipe}</li>
 }
 }
  13. ReactTDD.com Daniel Irvine Mounting a React component import React from

    'react' import ReactDOM from 'react-dom' import RecipeList from './recipeList' import { loadAllRecipes } from './recipeRepository' const recipes = loadAllRecipes() ReactDOM.render( <RecipeList recipes={recipes} />, document.getElementById('main'))
  14. ReactTDD.com Daniel Irvine Mounting a specific component in tests let

    container let component beforeEach(() => { container = document.createElement('div') }) function mountComponent() { component = ReactDOM.render( <RecipeList recipeRepository={repository} />, container) }
  15. ReactTDD.com Daniel Irvine Working with the DOM export function resetDom(windowUrl)

    { global.window = new JSDOM('', {url: windowUrl}).window global.document = global.window.document global.navigator = global.window.navigator window.fetch = () => {json: () => ''} } beforeEach(() => { resetDom() })
  16. Testing a React component const recipes = ['recipe 1', 'recipe

    2']
 
 describe('recipeList', () => {
 let component
 const repository = Promise.resolve(recipes) beforeEach(() => {
 resetDom()
 }) it('initially displays an empty unordered list', () => {
 mountComponent()
 expect(ul()).toBeDefined()
 expect(ul().children.length).toEqual(0)
 })
  17. ReactTDD.com Daniel Irvine DRYing up tests with helper methods function

    ul() {
 return ReactTestUtils
 .findRenderedDOMComponentWithTag(component, 'ul')
 }
  18. ReactTDD.com Daniel Irvine Getting rid of asynchronous behaviour it('test description',

    (done) => { mountComponent() setImmediate(() => { expect(...) done() }) })
  19. ReactTDD.com Daniel Irvine Mixing DOM methods with ReactTestUtils it('renders recipes

    as links', (done) => { mountComponent() setImmediate(() => { expect(ul().querySelectorAll('li > a').length).toEqual(2) done() }) })
  20. ReactTDD.com Daniel Irvine Yet more methods… function click(link) { return

    ReactTestUtils.Simulate.click(link) } function firstLink() { return ul().querySelectorAll('li > a')[0] }
  21. Changing the DOM it('clicking link sets the displayed recipe correctly',

    (done) => {
 mountComponent()
 setImmediate(() => {
 click(firstLink())
 setImmediate(() => {
 expect(recipe().props.chosenRecipe).toEqual('recipe 1')
 done()
 })
 })
 }) function recipe() {
 return ReactTestUtils
 .findRenderedComponentWithType(component, Recipe)
 }
  22. Using state render() { return <div id='recipeList'> <ul> {this.state.recipes.map(this.renderRecipe)} </ul>

    <Recipe chosenRecipe={this.state.chosenRecipe} /> </div> } renderRecipe(recipe) { return <li key={recipe}> <a onClick={this.handleChooseRecipe}>{recipe}</a> </li> } handleChooseRecipe(e) { this.setState({ chosenRecipe: e.target.textContent }) }
  23. Another use of state constructor(props) { super(props) this.recipesReceived = this.recipesReceived.bind(this)

    this.props.recipeRepository.then(this.recipesReceived) this.state = { recipes: [] } } recipesReceived(recipes) { this.setState({ recipes: recipes }) }
  24. Look again at that test it('clicking link sets the displayed

    recipe correctly', (done) => {
 mountComponent()
 setImmediate(() => {
 click(firstLink())
 setImmediate(() => {
 expect(recipe().props.chosenRecipe).toEqual('recipe 1')
 done()
 })
 })
 }) function recipe() {
 return ReactTestUtils
 .findRenderedComponentWithType(component, Recipe)
 }
  25. Arrange Act Assert Test description it('formats multiple whole items', ()

    => { Arrange const ingredientDetail = {singular: 'onion', plural: 'onions'} ingredientFinder = _ => ingredientDetail Act const actual = format({name: 'onion', amount: 2}) Assert expect(actual).toEqual('2 onions') })
  26. ReactTDD.com Daniel Irvine Test doubles •Fakes - contains ‘business logic’,

    and needs tests itself •Stubs - always returns the same value •Spies - like a stub but records when it was called Three types:
  27. ReactTDD.com Daniel Irvine Test doubles •Fakes - contains ‘business logic’,

    and needs tests itself •Stubs - always returns the same value •Spies - like a stub but records when it was called Three types:
  28. ReactTDD.com Daniel Irvine Stubs let ingriedientFinder it('formats multiple whole items',

    () => { const ingredientDetail = {singular: 'onion', plural: 'onions'} ingredientFinder = _ => ingredientDetail const actual = format({name: 'onion', amount: 2}) expect(actual).toEqual('2 onions’) }) function format(item) { return formatIngredientRequirement( ingredientFinder, measureFinder, item) }
  29. ReactTDD.com Daniel Irvine Example of a React spy - loading

    recipes Recipe RecipeRepository RecipeList 1 1 recipeLoader
  30. First test import { sampleRecipe } from '../sampleData/recipe'
 
 let

    recipeLoader
 
 beforeEach(() => {
 recipeLoader = jasmine.createSpy()
 .and.returnValue(Promise.resolve(sampleRecipe))
 })
 
 it('loads the recipe using the recipe loader', (done) => {
 mountComponent('Avocado bagel')
 setImmediate(() => {
 expect(recipeLoader).toHaveBeenCalledWith('Avocado bagel')
 done()
 })
 })
  31. Second and third test beforeEach(() => {
 recipeLoader = jasmine.createSpy()


    .and.returnValue(Promise.resolve(sampleRecipe))
 })
 
 it('displays the name of the recipe in a heading', (done) => {
 mountComponent('Avocado bagel')
 setImmediate(() => {
 expect(h2().textContent).toEqual('Avocado bagel')
 done()
 })
 }) it('displays whole ingredients', (done) => {
 mountComponent('Avocado bagel')
 setImmediate(() => {
 expect(textContent()).toContain('1 avocado')
 expect(textContent()).toContain('1 sesame bagel')
 done()
 })
 })
  32. Use the repository doLoad() {
 this.props.recipeLoader(this.props.chosenRecipe)
 .then(recipe => {
 this.setState({


    recipe: recipe
 })
 })
 } render() {
 if(this.state.recipe) {
 return <div id='recipe'>
 <h2>{this.props.chosenRecipe}</h2>
 <ul>{this.state.recipe.ingredient
 .map(this.renderIngredient)}
 </ul>
 </div>
 }
  33. ReactTDD.com Daniel Irvine Default properties import React from 'react' import

    ReactDOM from 'react-dom' import { loadRecipe } from './recipeRepository' export default class Recipe extends React.Component { // ... } Recipe.defaultProps = { recipeLoader: loadRecipe }
  34. ReactTDD.com Daniel Irvine Component hierarchies Recipe RecipeRepository RecipeList 1 1

    recipeLoader ViewRecipe EditRecipe Ingredient This component fetches data
 on load
  35. Top tips for refactoring Make a mess Don’t Repeat Yourself

    - including the tests Get away from React as much as possible Stay functional
  36. Top tips for testing Only write the minimum amount of

    code to pass the test Lots of small connecting methods makes very simple testing Get rid of asynchronous behaviour Don’t access OS resources Spy tests generally comes in pairs - for the call params and for the return value
  37. Thank you Sign up at ReactTDD.com for a free course


    that covers all this and more Daniel Irvine
 Email: [email protected]
 Twitter: @d_ir
 Instagram: @craft_of_code
  38. ReactTDD.com Daniel Irvine Jasmine API Reference Assertions expect(actual).toEqual(expected) expect(actual).toBeDefined()
 expect(actual).not.toEqual(unexpected)

    expect(actual).not.toBeDefined() expect(actual).not.toBeNull() // for querySelector expect(string).toContain(substring) For use with spies: expect(spy).toHaveBeenCalled() expect(spy).toHaveBeenCalledWith(argumentOne, argumentTwo, ...)
  39. ReactTDD.com Daniel Irvine ReactTestUtils API Reference import ReactTestUtils from 'react-dom/test-utils'

    ReactTestUtils.findRenderedDOMComponentWithTag(component, 'ul') ReactTestUtils.findRenderedComponentWithType(component, Recipe) ReactTestUtils.findRenderedDOMComponentWithClass(component, cssClassName) ReactTestUtils.scryRenderedDOMComponentsWithTag(component, 'ul') ReactTestUtils.Simulate.change(selectBox, {target: {value: option}} ) ReactTestUtils.Simulate.click(link) node.querySelector('ul') node.querySelectorAll('li')
  40. ReactTDD.com Daniel Irvine Jasmine API Reference Spies spyOn(object, functionName).and.returnValue(returnValue) e.g.

    spyOn(window, 'fetch') .and.returnValue(Promise.resolve({json: () => value})) const mySpy = jasmine.createSpy()