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

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

    View Slide

  2. Radoslav Stankov
    @rstankov
    blog.rstankov.com

    twitter.com/rstankov

    github.com/rstankov

    speakerdeck.com/rstankov

    View Slide

  3. View Slide

  4. View Slide

  5. https://rstankov.com/appearances

    View Slide

  6. View Slide

  7. Automated
    Testing
    Test Driven
    Development

    View Slide

  8. View Slide

  9. View Slide

  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;

    View Slide

  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');

    View Slide

  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]];

    View Slide

  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 (

    View Slide

  14. );
    return (


    {memory.trim()}
    actions.add('1')}>1
    actions.add('2')}>2
    actions.add('3')}>3
    actions.add('4')}>4
    actions.add('5')}>5
    actions.add('6')}>6
    actions.add('7')}>7
    actions.add('8')}>8
    actions.add('9')}>9
    C
    actions.add('0')}>0



    =
    actions.add('+')}>+
    actions.add('-')}>-
    actions.add('*')}>*


    );
    }

    View Slide

  15. View Slide

  16. ! Smoke Test

    View Slide

  17. return (


    {memory.trim()}
    actions.add('1')}>1
    actions.add('2')}>2
    actions.add('3')}>3
    actions.add('4')}>4
    actions.add('5')}>5
    actions.add('6')}>6
    actions.add('7')}>7
    actions.add('8')}>8
    actions.add('9')}>9
    C
    actions.add('0')}>0



    =
    actions.add('+')}>+
    actions.add('-')}>-
    actions.add('*')}>*


    );
    }

    View Slide

  18. return (


    {memory.trim()}
    actions.add('1')}>1
    actions.add('2')}>2
    actions.add('3')}>3
    actions.add('4')}>4
    actions.add('5')}>5
    actions.add('6')}>6
    actions.add('7')}>7
    actions.add('8')}>8
    actions.add('9')}>9
    C
    actions.add('0')}>0



    =
    actions.add('+')}>+
    actions.add('-')}>-
    actions.add('*')}>*


    );
    }

    View Slide

  19. yarn add --dev @testing-library/react

    View Slide

  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();
    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');
    });
    });

    View Slide

  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();
    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');
    });
    });

    View Slide

  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();
    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');
    });
    });

    View Slide

  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();
    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');
    });
    });

    View Slide

  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();
    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');
    });
    });

    View Slide

  25. " This is placeholder "
    Not a "real" unit test.
    It doesn't follow the 4 phases of good test:
    setup ➡ action ➡ assert ➡ teardown

    View Slide

  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;
    $

    View Slide

  27. % Extract Custom Hook

    View Slide

  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 (


    {memory.trim()}
    actions.add('1')}>1
    actions.add('2')}>2
    actions.add('3')}>3
    actions.add('4')}>4
    actions.add('5')}>5
    actions.add('6')}>6
    actions.add('7')}>7
    actions.add('8')}>8
    actions.add('9')}>9
    C
    actions.add('0')}>0



    View Slide

  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 (


    {memory.trim()}
    actions.add('1')}>1
    actions.add('2')}>2
    actions.add('3')}>3
    actions.add('4')}>4
    actions.add('5')}>5
    actions.add('6')}>6
    actions.add('7')}>7
    actions.add('8')}>8
    actions.add('9')}>9
    C
    actions.add('0')}>0



    View Slide

  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$/)) {

    View Slide

  31. & Test Custom hook

    View Slide

  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');

    View Slide

  33. yarn add --dev @testing-library/react-hooks

    View Slide

  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');

    View Slide

  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');

    View Slide

  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');

    View Slide

  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');

    View Slide

  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');

    View Slide

  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');
    });
    });

    View Slide

  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);

    View Slide

  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');
    });
    });

    View Slide

  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');
    });
    });
    });
    });

    View Slide

  43. ' Refactor Custom Hook

    View Slide

  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 = {

    View Slide

  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);

    View Slide

  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}`);
    }
    }

    View Slide

  47. Recap

    View Slide

  48. ( started with messy code
    ) added "smoke test"
    * extracted custom hook
    + added test for the custom hook
    , refactored the hook
    - removed "smoke test"

    View Slide

  49. View Slide

  50. Thanks .

    View Slide

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

    View Slide

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

    View Slide