RTL

(react-testing-library)

React Testing Libraries

  • ReactTestUtil/TestRenderer 
  • enzyme
  • react-testing-library

Sample component

import React, { useState, useEffect } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    document.title = `you clicked ${count} times`;
  })

  return (
    <div>
      <p data-testid="current-count">{count}</p>
      <button onClick={() => setCount(count + 1)}>
        <span>button</span>
      </button>
    </div>
  )
}

export default Counter;

Testing with test-utils

it('counter increments the count with test-utils', () => {
  // Test first render and effect
  act(() => {
    ReactDOM.render(<Counter />, container);
  });

  const button = container.querySelector('button');
  const label = container.querySelector('p');

  expect(label.textContent).toBe('0');
  expect(document.title).toBe('you clicked 0 times');

  // Test second render and effect
  act(() => {
    button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
  });

  expect(label.textContent).toBe('1');
  expect(document.title).toBe('you clicked 1 times');
})

Testing with test-utils

 PASS  src/components/counter/__tests__/index.js

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.299s

Testing with test-utils

it('counter increments the count with test-utils', () => {
  // Test first render and effect
  // act(() => {
    ReactDOM.render(<Counter />, container);
  // });

  const button = container.querySelector('button');
  const label = container.querySelector('p');

  expect(label.textContent).toBe('0');
  expect(document.title).toBe('you clicked 0 times');

  // Test second render and effect
  act(() => {
    button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
  });

  expect(label.textContent).toBe('1');
  expect(document.title).toBe('you clicked 1 times');
})

Testing with test-utils

 FAIL  src/components/counter/__tests__/index.js
  ● counter increments the count with test-utils

    expect(received).toBe(expected) // Object.is equality

    Expected: "you clicked 0 times"
    Received: "you clicked 1 times"

      39 | 
      40 |   expect(label.textContent).toBe('0');
    > 41 |   expect(document.title).toBe('you clicked 0 times');
         |                          ^
      42 | 
      43 |   // Test second render and effect
      44 |   act(() => {

      at Object.toBe (src/components/counter/__tests__/index.js:41:26)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total

Testing with enzyme

it('counter increments the count with enzyme mount', () => {
  const component = mount(<Counter />);

  expect(document.title).toBe('you clicked 0 times');
  expect(component.find('p').text()).toBe('0')

  act(() => {
    component.find('button').simulate('click');
  })

  expect(document.title).toBe('you clicked 1 times');

  expect(component.find('p').text()).toBe('1')
})

Testing with enzyme

 PASS  src/components/counter/__tests__/index.js

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.299s

Testing with enzyme

it('counter increments the count with enzyme mount', () => {
  const component = mount(<Counter />);

  expect(document.title).toBe('you clicked 0 times');
  expect(component.find('p').text()).toBe('0')

  // act(() => {
    component.find('button').simulate('click');
  // })

  expect(document.title).toBe('you clicked 1 times');

  expect(component.find('p').text()).toBe('1')
})

Testing with enzyme

 FAIL  src/components/counter/__tests__/index.js
  ● counter increments the count with enzyme mount

    expect(received).toBe(expected) // Object.is equality

    Expected: "you clicked 1 times"
    Received: "you clicked 0 times"

      70 |   // })
      71 | 
    > 72 |   expect(document.title).toBe('you clicked 1 times');
         |                          ^
      73 | 
      74 |   expect(component.find('p').text()).toBe('1')
      75 | })

      at Object.toBe (src/components/counter/__tests__/index.js:72:26)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total

Testing with RTL

it('counter increments the count with RTL', () => {
  const component = render(<Counter />)

  const button = component.getByText('button');
  const currentCount = component.getByTestId('current-count');

  expect(currentCount.textContent).toBe('0')
  expect(document.title).toBe('you clicked 0 times')

  fireEvent.click(button)

  expect(currentCount.textContent).toBe('1')

  expect(document.title).toBe('you clicked 1 times')
})

Testing with RTL

 PASS  src/components/counter/__tests__/index.js

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.299s

RTL

  • User interaction
  • No implementation details
  • No shallow rendering
  • dom-testing-library
  • Kent C. Dodds

Kent C Dodds

Testing should be about acquiring confidence

testingjavascript.com

kentcdodds.com/blog

shallow vs mount

render (
    <TodoInput
      onChange={newTaskText => this.setState({ newTaskText })}
      value={newTaskText}
      onSubmit={() => {
        this.setTasks([...tasks, { text: newTaskText, done: false }]);
        this.setState({ newTaskText: "" });
      }}
    />
    
    <TodoList
      tasks={tasks}
      deleteTask={i => {
        tasks.splice(i, 1);
        this.setTasks([...tasks]);
      }}
      toggleTask={i => {
        tasks[i].done = !tasks[i].done;
        this.setTasks([...tasks]);
      }}
    />
)

Testing React

import React from "react";
import { shallow } from "enzyme";
import { TodoContainer, myTasksKey } from "../TodoContainer";

describe("TodoContainer", () => {
  it("should read from localStorage on mount", () => {
    const expectedTasks = [{ text: "some task" }];
    localStorage.setItem(myTasksKey, JSON.stringify(expectedTasks));

    const wrapper = shallow(<TodoContainer />);
    expect(wrapper.find("TodoList").prop("tasks")).toMatchObject(expectedTasks);
  });
});

shallow vs mount

 it("pressing enter with the input focused should submit it", () => {
    const onChange = jest.fn();
    const onSubmit = jest.fn();

    const { getByTestId } = render(
      <TodoInput value="" onChange={onChange} onSubmit={onSubmit} />
    );
    const input = getByTestId("task-input");

    input.focus();
    fireEvent(
      input,
      new KeyboardEvent("keyDown", { key: "Enter", keyCode: 13, which: 13 })
    );

    expect(onSubmit).toHaveBeenCalledTimes(1);
  });

Accordion component

Class

class Accordion extends React.Component {
  state = { openIndex: 0 }
  setOpenIndex = openIndex => this.setState({ openIndex })

  render() {
    const { openIndex } = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <div key={item.title}>
            <button onClick={() => this.setOpenIndex(index)}>
              {item.title}
            </button>
            {
              index === openIndex
                ? (<div className="item">{item.description}</div>)
                : null
            }
          </div>
        ))}
      </div>
    )
  }
}

Testing implementation

test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />)

  expect(wrapper.state('openIndex')).toBe(0)

  wrapper.instance().setOpenIndex(1)

  expect(wrapper.state('openIndex')).toBe(1)
})

test('Accordion renders accordion item with the item description', () => {
  const pikachu = { 
    title: 'detective', description: 'sounds like deadpool' 
  }
  const bulbasaur = {
    title: 'bulbasaur', description: 'is the number 1'
  }
  const wrapper = mount(<Accordion items={[pikachu, bulbasaur]} />)

  expect(wrapper.find('.item').props().children).toBe(pikachu.description)
})

Testing implementation

 PASS  src/components/accordion/__tests__/index.js

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.388s, estimated 2s


False negatives

class Accordion extends React.Component {
  state = { openIndexes: [0] }
  setOpenIndex = openIndex => this.setState({ openIndexes: [openIndex] })

  render() {
    const { openIndexes } = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <div key={item.title}>
            <button onClick={() => this.setOpenIndex(index)}>
              {item.title}
            </button>
            {
              openIndexes.includes(index)
                ? (<div className="item">{item.description}</div>)
                : null
            }
          </div>
        ))}
      </div>
    )
  }
}

False negatives

 FAIL  src/components/accordion/__tests__/index.js
  ● setOpenIndex sets the open index state properly

    expect(received).toBe(expected) // Object.is equality

    Expected: 0
    Received: undefined

       8 |   const wrapper = mount(<Accordion items={[]} />)
       9 | 
    > 10 |   expect(wrapper.state('openIndex')).toBe(0)
         |                                      ^
      11 | 
      12 |   wrapper.instance().setOpenIndex(1)
      13 | 

      at Object.toBe (src/components/accordion/__tests__/index.js:10:38)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 2 total

False negatives

test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />)

  expect(wrapper.state('openIndexes')).toBe(0)

  wrapper.instance().setOpenIndex(1)

  expect(wrapper.state('openIndexes')).toBe(1)
})

test('Accordion renders accordion item with the item description', () => {
  const pikachu = { 
    title: 'detective', description: 'sounds like deadpool' 
  }
  const bulbasaur = {
    title: 'bulbasaur', description: 'is the number 1'
  }
  const wrapper = mount(<Accordion items={[pikachu, bulbasaur]} />)

  expect(wrapper.find('.item').props().children).toBe(pikachu.description)
})

False negatives

 PASS  src/components/accordion/__tests__/index.js

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.388s, estimated 2s


False positives

class Accordion extends React.Component {
  state = { openIndexes: [0] }
  setOpenIndex = openIndex => this.setState({ openIndexes: [openIndex] })

  render() {
    const { openIndexes } = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <div key={item.title}>
            <button onClick={() => this.setOpenIndex(index)}>
              {item.title}
            </button>
            {
              openIndexes.includes(index)
                ? (<div className="item">{item.description}</div>)
                : null
            }
          </div>
        ))}
      </div>
    )
  }
}

False positives

class Accordion extends React.Component {
  state = { openIndexes: [0] }
  setOpenIndex = openIndex => this.setState({ openIndexes: [openIndex] })

  render() {
    const { openIndexes } = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <div key={item.title}>
            <button onClick={this.setOpenIndex}>
              {item.title}
            </button>
            {
              openIndexes.includes(index)
                ? (<div className="item">{item.description}</div>)
                : null
            }
          </div>
        ))}
      </div>
    )
  }
}

False positives

 PASS  src/components/accordion/__tests__/index.js

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.388s, estimated 2s


Testing with RTL

test('can open accordion items to see the description', () => {
  const pikachu = { 
    title: 'detective', description: 'sounds like deadpool' 
  }
  const bulbasaur = {
    title: 'bulbasaur', description: 'is the number 1'
  }
  const {getByText, queryByText} = render(
    <Accordion items={[pikachu, bulbasaur]} />,
  )

  expect(getByText(pikachu.description)).toBeInTheDocument()
  expect(queryByText(bulbasaur.description)).toBeNull()

  fireEvent.click(getByText(bulbasaur.title))

  expect(getByText(bulbasaur.description)).toBeInTheDocument()
  expect(queryByText(pikachu.description)).toBeNull()
})

Implementation with hooks

const Accordion = ({ items }) => {
  const [openIndex, setOpenIndex] = useState(0)

  return (
    <div>
      {items.map((item, index) => (
        <div key={item.title}>
          <div>
            <button onClick={() => setOpenIndex(index)}>
              {item.title}
            </button>
          </div>
          {
            index === openIndex
              ? (<div>{item.description}</div>)
              : null
          }
        </div>
      ))}
    </div>
  )
}

Nothing changes :)

 PASS  src/components/accordion/__tests__/index.js

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.299s

Avoid implementation details and your tests will work with hooks or whatever the next thing will be

Enhanced Counter class

class EnhancedCounter extends React.Component {
  state = {
    count: Number(window.localStorage.getItem('count') || 0),
  }

  increment = () => this.setState(({count}) => ({count: count + 1}))

  componentDidMount() {
    window.localStorage.setItem('count', this.state.count)
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevState.count !== this.state.count) {
      window.localStorage.setItem('count', this.state.count)
    }
  }

  render() {
    return <button onClick={this.increment}>{this.state.count}</button>
  }
}
afterEach(() => {
  window.localStorage.removeItem('count')
})

test('counter increments the count', () => {
  const {container} = render(<EnhancedCounter />)
  const button = container.firstChild

  expect(button.textContent).toBe('0')

  fireEvent.click(button)

  expect(button.textContent).toBe('1')
})

test('reads and updates localStorage', () => {
  window.localStorage.setItem('count', 3)

  const {container} = render(<EnhancedCounter />)
  const button = container.firstChild

  expect(button.textContent).toBe('3')

  fireEvent.click(button)

  expect(button.textContent).toBe('4')
  expect(window.localStorage.getItem('count')).toBe('4')
})

Testing with RTL

 PASS  src/components/enhanced-counter/__tests__/index.js
  ✓ counter increments the count (26ms)
  ✓ reads and updates localStorage (3ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.42s

Testing with RTL

import React, {useState, useEffect} from 'react'

const EnhancedCounter = () => {
  const [count, setCount] = useState(() =>
    Number(window.localStorage.getItem('count') || 0),
  )

  const incrementCount = () => setCount(c => c + 1)

  useEffect(() => {
    window.localStorage.setItem('count', count)
  }, [count])

  return <button onClick={incrementCount}>{count}</button>
}

export default EnhancedCounter;

Testing with RTL

 PASS  src/components/enhanced-counter/__tests__/index.js
  ✓ counter increments the count (26ms)
  ✓ reads and updates localStorage (3ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.42s

Custom hooks

import { useState } from 'react';

const useCounter = () => {
  const [count, setCount] = useState(0)

  const increment = () => setCount(currentCount => currentCount + 1)

  return {count, increment}
}

export default useCounter

// usage:
// const Counter = () => {
//   const {count, increment} = useCounter()
//   return <button onClick={increment}>{count}</button>
// }

Custom hooks

const TestHook = ({ callback }) => {
  callback()
  return null
}

test('useCounter', () => {
  let count, increment
  render(<TestHook callback={() => ({count, increment} = useCounter())} />)

  expect(count).toBe(0)

  act(() => {
    increment()
  })

  expect(count).toBe(1)
})

react-hooks-testing-library

import { renderHook, act } from 'react-hooks-testing-library'

test('useCounter', () => {
  const { result } = renderHook(() => useCounter())

  expect(result.current.count).toBe(0)
 
  act(() => result.current.increment())
 
  expect(result.current.count).toBe(1)
})

react-hooks-testing-library

 PASS  src/components/use-counter/__tests__/index.js
  ✓ useCounter (2ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.213s, estimated 1s

React Lazy/Suspense

React Lazy/Suspense

import React from 'react';

const LazyComponent = React.lazy(() => import('./lazy')); // export default () => <div> I am lazy </div>

const Wrapper = () => {
  return (
    <div>
      <div> Lazy component here: </div>
      <LazyComponent />
    </div>
  )
}

export default Wrapper

React Lazy/Suspense

 FAIL  src/components/lazy-test/__tests__/index.js
  ✕ renders lazy (6ms)

  ● renders lazy

    A React component suspended while rendering, but no fallback UI was specified.

    Add a <Suspense fallback=...> component higher in the tree to provide a loading indicator or placeholder to display.
        in Unknown (at lazy-test/index.jsx:9)
        in div (at lazy-test/index.jsx:7)
        in Wrapper (at __tests__/index.js:7)

      5 | 
      6 | test('renders lazy', () => {
    > 7 |   const { debug } = render(<Wrapper />);
        |                     ^
      8 |   debug();
      9 | })

React Lazy/Suspense

test('renders lazy', () => {
  const { getByText } = render(
    <React.Suspense fallback="loading...">
      <Wrapper />
    </React.Suspense>
  );

  expect(getByText('I am lazy')).toBeInTheDocument();
})

React Lazy/Suspense

 FAIL  src/components/lazy-test/__tests__/index.js
  ✕ renders lazy (8ms)
  ● renders lazy
    Unable to find an element with the text: I am lazy. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

    <body>
      <div>
        <div
          style="display: none;"
        >
          <div>
             Lazy component here: 
          </div>
        </div>
        loading...
      </div>
    </body>

   
    > 13 |   expect(getByText('I am lazy')).toBeInTheDocument();
         |          ^
   

React Lazy/Suspense

import { render, wait } from 'react-testing-library';

test('renders lazy', async () => {
  const { getByText } = render(
    <React.Suspense fallback="loading...">
      <Wrapper />
    </React.Suspense>
  );

  await wait();

  expect(getByText('I am lazy')).toBeInTheDocument();
})

React Lazy/Suspense

 PASS  src/components/lazy-test/__tests__/index.js
  ✓ renders lazy (23ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.51s, estimated 1s

React Lazy/Suspense

import { render, waitForElement } from 'react-testing-library';

test('renders lazy', async () => {
  const { getByText } = render(
    <React.Suspense fallback="loading...">
      <Wrapper />
    </React.Suspense>
  );

  const lazyElement = await waitForElement(() => getByText('I am lazy'));

  expect(lazyElement).toBeInTheDocument();
})

Testing React

 PASS  src/components/lazy-test/__tests__/index.js
  ✓ renders lazy (75ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.486s, estimated 1s

Thx for watching

Questions?