$30 off During Our Annual Pro Sale. View Details »

Testing React Hooks with Confidence

Testing React Hooks with Confidence

Radoslav Stankov

January 16, 2021
Tweet

More Decks by Radoslav Stankov

Other Decks in Technology

Transcript

  1. Testing React Hooks with Confidence Radoslav Stankov 29/01/2020

  2. Radoslav Stankov @rstankov blog.rstankov.com
 twitter.com/rstankov
 github.com/rstankov
 speakerdeck.com/rstankov

  3. None
  4. None
  5. https://rstankov.com/appearances

  6. None
  7. Automated Testing Test Driven Development

  8. None
  9. None
  10. import * as React from 'react'; import Memory from './Memory';

    import Grid from './Grid'; import Button from './Button'; const OPERATIONS = { '+': (a, b) => a + b, '-': (a, b) => a - b, '*': (a, b) => a * b, }; export default function Calculator() { const [memory, setMemory] = React.useState('0'); const actions = React.useMemo( () => ({ add(input) { if (!input.match(/\d/)) { if (!memory.match(/\d$/)) { return; } if (!OPERATIONS[input]) { return; } setMemory(`${memory} ${input} `); return;
  11. const actions = React.useMemo( () => ({ add(input) { if

    (!input.match(/\d/)) { if (!memory.match(/\d$/)) { return; } if (!OPERATIONS[input]) { return; } setMemory(`${memory} ${input} `); return; } if (memory.match(/(^| )0$/)) { setMemory(`${memory.slice(0, -1)}${input}`); return; } setMemory(`${memory}${input}`); }, remove() { const newMemory = memory.match(/\d$/) ? memory.slice(0, -1) : memory.slice(0, -3); setMemory(newMemory || '0');
  12. if (memory.match(/(^| )0$/)) { setMemory(`${memory.slice(0, -1)}${input}`); return; } setMemory(`${memory}${input}`); },

    remove() { const newMemory = memory.match(/\d$/) ? memory.slice(0, -1) : memory.slice(0, -3); setMemory(newMemory || '0'); }, reset() { setMemory('0'); }, evaluate() { const chunks = memory.split(' '); let value = parseInt(chunks[0], 10); for (let i = 1; i < chunks.length; i += 2) { const other = chunks[i + 1]; if (typeof other === 'undefined' || other.length === 0) { continue; } const operation = OPERATIONS[chunks[i]];
  13. reset() { setMemory('0'); }, evaluate() { const chunks = memory.split('

    '); let value = parseInt(chunks[0], 10); for (let i = 1; i < chunks.length; i += 2) { const other = chunks[i + 1]; if (typeof other === 'undefined' || other.length === 0) { continue; } const operation = OPERATIONS[chunks[i]]; if (!operation) { throw new Error(`Invalid "${chunks[i]}" operand`); } value = operation(value, parseInt(other, 10)); } setMemory(value.toString()); }, }), [memory, setMemory], ); return (
  14. ); return ( <Grid size="4"> <Grid.Column size="3"> <Memory>{memory.trim()}</Memory> <Button onClick={()

    => actions.add('1')}>1</Button> <Button onClick={() => actions.add('2')}>2</Button> <Button onClick={() => actions.add('3')}>3</Button> <Button onClick={() => actions.add('4')}>4</Button> <Button onClick={() => actions.add('5')}>5</Button> <Button onClick={() => actions.add('6')}>6</Button> <Button onClick={() => actions.add('7')}>7</Button> <Button onClick={() => actions.add('8')}>8</Button> <Button onClick={() => actions.add('9')}>9</Button> <Button onClick={actions.reset}>C</Button> <Button onClick={() => actions.add('0')}>0</Button> <Button onClick={actions.remove}>←</Button> </Grid.Column> <Grid.Column size="1"> <Button onClick={actions.evaluate}>=</Button> <Button onClick={() => actions.add('+')}>+</Button> <Button onClick={() => actions.add('-')}>-</Button> <Button onClick={() => actions.add('*')}>*</Button> </Grid.Column> </Grid> ); }
  15. None
  16. ! Smoke Test

  17. return ( <Grid size="4"> <Grid.Column size="3" data-testid="buttons"> <Memory data-testid="result">{memory.trim()}</Memory> <Button

    data-testid="1" onClick={() => actions.add('1')}>1</Button> <Button data-testid="2" onClick={() => actions.add('2')}>2</Button> <Button data-testid="3" onClick={() => actions.add('3')}>3</Button> <Button data-testid="4" onClick={() => actions.add('4')}>4</Button> <Button data-testid="5" onClick={() => actions.add('5')}>5</Button> <Button data-testid="6" onClick={() => actions.add('6')}>6</Button> <Button data-testid="7" onClick={() => actions.add('7')}>7</Button> <Button data-testid="8" onClick={() => actions.add('8')}>8</Button> <Button data-testid="9" onClick={() => actions.add('9')}>9</Button> <Button data-testid="reset" onClick={actions.reset}>C</Button> <Button data-testid="0" onClick={() => actions.add('0')}>0</Button> <Button data-testid="remove" onClick={actions.remove}>← </Button> </Grid.Column> <Grid.Column size="1" data-testid="operations"> <Button data-testid="evaluate" onClick={actions.evaluate}>=</Button> <Button data-testid="+" onClick={() => actions.add('+')}>+</Button> <Button data-testid="-" onClick={() => actions.add('-')}>-</Button> <Button data-testid="*" onClick={() => actions.add('*')}>*</Button> </Grid.Column> </Grid> ); }
  18. return ( <Grid size="4"> <Grid.Column size="3" data-testid="buttons"> <Memory data-testid="result">{memory.trim()}</Memory> <Button

    data-testid="1" onClick={() => actions.add('1')}>1</Button> <Button data-testid="2" onClick={() => actions.add('2')}>2</Button> <Button data-testid="3" onClick={() => actions.add('3')}>3</Button> <Button data-testid="4" onClick={() => actions.add('4')}>4</Button> <Button data-testid="5" onClick={() => actions.add('5')}>5</Button> <Button data-testid="6" onClick={() => actions.add('6')}>6</Button> <Button data-testid="7" onClick={() => actions.add('7')}>7</Button> <Button data-testid="8" onClick={() => actions.add('8')}>8</Button> <Button data-testid="9" onClick={() => actions.add('9')}>9</Button> <Button data-testid="reset" onClick={actions.reset}>C</Button> <Button data-testid="0" onClick={() => actions.add('0')}>0</Button> <Button data-testid="remove" onClick={actions.remove}>← </Button> </Grid.Column> <Grid.Column size="1" data-testid="operations"> <Button data-testid="evaluate" onClick={actions.evaluate}>=</Button> <Button data-testid="+" onClick={() => actions.add('+')}>+</Button> <Button data-testid="-" onClick={() => actions.add('-')}>-</Button> <Button data-testid="*" onClick={() => actions.add('*')}>*</Button> </Grid.Column> </Grid> ); }
  19. yarn add --dev @testing-library/react

  20. import { render, fireEvent, screen } from '@testing-library/react'; const click

    = (dt) => fireEvent.click(screen.getByTestId(dt)); const expectComponent = (dt) => expect(screen.getByTestId(dt)); describe(Component.name, () => { it('works', () => { render(<Component />); expectComponent('result').toHaveTextContent('0'); click('1'); click('+'); click('1'); expectComponent('result').toHaveTextContent('1 + 1'); click('remove'); expectComponent('result').toHaveTextContent('1 +'); click('2'); expectComponent('result').toHaveTextContent('1 + 2'); click('evaluate'); expectComponent('result').toHaveTextContent('3'); click('reset'); expectComponent('result').toHaveTextContent('0'); }); });
  21. import { render, fireEvent, screen } from '@testing-library/react'; const click

    = (dt) => fireEvent.click(screen.getByTestId(dt)); const expectComponent = (dt) => expect(screen.getByTestId(dt)); describe(Component.name, () => { it('works', () => { render(<Component />); expectComponent('result').toHaveTextContent('0'); click('1'); click('+'); click('1'); expectComponent('result').toHaveTextContent('1 + 1'); click('remove'); expectComponent('result').toHaveTextContent('1 +'); click('2'); expectComponent('result').toHaveTextContent('1 + 2'); click('evaluate'); expectComponent('result').toHaveTextContent('3'); click('reset'); expectComponent('result').toHaveTextContent('0'); }); });
  22. import { render, fireEvent, screen } from '@testing-library/react'; const click

    = (dt) => fireEvent.click(screen.getByTestId(dt)); const expectComponent = (dt) => expect(screen.getByTestId(dt)); describe(Component.name, () => { it('works', () => { render(<Component />); expectComponent('result').toHaveTextContent('0'); click('1'); click('+'); click('1'); expectComponent('result').toHaveTextContent('1 + 1'); click('remove'); expectComponent('result').toHaveTextContent('1 +'); click('2'); expectComponent('result').toHaveTextContent('1 + 2'); click('evaluate'); expectComponent('result').toHaveTextContent('3'); click('reset'); expectComponent('result').toHaveTextContent('0'); }); });
  23. import { render, fireEvent, screen } from '@testing-library/react'; const click

    = (dt) => fireEvent.click(screen.getByTestId(dt)); const expectComponent = (dt) => expect(screen.getByTestId(dt)); describe(Component.name, () => { it('works', () => { render(<Component />); expectComponent('result').toHaveTextContent('0'); click('1'); click('+'); click('1'); expectComponent('result').toHaveTextContent('1 + 1'); click('remove'); expectComponent('result').toHaveTextContent('1 +'); click('2'); expectComponent('result').toHaveTextContent('1 + 2'); click('evaluate'); expectComponent('result').toHaveTextContent('3'); click('reset'); expectComponent('result').toHaveTextContent('0'); }); });
  24. import { render, fireEvent, screen } from '@testing-library/react'; const click

    = (dt) => fireEvent.click(screen.getByTestId(dt)); const expectComponent = (dt) => expect(screen.getByTestId(dt)); describe(Component.name, () => { it('works', () => { render(<Component />); expectComponent('result').toHaveTextContent('0'); click('1'); click('+'); click('1'); expectComponent('result').toHaveTextContent('1 + 1'); click('remove'); expectComponent('result').toHaveTextContent('1 +'); click('2'); expectComponent('result').toHaveTextContent('1 + 2'); click('evaluate'); expectComponent('result').toHaveTextContent('3'); click('reset'); expectComponent('result').toHaveTextContent('0'); }); });
  25. " This is placeholder " Not a "real" unit test.

    It doesn't follow the 4 phases of good test: setup ➡ action ➡ assert ➡ teardown
  26. import * as React from 'react'; import Memory from './Memory';

    import Grid from './Grid'; import Button from './Button'; const OPERATIONS = { '+': (a, b) => a + b, '-': (a, b) => a - b, '*': (a, b) => a * b, }; export default function Calculator() { const [memory, setMemory] = React.useState('0'); const actions = React.useMemo( () => ({ add(input) { if (!input.match(/\d/)) { if (!memory.match(/\d$/)) { return; } if (!OPERATIONS[input]) { return; } setMemory(`${memory} ${input} `); return; $
  27. % Extract Custom Hook

  28. import * as React from 'react'; import Memory from './Memory';

    import Grid from './Grid'; import Button from './Button'; import useCalculatorState from './useCalculatorState'; export default function Calculator() { const [memory, actions] = useCalculatorState(); return ( <Grid size="4"> <Grid.Column size="3" data-testid="buttons"> <Memory data-testid="result">{memory.trim()}</Memory> <Button data-testid="1" onClick={() => actions.add('1')}>1</Button> <Button data-testid="2" onClick={() => actions.add('2')}>2</Button> <Button data-testid="3" onClick={() => actions.add('3')}>3</Button> <Button data-testid="4" onClick={() => actions.add('4')}>4</Button> <Button data-testid="5" onClick={() => actions.add('5')}>5</Button> <Button data-testid="6" onClick={() => actions.add('6')}>6</Button> <Button data-testid="7" onClick={() => actions.add('7')}>7</Button> <Button data-testid="8" onClick={() => actions.add('8')}>8</Button> <Button data-testid="9" onClick={() => actions.add('9')}>9</Button> <Button data-testid="reset" onClick={actions.reset}>C</Button> <Button data-testid="0" onClick={() => actions.add('0')}>0</Button> <Button data-testid="remove" onClick={actions.remove}>← </Button> </Grid.Column> <Grid.Column size="1" data-testid="operations">
  29. import * as React from 'react'; import Memory from './Memory';

    import Grid from './Grid'; import Button from './Button'; import useCalculatorState from './useCalculatorState'; export default function Calculator() { const [memory, actions] = useCalculatorState(); return ( <Grid size="4"> <Grid.Column size="3" data-testid="buttons"> <Memory data-testid="result">{memory.trim()}</Memory> <Button data-testid="1" onClick={() => actions.add('1')}>1</Button> <Button data-testid="2" onClick={() => actions.add('2')}>2</Button> <Button data-testid="3" onClick={() => actions.add('3')}>3</Button> <Button data-testid="4" onClick={() => actions.add('4')}>4</Button> <Button data-testid="5" onClick={() => actions.add('5')}>5</Button> <Button data-testid="6" onClick={() => actions.add('6')}>6</Button> <Button data-testid="7" onClick={() => actions.add('7')}>7</Button> <Button data-testid="8" onClick={() => actions.add('8')}>8</Button> <Button data-testid="9" onClick={() => actions.add('9')}>9</Button> <Button data-testid="reset" onClick={actions.reset}>C</Button> <Button data-testid="0" onClick={() => actions.add('0')}>0</Button> <Button data-testid="remove" onClick={actions.remove}>← </Button> </Grid.Column> <Grid.Column size="1" data-testid="operations">
  30. import * as React from 'react'; const OPERATIONS = {

    '+': (a, b) => a + b, '-': (a, b) => a - b, '*': (a, b) => a * b, }; export default function useCalculatorState(initalState = '0') { const [memory, setMemory] = React.useState(initalState); const actions = React.useMemo( () => ({ add(input) { if (!input.match(/\d/)) { if (!memory.match(/\d$/)) { return; } if (!OPERATIONS[input]) { return; } setMemory(`${memory} ${input} `); return; } if (memory.match(/(^| )0$/)) {
  31. & Test Custom hook

  32. import useCalculatorState from './useCalculatorState'; import { renderHook, act } from

    '@testing-library/react-hooks'; describe(useCalculatorState.name, () => { function initHook(initalState) { return renderHook(() => useCalculatorState(initalState)).result; } describe('add', () => { function call(initalState, value) { const hook = initHook(initalState); act(() => { hook.current[1].add(value); }); return hook.current[0]; } it('can add digits', () => { expect(call('1', '2')).toEqual('12'); }); it('ensures numbers cannot start with 0', () => { expect(call('0', '1')).toEqual('1'); expect(call('1 + 0', '0')).toEqual('1 + 0'); expect(call('1 + 0', '1')).toEqual('1 + 1');
  33. yarn add --dev @testing-library/react-hooks

  34. import useCalculatorState from './useCalculatorState'; import { renderHook, act } from

    '@testing-library/react-hooks'; describe(useCalculatorState.name, () => { function initHook(initalState) { return renderHook(() => useCalculatorState(initalState)).result; } describe('add', () => { function call(initalState, value) { const hook = initHook(initalState); act(() => { hook.current[1].add(value); }); return hook.current[0]; } it('can add digits', () => { expect(call('1', '2')).toEqual('12'); }); it('ensures numbers cannot start with 0', () => { expect(call('0', '1')).toEqual('1'); expect(call('1 + 0', '0')).toEqual('1 + 0'); expect(call('1 + 0', '1')).toEqual('1 + 1');
  35. import useCalculatorState from './useCalculatorState'; import { renderHook, act } from

    '@testing-library/react-hooks'; describe(useCalculatorState.name, () => { function initHook(initalState) { return renderHook(() => useCalculatorState(initalState)).result; } describe('add', () => { function call(initalState, value) { const hook = initHook(initalState); act(() => { hook.current[1].add(value); }); return hook.current[0]; } it('can add digits', () => { expect(call('1', '2')).toEqual('12'); }); it('ensures numbers cannot start with 0', () => { expect(call('0', '1')).toEqual('1'); expect(call('1 + 0', '0')).toEqual('1 + 0'); expect(call('1 + 0', '1')).toEqual('1 + 1');
  36. import useCalculatorState from './useCalculatorState'; import { renderHook, act } from

    '@testing-library/react-hooks'; describe(useCalculatorState.name, () => { function initHook(initalState) { return renderHook(() => useCalculatorState(initalState)).result; } describe('add', () => { function call(initalState, value) { const hook = initHook(initalState); act(() => { hook.current[1].add(value); }); return hook.current[0]; } it('can add digits', () => { expect(call('1', '2')).toEqual('12'); }); it('ensures numbers cannot start with 0', () => { expect(call('0', '1')).toEqual('1'); expect(call('1 + 0', '0')).toEqual('1 + 0'); expect(call('1 + 0', '1')).toEqual('1 + 1');
  37. import useCalculatorState from './useCalculatorState'; import { renderHook, act } from

    '@testing-library/react-hooks'; describe(useCalculatorState.name, () => { function initHook(initalState) { return renderHook(() => useCalculatorState(initalState)).result; } describe('add', () => { function call(initalState, value) { const hook = initHook(initalState); act(() => { hook.current[1].add(value); }); return hook.current[0]; } it('can add digits', () => { expect(call('1', '2')).toEqual('12'); }); it('ensures numbers cannot start with 0', () => { expect(call('0', '1')).toEqual('1'); expect(call('1 + 0', '0')).toEqual('1 + 0'); expect(call('1 + 0', '1')).toEqual('1 + 1');
  38. import useCalculatorState from './useCalculatorState'; import { renderHook, act } from

    '@testing-library/react-hooks'; describe(useCalculatorState.name, () => { function initHook(initalState) { return renderHook(() => useCalculatorState(initalState)).result; } describe('add', () => { function call(initalState, value) { const hook = initHook(initalState); act(() => { hook.current[1].add(value); }); return hook.current[0]; } it('can add digits', () => { expect(call('1', '2')).toEqual('12'); }); it('ensures numbers cannot start with 0', () => { expect(call('0', '1')).toEqual('1'); expect(call('1 + 0', '0')).toEqual('1 + 0'); expect(call('1 + 0', '1')).toEqual('1 + 1');
  39. it('can add digits', () => { expect(call('1', '2')).toEqual('12'); }); it('ensures

    numbers cannot start with 0', () => { expect(call('0', '1')).toEqual('1'); expect(call('1 + 0', '0')).toEqual('1 + 0'); expect(call('1 + 0', '1')).toEqual('1 + 1'); expect(call('10', '0')).toEqual('100'); }); it('can add operand', () => { expect(call('1', '+')).toEqual('1 + '); }); it('guards against add operand before digit', () => { expect(call('', '+')).toEqual(''); }); it('guards against two operands one after the other', () => { expect(call('1 + ', '+')).toEqual('1 + '); }); it('guards against add invalid operator', () => { expect(call('1', '~')).toEqual('1'); }); });
  40. describe('remove', () => { function call(initalState) { const hook =

    initHook(initalState); act(() => { hook.current[1].remove(); }); return hook.current[0]; } it('removes one digit', () => { expect(call('1234')).toEqual('123'); }); it('removes one operand', () => { expect(call('1 + ')).toEqual('1'); }); it('returns at least 0 when fully cleared', () => { expect(call('1')).toEqual('0'); }); }); describe('evaluate', () => { function call(initalState) { const hook = initHook(initalState);
  41. describe('evaluate', () => { function call(initalState) { const hook =

    initHook(initalState); act(() => { hook.current[1].evaluate(); }); return hook.current[0]; } describe('operations', () => { it('handles noop', () => { expect(call('12')).toEqual('12'); }); it('handles "1 + 1" input', () => { expect(call('1 + 1')).toEqual('2'); }); it('handles "1 - 1" input', () => { expect(call('2 - 1')).toEqual('1'); }); it('handles "2 * 3" input', () => { expect(call('2 * 3')).toEqual('6'); }); });
  42. expect(call('2 * 3')).toEqual('6'); }); }); describe('edge cases', () => {

    it('handles zero in input', () => { expect(call('1 + 0')).toEqual('1'); }); it('handles larger number in input', () => { expect(call('123 + 456')).toEqual((123 + 456).toString()); }); it('handles multiple operations input', () => { expect(call('1 + 1 + 1')).toEqual('3'); }); it('handles incomplete operation input', () => { expect(call('1 + ')).toEqual('1'); }); it('handles negative values', () => { expect(call('-1 + 2')).toEqual('1'); }); }); }); });
  43. ' Refactor Custom Hook

  44. import * as React from 'react'; export default function useCalculatorState(initalState

    = '0') { const [memory, dispatch] = React.useReducer(calculatorReducer, initalState); const actions = React.useMemo( () => ({ add(value) { dispatch({ type: 'add', payload: value }); }, remove() { dispatch({ type: 'remove' }); }, reset() { dispatch({ type: 'reset' }); }, evaluate() { dispatch({ type: 'evaluate' }); }, }), [dispatch], ); return [memory, actions]; } const OPERATIONS = {
  45. function calculatorReducer(memory, action) { switch (action.type) { case 'add': const

    input = action.payload; if (!input.match(/\d/)) { if (!memory.match(/\d$/)) { return memory; } if (!OPERATIONS[input]) { return memory; } return `${memory} ${input} `; } if (memory.match(/(^| )0$/)) { return `${memory.slice(0, -1)}${input}`; } return `${memory}${input}`; case 'remove': const newMemory = memory.match(/\d$/) ? memory.slice(0, -1) : memory.slice(0, -3);
  46. case 'reset': return '0'; case 'evaluate': const chunks = memory.split('

    '); let value = parseInt(chunks[0], 10); for (let i = 1; i < chunks.length; i += 2) { const other = chunks[i + 1]; if (typeof other === 'undefined' || other.length === 0) { continue; } const operation = OPERATIONS[chunks[i]]; if (!operation) { throw new Error(`Invalid "${chunks[i]}" operand`); } value = operation(value, parseInt(other, 10)); } return value.toString(); default: throw new Error(`Invalid action - ${action.type}`); } }
  47. Recap

  48. ( started with messy code ) added "smoke test" *

    extracted custom hook + added test for the custom hook , refactored the hook - removed "smoke test"
  49. None
  50. Thanks .

  51. Thanks . https://github.com/rstankov/talks-code

  52. Thanks . https://rstankov.com/appearances