Написание тестов для тестирования компонента React может оказаться сложной задачей. Я потратил время на поиски правильного способа сделать это, но с трудом нашел нужные мне примеры для решения задач. И это заставляет меня чувствовать желание написать эту статью, чтобы поделиться своим опытом и идеями.

Настройка среды

Если вам интересно узнать, как настроить среду библиотеки тестирования Jest и React, вы также можете прочитать эту статью.

Демонстрационный репозиторий

Если вы хотите увидеть полный пример, я создал образец для демонстрации: https://github.com/tabsteveyang/react-jest-rtl-boilerplate.

Насмешка

После настройки среды для запуска библиотеки Jest и React Testing Library вы можете вскоре обнаружить, что некоторые из ваших компонентов сломаются, когда вы будете визуализировать их в тесте.

Это связано с тем, что компонент, отображаемый в тесте, может работать только в определенных средах. Обычно он уже настроен в средах разработки и производства, но мы должны смоделировать поддельный во время выполнения тестов.

Я помещу файлы, созданные в этом разделе, в каталог jest/mock/ и добавлю псевдоним @jest в файл webpack.config.js.

alias: {
  '@js': path.resolve(__dirname, 'src/js/'),
  '@scss': path.resolve(__dirname, 'src/scss/'),
  '@img': path.resolve(__dirname, 'img/'),
  '@jest': path.resolve(__dirname, 'jest/')
}

А также добавить его в jest.config.js

moduleNameMapper: {
  '^@js(.*)$': '<rootDir>/src/js$1',
  '^@scss(.*)$': '<rootDir>/src/scss$1',
  '^@img(.*)$': '<rootDir>/img$1',
  '^@jest(.*)$': '<rootDir>/jest$1'
},

Насмешливый Редукс

1. Создайте копию реального магазина

В некоторых случаях нам нужна только среда, которая может отображать компонент в состоянии по умолчанию. Вы можете сделать это, скопировав фактический файл истории и удалив ненужные конфигурации (например, конфигурацию для Redux DevTools).

// jest/mock/store/index.js
import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import reducer from '@js/reducers'
const composeEnhancers = compose
const middleware = [thunk]
export default createStore(
  reducer,
  composeEnhancers(applyMiddleware(...middleware))
)

2. Имитация провайдера

Если мы используем React с Redux, нам нужно обернуть компоненты в компонент Provider из библиотеки react-redux. Нам нужно сделать то же самое при рендеринге компонента в тесте. Чтобы избежать многократного написания одной и той же логики, мы можем вместо этого создать компонент для ее обработки.

// jest/mock/MockProvider.jsx
import { Provider } from 'react-redux'
// use the replica one as the default store
import store from './store'
const MockProvider = ({ children = null, mockStore = null }) => {
  return (
    <Provider store={mockStore || store}>
      { children }
    </Provider>
  )
}
MockProvider.propTypes = {
  mockStore: PropTypes.object,  // this prop allows us to set the store to specific state
  children: PropTypes.node
}
export default MockProvider

Также нам предстоит создать модуль для создания различных состояний магазина. Модуль использует библиотеку redux-mock-store для создания фиктивного хранилища и заменяет атрибут отправки фиктивного хранилища функцией фиктивного Jest, чтобы мы могли отслеживать статус, когда это необходимо. И мы можем использовать параметр для настройки начального состояния хранилища.

// jest/mock/store/createMockStore.js
import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'
const middleware = [thunk]
export default (initialState) => {
  const mockStore = configureMockStore(middleware)(initialState)
  // eslint-disable-next-line no-undef
  // replace the dispatch method with a spy and keep the funtionality
  mockStore.dispatch = jest.fn(mockStore.dispatch)
  return mockStore
}

Насмешливые маршруты и история

Вот еще один подобный случай. Чтобы компоненты, использующие библиотеку react-router-dom, работали, мы должны обернуть их в компонент Router. Поэтому мы также можем создать модуль для этого.

// jest/mock/mockRouter.jsx
import { Router } from 'react-router-dom'
import { createBrowserHistory } from 'history'
const MockRouter = ({ children }) => {
  return (
    <Router history={createBrowserHistory()}>
      { children }
    </Router>
  )
}
MockRouter.propTypes = {
  children: PropTypes.node  // prop for sending in the components that you want to render
}
export default MockRouter

Имитация модулей импорта

В некоторых случаях необходимо мокать модули (в том числе и пакеты из npm) в тесте, и вот пример как это сделать:

import { render, cleanup } from '@testing-library/react'
import MainPage from '../MainPage'
// *** must write it in the global scope ***
// mock the react-router-dom library
// and replace the useHistory attribute of the module
jest.mock('react-router-dom', () => ({
  useHistory: () => ({
    push: jest.fn()
  })
}))
afterEach(cleanup)
beforeEach(() => {
  // ...
})
describe('MainPage.jsx', () => {
  // ...
}

Подробнее о мок-функциях можно прочитать в официальном документе, а позже вы увидите больше примеров.

Что тестировать?

Создатель react-testing-library (Kent C. Dodds) создает инструмент, помогающий разработчикам избежать тестирования деталей реализации, чтобы мы не занимались исправлением тестовых случаев при попытке рефакторинга компонентов.

Это означает, что вы должны сосредоточиться на тестировании того, как компоненты взаимодействуют с пользователями. Пишите тесты так, как если бы пользователь использовал компонент, и проверяйте, правильно ли реагирует компонент. Поэтому очень важно знать, как:

1. Подражать пользователю

Мы можем использовать библиотеку @testing-library/user-event для имитации пользователя. И используйте fireEvent из библиотеки @testing-library/react для событий, которые пользовательское событие не может выполнить.

2. Запросить DOM

  • Запросы от объекта экрана

После рендеринга компонента с помощью метода рендеринга вы можете получить доступ к результату с помощью объекта экрана и выполнить с ним запрос. Подробнее об этом можно прочитать в официальной шпаргалке. (Есть удобный столик!)

  • Запрос по className

Экранный объект не предоставляет метод для запроса по className, но все же есть способ сделать это; вы можете получить корневой DOM с атрибутом контейнера, а затем вызвать метод getElementsByClassName:

import { render } from '@testing-library/react'
it('some description', () => {
  const screen = render(<Component />)
  screen.container.getElementsByClassName('<the_target_class_name>')
}

Сценарии

1. Простой компонент

  • Рендер в соответствии с реквизитом

Если вы тестируете компоненты, которые различаются только своими реквизитами, вам нужно написать тесты для отправки различных реквизитов компонентам и проверить.

Чтобы было проще, в общем случае я буду писать тестовые случаи только для:

а. Все реквизиты с правдивой стоимостью

б. Все реквизиты с ложным значением

И используйте библиотеку react-test-renderer, чтобы выполнить тест снимков для этих сценариев, чтобы сэкономить время на выполнение запросов для проверки этих деталей.

import { cleanup } from '@testing-library/react'
import renderer from 'react-test-renderer'  // renderer for snapshot test
import UserInfo from '../UserInfo'
afterEach(cleanup)
describe('UserInfo.jsx', () => {
  it('snapshot renders correctly, truthy values', () => {
    const tree = renderer
      .create(<UserInfo
          userId="202200001"
          userName="Test User"
          userImg="./test_user.img"
        />)
      .toJSON()
    expect(tree).toMatchSnapshot()
  })
  it('snapshot renders correctly, falsy values', () => {
    const tree = renderer
      .create(<UserInfo/>)
      .toJSON()
    expect(tree).toMatchSnapshot()
  })
})
  • Рендер по редукторам

Тестирование компонентов, которые меняются в зависимости от состояния редукторов, очень похоже на последний случай. Вы можете смоделировать состояние редукторов в определенное значение и визуализировать компоненты. Затем, опять же, вы можете проверить результат, выполнив несколько запросов или тестов моментальных снимков.

import { cleanup } from '@testing-library/react'
import renderer from 'react-test-renderer'
import MockProvider from '@jest/mock/MockProvider'
import createMockStore from '@jest/mock/store/createMockStore'
import UserInfoV2 from '../UserInfoV2'
afterEach(cleanup)
describe('UserInfoV2.jsx', () => {
  it('snapshot renders correctly, truthy values', () => {
    const store = createMockStore({
      userInfo: {
        userId: '202200001',
        userName: 'Test User',
        userImg: './test_user.img'
      }
    })
    const tree = renderer
      .create(<MockProvider mockStore={store}>
        <UserInfoV2 />
      </MockProvider>)
      .toJSON()
    expect(tree).toMatchSnapshot()
  })
  it('snapshot renders correctly, falsy values', () => {
    const store = createMockStore({
      userInfo: {
        userId: '',
        userName: '',
        userImg: ''
      }
    })
    const tree = renderer
      .create(<MockProvider mockStore={store}>
        <UserInfoV2 />
      </MockProvider>)
      .toJSON()
    expect(tree).toMatchSnapshot()
  })
})

2. Компонент с интервалами и обещаниями

Если есть что-то, что требует некоторого времени, чтобы дождаться завершения, вы должны попробовать запросы waitFor и findBy. Вы можете легко найти примеры в Интернете, поэтому я опущу подробности.

3. Компонент с крючками

Как упоминалось в разделе Mocking, мы можем использовать jest для имитации всех импортированных модулей. И это то же самое для крючков!

  • Параметры маршрутизации SPA

Например, предположим, что есть компонент DataPage, использующий хук useParams из библиотеки react-router-dom:

import { useParams } from 'react-router-dom'
const DataPage = (props) => {
  const { dataId = '' } = useParams()
  const data = {
    data1: 'this is the content form data 1',
    data2: 'this is the content form data 2',
  }
  return (
    <div className="data-page">
      { 
        data[dataId]
          ? <div className="data-page__content">{data[dataId]}</div>
          : <div className="data-page__not-found">content not found</div>
      }
    </div>
  )
}
export default DataPage

Вот способ смоделировать значение, которое вернет useParams:

import { screen, render, cleanup } from '@testing-library/react'
import DataPage from '../DataPage'
afterEach(cleanup)
jest.mock('react-router-dom', () => ({
  ...jest.requireActual('react-router-dom'),
  useParams: () => ({
    dataId: 'data1'
  })
}))
describe('DataPage.jsx', () => {
  it('render the content if data exist', () => {
    render(<DataPage />)
    screen.getByText('this is the content form data 1')
  })
})
  • Пользовательский хук возвращает функцию в массиве

Предполагая, что есть такой компонент:

import { useSearchData } from '@js/hooks/searchData'
const SearchPage = (props) => {
  const [search] = useSearchData() // the custom hook
  const onSearchButtonClick = () => {
    search()
  }
return (
    <div className="search-page">
      <button onClick={onSearchButtonClick}>search</button>
    </div>
  )
}
export default SearchPage

Вы можете издеваться над пользовательским хуком следующим образом:

import { screen, render, cleanup } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SearchPage from '../SearchPage'
import { useSearchData } from '@js/hooks/searchData'
afterEach(cleanup)
jest.mock('@js/hooks/searchData', () => {
  const spy = jest.fn()
  return {
    useSearchData: () => {
      return [spy]
    }
  }
})
describe('SearchPage.jsx', () => {
  it('render the content if data exist', () => {
    render(<SearchPage />)
    // initialize the imported function,
    // and get the reference of the spy
    const [search] = useSearchData()
    userEvent.click(screen.getByText('search'))
    expect(search).toHaveBeenCalled()
  })
})

4. Компонент с импортированными модулями

Давайте углубимся в случай тестирования компонента DataPage. В предыдущем примере мы можем имитировать реализацию useParams только один раз.

Если вы хотите сохранить маневренность, вы можете сначала смоделировать весь модуль и использовать метод mockImplementationOnce или mockReturnValueOnce перед рендерингом компонента. Делая это, вы можете имитировать реализацию или возвращаемое значение функции для каждого тестового примера.

Кроме того, если код в компоненте зависит от результата функции, фиктивная реализация функции позволяет избежать ошибок.

import { screen, render, cleanup } from '@testing-library/react'
import { useParams } from 'react-router-dom'
import DataPage from '../DataPage'
afterEach(cleanup)
jest.mock('react-router-dom', () => {
  const spy = jest.fn()
  return {
    ...jest.requireActual('react-router-dom'),
    useParams: spy
  }
})
describe('DataPage.jsx', () => {
  it('render the content if data exist', () => {
    useParams.mockImplementationOnce(() => ({
      dataId: 'data1'
    }))
    render(<DataPage />)
    screen.getByText('this is the content form data 1')
  })
  it('render the hint if data does not exist', () => {
    useParams.mockReturnValueOnce({
      dataId: ''
    })
    render(<DataPage />)
    screen.getByText('content not found')
  })
})

Обычно вы можете смоделировать всю библиотеку и продолжить:

jest.mock('react-router-dom')

Но если мы имитируем всю библиотеку react-router-dom, атрибут useParams по какой-то причине станет неопределенным, поэтому я использую подход из последнего примера.

5. Компоненты, которые отправляют действия

Хотя в руководстве сказано, что нам следует избегать тестирования деталей реализации, я все же считаю необходимым тестировать действия, которые отправляют компоненты. И то, что меня волнует, это:

  • Время. Срабатывает ли диспетчер в нужное время при правильном действии?
  • Параметры. Правильно ли отправлены параметры для действия?

На самом деле вы также можете проверить время и параметры этих импортированных функций (включая импортированные модули, действия и библиотеки).

Это может показаться странным примером, но я пытаюсь охватить более широкий сценарий в компоненте:

import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
  setSettings,  // a plain object action
  startGetSettings  // a redux-thunk action
} from '@js/actions'
const DemoPage = (props) => {
  const dispatch = useDispatch()
  const { title = '' } = useSelector(state => state.settings)
  useEffect(() => {
    dispatch(setSettings({
      title: 'Title set when the component mount.'
    }))
  }, [])
  const onFetchClick = () => {
    dispatch(startGetSettings({ foo: 'bar' }))
  }
  return (
    <div className="demo-page">
      <h1>{title}</h1>
      <button onClick={onFetchClick}>fetch settings</button>
    </div>
  )
}
export default DemoPage

Это довольно просто, если мы тестируем действие, которое возвращает простой объект:

it('should dispatch setSettings action when the component mount', () => {
  const mockStore = createMockStore({
    settings: {}
  })
  render(<MockProvider mockStore={mockStore}>
    <DemoPage />
  </MockProvider>)
  // the Nth element from the result of the getActions method
  // will be the object sent by Nth dispatch.
  expect(mockStore.getActions()[0].type).toBe(actions.SET_SETTINGS)
  expect(mockStore.getActions()[0].data.title).toBe('Title set when the component mount.')
  expect(mockStore.dispatch).toHaveBeenCalledTimes(1)
})

С другой стороны, будет довольно сложно управлять, если вы тестируете действие redux-thunk. Поскольку действия должны возвращать объект, это вызовет ошибку, если вы имитируете возвращаемое значение как обещание.

Поэтому я решил смоделировать эти действия в общие действия:

jest.mock('@js/actions', () => ({
  // keep the functionality of the action module
  ...jest.requireActual('@js/actions'),
  // replace the thunk-action into a plain object action,
  // to test the received parameters.
  startGetSettings: parameters => ({ type: 'MOCK', parameters })
}))

И сверить время и параметры с результатом метода getActions:

it('should dispatch startGetSettings action when user click the button', async () => {
  const mockStore = createMockStore({
    settings: {}
  })
  render(<MockProvider mockStore={mockStore}>
    <DemoPage />
  </MockProvider>)
  userEvent.click(screen.getByText('fetch settings'))
  await waitFor(() => {
    expect(mockStore.getActions()[1].parameters)
      .toEqual({ foo: 'bar' })
    expect(mockStore.dispatch).toHaveBeenCalledTimes(2)
  })
})

Важным моментом здесь является определение границ. Если вы пишете тесты для компонента, вы должны сосредоточиться на логике этого компонента. Достаточно протестировать время и параметры для этих импортированных функций. Лучше создавать зависимые тестовые файлы для проверки деталей в этих частях.

Окончание

Думаю, у каждого свой способ написания тестов, и я реализую свой именно так. Не стесняйтесь оставлять свои мысли в комментариях, чтобы мы все могли стать лучшими разработчиками 🤓

Подробнее о модульном тестировании

Рекомендации

  1. https://jestjs.io/docs/mock-функции
  2. https://kentcdodds.com/blog/testing-implementation-details
  3. https://testing-library.com/docs/ecosystem-user-event/
  4. https://testing-library.com/docs/react-testing-library/cheatsheet/
  5. https://www.codecademy.com/learn/learn-react-testing/modules/react-testing-library/cheatsheet

Больше контента на plainenglish.io. Подпишитесь на нашу бесплатную еженедельную рассылку новостей. Получите эксклюзивный доступ к возможностям написания и советам в нашем сообществе Discord.