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

Если вы хотите увидеть полноценный пример работы, взгляните на демонстрационный шаблон.


Имитация

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

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

Я помещаю файлы, созданные в этом разделе, в каталог 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'
},

Имитация Redux

1. Создайте копию реального хранилища (store).

В некоторых случаях нужна только среда, которая может отрисовать компонент с состоянием по умолчанию. Это можно сделать, скопировав реальный файл истории и удалив ненужные конфигурации (например, для 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. Сымитируйте компонент Provider.

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

// jest/mock/MockProvider.jsx

import { Provider } from 'react-redux'
// используйте копию 1 в качестве хранилища по умолчанию
import store from './store'

const MockProvider = ({ children = null, mockStore = null }) => {
  return (
    <Provider store={mockStore || store}>
      { children }
    </Provider>
  )
}

MockProvider.propTypes = {
  mockStore: PropTypes.object,  // этот пропс позволяет нам установить store в определенное состояние
  children: PropTypes.node
}

export default MockProvider

Нам также нужно создать модуль для различных состояний store. Этот модуль будет использовать библиотеку redux-mock-store для создания имитированного store. Он также заменит атрибут dispatch имитированного store на имитированную функцию Jest, чтобы при необходимости можно было отслеживать состояние. Начальное состояние store можно установить с помощью специального параметра. 

// 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
  // замените метод dispatch "шпионом" и поддерживайте  функциональность
  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  // пропс для отправки компонентов, которые вы хотите отобразить
}

export default MockRouter

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

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

import { render, cleanup } from '@testing-library/react'
import MainPage from '../MainPage'

// *** это должно быть записано в глобальной области видимости ***
// сымитируйте библиотеку react-router-dom
// и замените атрибут useHistory модуля
jest.mock('react-router-dom', () => ({
  useHistory: () => ({
    push: jest.fn()
  })
}))

afterEach(cleanup)
beforeEach(() => {
  // ...
})describe('MainPage.jsx', () => {
  // ...
}

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

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

Автор react-testing-library  —  Kent C. Dodds  —  создал этот инструмент, чтобы помочь разработчикам избежать тестирования деталей реализации. Поэтому нам не нужно исправлять тестовые примеры при рефакторинге компонентов.

Telegram-канал по теме
NOP::Nuances of ProgrammingСтатьи и переводы для разработчиков — каждый день
Подписаться

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

1. Имитация пользователя

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

2. Выполнение DOM-запросов

  • Запросы из объекта screen

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

  • Запрос по className

Объект screen не предоставляет метод для запроса по className, но все же есть способ сделать это. Вы можете получить корневой DOM с помощью атрибута container, а затем вызвать метод 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 для выполнения snapshot-теста для этих сценариев: так вы сэкономите время, которые потратили бы на выполнение запросов.

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()
  })
})
  • Рендеринг в соответствии с редьюсерами

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

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. Компонент с хуками

Как уже упоминалось в разделе “Имитация”, мы можем использовать 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() // пользовательский хук
  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 />)
    // инициализируйте импортированную функцию
    // и получите ссылку на "шпиона"
    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>)
// N-й элемент из результата метода getActions
// будет объект, отправленный N-м диспетчером.
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', () => ({
// сохраняем функциональность модуля действий
...jest.requireActual('@js/actions'),
// замените thunk-action на обычное объектное действие
// для проверки полученных параметров.
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)
})
})

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

Читайте также:


Перевод статьи tabsteveyang: How to Test React Components

Мы в Telegram — подписывайтесь
NOP::Nuances of ProgrammingСтатьи и переводы для разработчиков — каждый день
Подписаться
NOP::HumorИТ-юмор и мемы о боли разработчиков
Подписаться
NOP::Recruiter RemoteУдалённые ИТ-вакансии с вилками
Подписаться
Предыдущая статьяЯзык С: типы данных
Следующая статья8 причин использовать Pydantic для улучшения парсинга и валидации данных