Testing

// pseudo tests using AAA, read it as prose

describe('wrap everything in a describe', () => {
  /* 1 */ beforeEach(() => {
    // setup needed for every test
    // ARRANGE
  })

  describe('group main functionality in 1st level of describes', () => {
    /* 2 */ beforeEach(() => {
      // do something to change the state
      // use a `beforeEach` even if there is only one `it`
      // it will be easier to extend later
      // ARRANGE & ACT
    })

    it('should respond to the change', () => {
      // test the outcome
      // ASSERT
    })

    describe('use nested describes to dive into branches', () => {
      /* 3 */ beforeEach(() => {
        // do something that changes the state further
        // ARRANGE & ACT
      })

      it('should respond to the change', () => {
        // assertion given that 1, 2, 3 executed
        // ASSERT
      })

      it('should follow the structure of your code', () => {
        // it should be easy to find where new tests need to go
        // ASSERT
      })
    })
  })

  // #region events
  describe('example events', () => {
    describe('when the user types', () => {
      beforeEach(() => {
        // find input element
        // ARRANGE
        // fire change event so that it is invalid
        // ACT
      })

      it('should validate input', () => {
        // find error
        // match text in error
        // ASSERT
      })

      describe('when the input is valid', () => {
        beforeEach(() => {
          // fire a change event so that the input is valid
          // ACT
        })

        it('should validate input', () => {
          // find error
          // expect that there are none
          // ASSERT
        })

        describe('when the user submits', () => {
          beforeEach(() => {
            // find submit button
            // click it
            // ARRANGE & ACT
          })

          it('should call api', () => {
            // expect mock to have been called with form data
            // ASSERT
          })
        })
      })
    })
  })
  // #endregion

  // #region a11y
  describe('example a11y', () => {
    describe('when the accordion is closed', () => {
      beforeEach(() => {
        // find the toggle button
        // ARRANGE
      })

      it('should have aria-expanded=false', () => {
        // expect the toggle button to have the attribute
        // ASSERT
      })

      describe('when the accordion is open', () => {
        beforeEach(() => {
          // click the toggle
          // ACT
        })

        it('should have aria-expanded=true', () => {
          // expect the toggle button to have the attribute
          // ASSERT
        })

        describe('when the accordion is closed', () => {
          beforeEach(() => {
            // click the toggle
            // ACT
          })

          it('should have aria-expanded=false', () => {
            // expect the toggle button to have the attribute
            // ASSERT
          })
        })
      })
    })
  })
  // #endregion

  // #region non-happy
  describe('example non-happy paths', () => {
    describe('when the api times out', () => {
      beforeEach(() => {
        // use mock timers
        // set mock to return unresolved promise
        // ARRANGE
        // action that calls into api
        // ACT
      })

      it('should show spinner', () => {
        // find spinner
        // expect that it exists
        // ASSERT
      })

      describe('when request times out', () => {
        beforeEach(() => {
          // run timers
          // ACT
        })

        it('should show error', () => {
          // find error
          // match text
          // ASSERT
        })
      })
    })

    describe('when api errors', () => {
      beforeEach(() => {
        // set mock to return unresolved promise
        // ARRANGE
        // action that calls into api
        // ACT
      })

      it('should show error', () => {
        // find error
        // match text
        // ASSERT
      })
    })
  })
  // #endregion
})

export const dummy = null

GifFinder

  • let’s test a component that uses state & effects to find gifs
import React, { FC, useEffect, useState } from 'react'
import classes from './example.module.css'
import { TenorAPI } from './tenor-api'

export interface GifFinderProps {
  onFound?(url: string): void
}

export const GifFinder: FC<GifFinderProps> = ({ onFound }) => {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<string[] | null>(null)

  useEffect(() => {
    let shouldUpdate = true

    if (query) {
      TenorAPI.search(query).then((newResults) => {
        if (shouldUpdate) setResults(newResults)
      })
    }

    return () => {
      shouldUpdate = false
    }
  }, [query])

  return (
    <section>
      <label>
        find a gif
        <input
          placeholder="query"
          value={query}
          onChange={(event) => setQuery(event.target.value)}
        />
      </label>
      {results && (
        <div className={classes.grid}>
          {results.map((result) => (
            <button
              type="button"
              key={result}
              className={classes.button}
              onClick={() => onFound && onFound(result)}
              aria-label="gif"
            >
              <img className={classes.image} src={result} alt="gif" />
            </button>
          ))}
        </div>
      )}
    </section>
  )
}

export const Example: FC = () => {
  const [gif, setGif] = useState<string | null>(null)

  return gif ? (
    <div>
      <img src={gif} alt="gif" />
      <p>found gif</p>
      <button type="button" onClick={() => setGif(null)}>
        find new gif
      </button>
    </div>
  ) : (
    <GifFinder onFound={setGif} />
  )
}

export default <Example />

Testing GifFinder

The more your tests resemble the way your software is used, the more confidence they can give you.

@testing-library

import {
  act,
  cleanup,
  fireEvent,
  render,
  RenderResult,
} from '@testing-library/react'
import React from 'react'
import { GifFinder } from './example'
import { TenorAPI } from './tenor-api'

describe('GifFinder', () => {
  let component: RenderResult
  let onFound: jest.Mock

  beforeEach(() => {
    onFound = jest.fn()
    component = render(<GifFinder onFound={onFound} />)
  })

  afterEach(() => {
    cleanup()
  })

  it('should snapshot', () => {
    expect(component.container).toMatchSnapshot()
  })

  describe('when searching', () => {
    let queryInput: HTMLElement
    let searchSpy: jest.SpyInstance

    beforeEach(async () => {
      const searchPromise = Promise.resolve(['gif1', 'gif2', 'gif3'])
      searchSpy = jest.spyOn(TenorAPI, 'search').mockReturnValue(searchPromise)
      queryInput = component.getByLabelText('find a gif')
      fireEvent.input(queryInput, { target: { value: 'react' } })
      await act(async () => {
        await searchPromise
      })
    })

    afterEach(() => {
      searchSpy.mockRestore()
    })

    it('should start a search', () => {
      expect(searchSpy).toHaveBeenCalledWith('react')
      expect(searchSpy).toHaveBeenCalledTimes(1)
    })

    it('should render the gifs', () => {
      expect(component.container).toMatchSnapshot()
    })

    describe('when clicking a gif', () => {
      beforeEach(() => {
        const [, gif2] = component.getAllByLabelText('gif')
        fireEvent.click(gif2)
      })

      it('should call onFound', () => {
        expect(onFound).toHaveBeenCalledWith('gif2')
        expect(onFound).toHaveBeenCalledTimes(1)
      })
    })
  })
})