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

Testing React Hooks with Confidence

Testing React Hooks with Confidence

Avatar for Radoslav Stankov

Radoslav Stankov

January 16, 2021
Tweet

More Decks by Radoslav Stankov

Other Decks in Technology

Transcript

  1. 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;
  2. 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');
  3. 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]];
  4. 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 (
  5. ); 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> ); }
  6. 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> ); }
  7. 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> ); }
  8. 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'); }); });
  9. 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'); }); });
  10. 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'); }); });
  11. 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'); }); });
  12. 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'); }); });
  13. " This is placeholder " Not a "real" unit test.

    It doesn't follow the 4 phases of good test: setup ➡ action ➡ assert ➡ teardown
  14. 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; $
  15. 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">
  16. 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">
  17. 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$/)) {
  18. 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');
  19. 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');
  20. 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');
  21. 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');
  22. 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');
  23. 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');
  24. 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'); }); });
  25. 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);
  26. 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'); }); });
  27. 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'); }); }); }); });
  28. 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 = {
  29. 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);
  30. 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}`); } }
  31. ( started with messy code ) added "smoke test" *

    extracted custom hook + added test for the custom hook , refactored the hook - removed "smoke test"