В первой части этой серии мы говорили о том, что такое мокинг, зачем нужен мокинг и как его настроить.
В этой части мы будем применять то, что узнали в первой части, для тестирования компонентов React
. Мы начнем с иллюстрации того, зачем нужен мокинг, и продолжим с реального мокинга некоторых компонентов.
- Зачем нужен мокинг для тестирования React
- Как издеваться над компонентами React
- Издевательство над компонентами, имеющими реквизиты
Примеры, которые я использую в этой статье, доступны на github (src/part2). Эти файлы собираются на основе create-react-app, поэтому вы можете запустить их с помощью npm run start
или запустить тесты с помощью npm run test
.
- 1. Зачем нужен мокинг для тестирования React
- 2. Как издеваться над компонентами React
- 2.1 Автоматическое моделирование с помощью jest.mock()
- 2.2 Тестирование имитатора
- 2.3 expect(element) vs expect(function)
- 2.4 Заключительный тест
- 3. Издевательство над компонентами, имеющими реквизиты
- 3.1 .toHaveBeenCalledWith() на макетах js-функций
- 3.2 .toHaveBeenCalledWith() на макетах компонентов React
- Резюме
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, но просто знаю это:
- Значение ref обычно пустое (
{}
). - Вам это нужно, иначе ваш тест провалится.
Итак, давайте обновим тест:
// 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()
.
В следующей части этой серии мы покажем, почему и как мы возвращаем значения из макетов.