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 full-size slide

  2. Radoslav Stankov
    @rstankov
    blog.rstankov.com

    twitter.com/rstankov

    github.com/rstankov

    speakerdeck.com/rstankov

    View full-size slide

  3. https://rstankov.com/appearances

    View full-size slide

  4. Automated
    Testing
    Test Driven
    Development

    View full-size slide

  5. 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 full-size slide

  6. 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 full-size slide

  7. 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 full-size slide

  8. 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 full-size slide

  9. );
    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 full-size slide

  10. ! Smoke Test

    View full-size slide

  11. 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 full-size slide

  12. 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 full-size slide

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

    View full-size slide

  14. 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 full-size slide

  15. 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 full-size slide

  16. 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 full-size slide

  17. 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 full-size slide

  18. 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 full-size slide

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

    View full-size slide

  20. 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 full-size slide

  21. % Extract Custom Hook

    View full-size slide

  22. 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 full-size slide

  23. 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 full-size slide

  24. 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 full-size slide

  25. & Test Custom hook

    View full-size slide

  26. 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 full-size slide

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

    View full-size slide

  28. 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 full-size slide

  29. 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 full-size slide

  30. 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 full-size slide

  31. 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 full-size 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 full-size slide

  33. 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 full-size slide

  34. 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 full-size slide

  35. 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 full-size slide

  36. 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 full-size slide

  37. ' Refactor Custom Hook

    View full-size slide

  38. 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 full-size slide

  39. 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 full-size slide

  40. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide