Мокинг компонентов React (Jest mocking + React, часть 2)

В первой части этой серии мы говорили о том, что такое мокинг, зачем нужен мокинг и как его настроить.

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

  1. Зачем нужен мокинг для тестирования React
  2. Как издеваться над компонентами React
  3. Издевательство над компонентами, имеющими реквизиты

Примеры, которые я использую в этой статье, доступны на github (src/part2). Эти файлы собираются на основе create-react-app, поэтому вы можете запустить их с помощью npm run start или запустить тесты с помощью npm run test.

1. Зачем нужен мокинг для тестирования React

Компоненты React всегда связаны/вложены в другие компоненты или пакеты. Но, тестируя компонент, вы хотите тестировать только этот компонент. Поэтому вам нужно как-то изолировать его от остальных. Именно здесь в игру вступает Jest mocking. Вот пример:

// part2/example1/ChildComponent.js
function ChildComponent(){
  return(
    <div className="ChildComponent">
      Child component
    </div>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима
// part2/example1/ParentComponent.js
function ParentComponent(){
  return(
    <div className="ParentComponent">
      <div>Parent Component</div>
      <ChildComponent />
    </div>
  )
}
Войти в полноэкранный режим Выход из полноэкранного режима

Наша цель — протестировать ParentComponent. Без мокинга это выглядело бы следующим образом:

// part2/example1/__tests__/test1.js
import { screen, render } from '@testing-library/react'
import ParentComponent from '../ParentComponent'

test('ParentComponent renders', () => {
  render(<ParentComponent />)
  expect(screen.getByText(/Parent Component/i)).toBeInTheDocument()
})
Вход в полноэкранный режим Выход из полноэкранного режима

Как и ожидалось, этот тест отображает и родительский, и дочерний компоненты. Мы можем проверить это, добавив screen.debug() (выводит вывод в консоль) или добавив еще один оператор expect.

// part2/example1/__tests__/test1.js
import { screen, render } from '@testing-library/react'
import ParentComponent from '../ParentComponent'

test('ParentComponent and ChildComponent render', () => {
  render(<ParentComponent />)
  screen.debug()
  expect(screen.getByText(/Parent Component/i)).toBeInTheDocument()
  expect(screen.getByText(/Child Component/i)).toBeInTheDocument()
})
Вход в полноэкранный режим Выход из полноэкранного режима

Это нормальное поведение реакции, но не то, что мы хотим при тестировании! Допустим, кто-то другой еще не написал этот ChildComponent, и вы ничего об этом не знаете. Может быть, дочерний компонент также содержит текст «Родительский компонент»? Это сделает ваш тест неудачным. (getByText возвращает ошибку при наличии более 1 совпадения)

Это был упрощенный пример, но он ясно показывает суть проблемы. При тестировании компонента мы не хотим вмешательства других компонентов или модулей. Однако, компоненты React всегда связаны друг с другом. Как же нам изолировать компонент? С помощью Jest mock.


2. Как издеваться над компонентами React

Давайте перепишем тест. Мы добавляем одно правило jest.mock('../ChildComponent') и обновляем оператор expect для дочернего компонента.

// part2/example1/__tests__/test2.js
import { screen, render } from '@testing-library/react'
import ParentComponent from '../ParentComponent'

jest.mock('../ChildComponent')

test('ParentComponent renders and ChildComponent does not', () => {
  render(<ParentComponent />)
  expect(screen.getByText(/Parent Component/i)).toBeInTheDocument()
  // notice the .not
  expect(screen.queryByText(/Child Component/i)).not.toBeInTheDocument()
})
Вход в полноэкранный режим Выход из полноэкранного режима

2.1 Автоматическое моделирование с помощью jest.mock()

Теперь давайте разберем этот новый тест. jest.mock() принимает в качестве первого параметра путь к файлу, в котором находится модуль. Если второй параметр отсутствует, Jest выполняет автоматический mock. Это означает, что он создает макет импортируемого модуля (модулей).

Для этой строки: jest.mock('../ChildComponent'), Jest посмотрит на любой импорт, который мы сделаем из этого файла, и заменит его на jest.fn().

Ребенок был сымитирован. Это означает, что реальный компонент был заменен пустым макетом. Как мы видели в первой части этой серии, функция mock ничего не возвращает (если только мы не скажем ей об этом). Это означает, что содержимое дочернего компонента больше не должно находиться в документе, что мы и проверили:

// previous test
expect(screen.getByText(/Child Component/i)).toBeInTheDocument()
// last test: 
// notice the queryByText and the .not method
expect(screen.queryByText(/Child Component/i)).not.toBeInTheDocument()
Войти в полноэкранный режим Выход из полноэкранного режима

Мы успешно сымитировали ChildComponent.

В качестве примечания: мы вызвали jest.mock(). Jest mocking запутан, потому что в нем термины mock и mocking используются для обозначения разных вещей. jest.fn() создает насмешливую функцию. Затем есть свойство .mock у насмешливой функции, которое дает вам доступ к «логам». (например, mockedFunction.mock.calls[0][0]). И только что мы использовали метод .mock на глобальном объекте jest. Извините за эту путаницу, но именно так это делает Jest.

2.2 Тестирование имитатора

Но как же нам получить доступ к макету? Насмешливая функция — jest.fn() — похожа на «журнал». Но как нам получить доступ к этому «журналу»?

// part2/example1/__tests__/test3.js
import { render } from '@testing-library/react'
import ParentComponent from '../ParentComponent'
import ChildComponent from '../ChildComponent'

jest.mock('../ChildComponent')

test('ChildComponent mock can be tested', () => {
  render(<ParentComponent />)
  // using .mock property
  expect(ChildComponent.mock.calls).toHaveLength(1)
  // using jest helpers
  expect(ChildComponent).toHaveBeenCalled()
})
Вход в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что теперь мы импортировали ChildComponent в наш тест. Когда мы запустим тест, jest.mock сделает свое дело с автоматической блокировкой, и мы сможем получить доступ к моделирующей функции (jest.fn()), просто вызвав ChildComponent, как мы делали в последних двух операторах expect:

// using .mock property
expect(ChildComponent.mock.calls).toHaveLength(1)
// using  jest helpers
expect(ChildComponent).toHaveBeenCalled()
Вход в полноэкранный режим Выйти из полноэкранного режима

ChildComponent теперь ведет себя именно так, как мы ожидаем от jest.fn(). Мы можем проверить его свойство .mock («логи») или вызвать вспомогательные методы Jest, например .toHaveBeenCalled().

2.3 expect(element) vs expect(function)

Будьте внимательны к тому, какие функции matcher вы используете при мокинге. Например, matcher .toBeInTheDocument() работает только с элементами dom. Макет — это функция. Вы можете вызывать матчеры, ожидающие функцию, например .toHaveBeenCalled(), только на mocking-функции.

2.4 Заключительный тест

Краткое резюме. Чтобы высмеять модуль React, мы используем jest.mock(path). Это так называемый автоматический mock. Он автоматически передразнивает компонент. Вы можете получить доступ к этому макету, просто вызвав его имя после того, как вы его импортировали.

Это окончательный тест для нашего ParentComponent:

// part2/example1/__tests__/test4.js
import { screen, render } from '@testing-library/react'
import ParentComponent from '../ParentComponent'
import ChildComponent from '../ChildComponent'

jest.mock('../ChildComponent')

test('ParentComponent rendered', () => {
  render(<ParentComponent />)
  expect(screen.getByText(/Parent Component/i)).toBeInTheDocument()
})

test('ChildComponent mock was called', () => {
  render(<ParentComponent />)
  expect(ChildComponent).toHaveBeenCalled()
})
Вход в полноэкранный режим Выход из полноэкранного режима

Если вам интересно, почему мы тестируем макет: родительский компонент рендерит дочерний. Несмотря на то, что мы не хотим отображать дочерний компонент, потому что хотим протестировать родительский компонент в изоляции, мы все равно ожидаем, что дочерний компонент будет там. Вот почему мы имитируем или передразниваем ребенка.


3. Издевательство над компонентами, имеющими реквизиты

До сих пор мы работали с простыми примерами компонентов. Что произойдет, если мы добавим реквизит к нашему дочернему компоненту?

// part2/example2/ChildComponent.js
function ChildComponent(props){
  return(
    <div className="ChildComponent">
      Child component says {props.message}
    </div>
  )
}
export default ChildComponent
Вход в полноэкранный режим Выход из полноэкранного режима
// part2/example2/ParentComponent.js
import ChildComponent from "./ChildComponent"
function ParentComponent(){
  return(
    <div className="ParentComponent">
      <div>Parent Component</div>
      <ChildComponent message="Hello" />
    </div>
  )
}
export default ParentComponent
Вход в полноэкранный режим Выход из полноэкранного режима

В родительском компоненте мы вызываем ChildComponent с сообщением prop, после чего дочерний компонент возвращается: «Child component says hello».

И снова мы хотим протестировать родительский компонент, одновременно подражая дочернему. На этот раз мы хотим проверить, вызывается ли подражаемый дочерний компонент с правильным реквизитом. Но с чем вызывается дочерний компонент? В React вы можете вызывать компоненты как функции. В этих двух случаях результат будет точно таким же:

<MyComponent prop1="foo" prop2="bar" />
// equals
{MyComponent({ prop1: "foo", prop2: "bar" })}
Вход в полноэкранный режим Выход из полноэкранного режима

Из этого также становится понятно, с чем вызывается компонент: объект со всеми реквизитами. Итак, мы ожидаем, что макет ChildComponent будет вызван с { message: 'hello' }.

3.1 .toHaveBeenCalledWith() на макетах js-функций

Мы видели, как .toHaveBeenCalledWith() работает на javascript-функции в первой части этой серии, но я быстро напомню об этом:

function doAThing(callback){
  callback('foo')
}
Вход в полноэкранный режим Выход из полноэкранного режима

У нас есть простая функция. Она принимает в качестве аргумента функцию обратного вызова и вызывает этот обратный вызов с аргументом ‘foo’. В тесте мы бы высмеяли callback, а затем проверили, вызывается ли эта высмеиваемая функция с правильным аргументом.

test('MockCallBack gets called with the correct argument', () => {
  const mockCallback = jest.fn()
  doSomething(mockCallback)
  expect(mockCallback).toHaveBeenCalledWith('foo')
})
Вход в полноэкранный режим Выход из полноэкранного режима

3.2 .toHaveBeenCalledWith() на макетах компонентов React

Теперь давайте используем .toHaveBeenCalledWith() для тестирования нашего макета ChildComponent. Мы ожидаем, что он будет вызван с: { message: 'Hello' }.

// part2/example2/__tests__/test1.js
import { render } from '@testing-library/react'
import ParentComponent from '../ParentComponent'
import ChildComponent from '../ChildComponent'

jest.mock('../ChildComponent')

// fails
test('The mocked ChildComponent gets called with the correct props', () => {
  render(<ParentComponent />)
  expect(ChildComponent).toHaveBeenCalledWith(
    { message: 'Hello' }
  )
})
Вход в полноэкранный режим Выйти из полноэкранного режима

Но этот тест не работает. Причина этого немного неясна. Когда вызывается компонент React, он получает два аргумента: объект с реквизитами и ссылку. Я сам не до конца понимаю, что такое ref, но просто знаю это:

  1. Значение ref обычно пустое ({}).
  2. Вам это нужно, иначе ваш тест провалится.

Итак, давайте обновим тест:

// part2/example2/__tests__/test1.js
import { render } from '@testing-library/react'
import ParentComponent from '../ParentComponent'
import ChildComponent from '../ChildComponent'

jest.mock('../ChildComponent')

// passes
test('The mocked ChildComponent gets called with the { message: "Hello" } and {}', () => {
  render(<ParentComponent />)
  expect(ChildComponent).toHaveBeenCalledWith(
    { message: 'Hello' }
    {}
  )
})
Войти в полноэкранный режим Выйти из полноэкранного режима

Позвольте мне быстро напомнить. Мы тестируем макет компонента React: <ChildComponent message="Hello" />. Мы хотим проверить, был ли этот макет вызван с определенным реквизитом. Но когда вызывается компонент React, он фактически получает два аргумента: Объект с его реквизитами и второе значение ref. Это второе значение обычно пустое.

Матчер .toHaveBeenCalledWith() получает от mock две вещи: объект props и значение ref. Теперь мы передаем в матчер то, что ожидаем найти: объект со свойствами (для соответствия реквизиту) и пустой объект ´{}´ для соответствия рефлексу.

Этот тест работает, но он не оптимален. Что, если у этой ссылки есть значение, например. Для борьбы с этим мы заменим {} следующей строкой: expect.anything().

.anything() — это Jest matcher, который принимает за все, что ожидает undefined или null. Таким образом, это идеальный кандидат для замены {} или фактического значения ref. Обновим тест:

  expect(ChildComponent).toHaveBeenCalledWith(
    { message: 'Hello' },
    expect.anything()
  )
Войти в полноэкранный режим Выход из полноэкранного режима

Второе улучшение мы можем сделать в строке { message: 'Hello'}}. Здесь выполняется точное совпадение. Полученный объект должен точно соответствовать ожидаемому объекту, который мы передаем. Но это не идеальный вариант. Допустим, по какой-то причине мы хотим проверить только одно свойство. Как бы мы это сделали?

  expect(ChildComponent).toHaveBeenCalledWith(
    expect.objectContaining({ 
      message: 'Hello',
    }),
    expect.anything()
  )
Войти в полноэкранный режим Выйти из полноэкранного режима

expect.objectContaining() — это еще один метод Jest matcher. Он сравнивает полученный объект с ожидаемым объектом. Он требует, чтобы каждое свойство и значение ожидаемого объекта присутствовало в полученном объекте. Но он не работает в обратном направлении.

// pass
received: { prop1: true, prop2: true }
expected: { prop1: true }

// fail
received: { prop1: true }
expected: { prop1: true, prop2: true }
Вход в полноэкранный режим Выход из полноэкранного режима

Таким образом, использование expect.objectContaining() делает наш тест более гибким. Он позволяет нам выбирать, какие свойства мы будем тестировать. Вот полный обновленный тест:

// part2/example2/__tests__/test1.js
test('The mocked ChildComponent gets called with the correct props', () => {
  render(<ParentComponent />)
  expect(ChildComponent).toHaveBeenCalledWith(
    expect.objectContaining({
      message: 'Hello',
    }),
    expect.anything()
  )
})
Войти в полноэкранный режим Выход из полноэкранного режима

Мы пройдемся по тесту еще раз. Мы проверяем, был ли вызван макет дочернего компонента с правильными реквизитами, поэтому мы используем метод .toHaveBeenCalled() на макете ChildComponent. Мы получаем два значения: объект с реквизитом и значение ref. Мы сопоставляем объект props с expect.objectContaining, потому что это дает нам гибкость в выборе реквизитов для тестирования. Для второго значения, ref, мы используем expect.anything(). Мы должны соответствовать второму значению, но нас это не волнует. expect.anything() — идеальное решение.

Резюме

В этой статье мы рассмотрели, зачем и как устанавливать (автоматический) имитатор. Затем мы рассмотрели, как тестировать эти имитаторы и как использовать .toHaveBeenCalledWith().

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

Оцените статью
devanswers.ru
Добавить комментарий