Перейти к основному содержимому

TDD - Test Driven Development

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

Следующий этап – добавление нового функционала для тестирования или усложнение существующего. Тест проваливается. После чего пишется, код, который удовлетворит уже новый тест.

Процесс разработки с использованием этого подхода можно описать как цикл: тестирование – провал – написание рабочего кода – усложнение теста.

tdd-overview

Что тестируется

  • вёрстка - например: кнопки, инпаты, фрагменты, страницы
  • функционал - например: вызов модального окна, переход на роут
  • бизнес-логика - например: переход по пользовательскому маршруту (CJM) с соблюдением условий (conditions)

Основные виды тестов

Unit

  • тестирование изолированных модулей, например: ui, hook
  • это тестирование фундаментальных частей системы

Integration

  • тестирования группы модулей, например: fragments, pages
  • это тестирование позволяет выявить и предотвратить возможные конфликты при использовании нескольких модулей

e2e - End To End

  • тестирование процесса или сценария использования приложения, рассмотрим на примере страницы регистрации:
    • переход на страницу
    • ввод логина
    • получение реакции (валидация)
    • ввод пароля
    • получение реакции (валидация)
    • нажатие на кнопку регистрации
    • получение реакции (сообщение)
  • если первый тест пройден - проверяется следующий этап, например следующий этап регистрации - верификация

Как запускать тесты

Можно указывать название тестируемого файла, или --watch

Подготовка к написанию тестов (Unit, Integration)

Необходимые пакеты

Глобально:

  • @atls/config-jest - наш конфиг jest

Каждый пакет, локально:

  • @testing-library/react - библиотека для тестирования приложений на React
  • @emotion/jest - библиотека для тестирования UI написанного с помощью emotion

Необходимые настройки

next.config.js

module.exports = {
compiler: {
reactRemoveProperties: true
}
}

Благодаря этому в дев и билд не попадут аттрибуты data-testid, которые нам могут понадобиться для тестов сложных компонентов/фрагментов.

Как пишем тесты

Пример Unit-теста: компонент прогрессбара

tdd-unit-test

import React               from 'react'
import { FC } from 'react'
import { memo } from 'react'
import { useMemo } from 'react'

import { Box } from '@ui/layout'

import { ProgressElement } from './progress.element'
import { ProgressProps } from './progress.interfaces'

export const Progress: FC<ProgressProps> = memo(({ percent, color = 'pink' }) => {
const currentPercent = useMemo(() => {
if (percent < 0) return 0
if (percent > 100) return 100

return percent
}, [percent])

return (
<Box
position='relative'
width='100%'
height={3}
borderRadius='f2'
backgroundColor='gray'
flexShrink={0}
>
<ProgressElement
position='absolute'
top={0}
left={0}
height={3}
width={`${currentPercent}%`}
backgroundColor={color}
borderRadius='f2'
/>
</Box>
)
})

План тестирования

  1. Вёрстка - убедиться что контейнер с потомками успешно отрендерился
  2. Функционал - при передаче значения percent в пропсах - отображает нужный нам прогресс

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

// Здесь мы обозначаем среду для `jest`, в которой будет выполняться тест.
// Для фронтенда это всегда будет `jsdom`
/**
* @jest-environment jsdom
*/

import { RenderResult } from '@testing-library/react'
// Расширение для правил тестирования, чтобы можно было задействовать `toHaveStyleRule`
import { matchers } from '@emotion/jest'
import { render } from '@testing-library/react'

import React from 'react'

// Берем провайдер темы и объект темы с локального пакета - для правильной работы `styled-components`
// необходимо обязательно рендерить тему
import { ThemeProvider } from '@ui/theme'
import { theme } from '@ui/theme'

// Сам тестируемый компонент
import { Progress } from './progress.component'

// Расширяем мэтчеры тестирования с помощью `@emotion/jest`
expect.extend(matchers)

type CustomRender = (element: React.ReactNode | React.ReactNode[]) => RenderResult

// Создаем кастомный рендер - чтобы не повторятся в наших тестах с рендером темы
const customRender: CustomRender = (element) => render(<ThemeProvider>{element}</ThemeProvider>)

// С `describe` начинается группа тестирования. С его помощью обозначаются группы тестов,
// которые можно логически объединить в одну область тестирования. Можно обходиться и без этого,
// но тогда тесты будут разрозненны и тяжело читаемы в результате тестов.
describe('Progress component', () => {

// Это уже наш тест. Описываем что тестируем и передаем фукнцию с тестом.
// К `it` можно добавлять "модификаторы". Напр.:
// - `it.only` - из всех тестов в файле будет выполнятся только этот
// - `it.skip` - этот тест будет пропускаться
it('renders correct percent width', () => {
const testPercent = 50

// Рендерим наш компонент.
const { container } = customRender(<Progress percent={testPercent} />)

// Т.к. наш компонент не имеет каких либо кнопок, инпутов или текста, то
// поиск его в дереве происходит стандартным `querySelector`.
const progressBar = container.querySelector('div > div > div')

// Проверка что такой элемент в дереве нашелся
expect(progressBar).toBeTruthy()

// Проверка что у элемента есть правило ширины и оно соответствует значению выше.
expect(progressBar).toHaveStyleRule('width', `${testPercent}%`)
})

it('applies the correct color', () => {
const testColor = 'pink'
const { container } = customRender(<Progress percent={50} color={testColor} />)

const progressBar = container.querySelector('div > div > div')

// Проверяем что наш компонент при заданных пропсах имеет в стилях значение из темы.
expect(progressBar).toHaveStyleRule('background-color', theme.colors[testColor])
})
})

Пример integration-теста: компонент тултип

Компонент рендерится при клике по элементу, к которому прикреплен:

tdd-integration-test

План тестирования

  1. Верстка
  2. Функционал - рендер по триггеру (клик, ховер)
/**
* @jest-environment jsdom
*/

import { RenderResult } from '@testing-library/react'
import { fireEvent } from '@testing-library/react'
import { act } from '@testing-library/react'
import { render } from '@testing-library/react'

import React from 'react'

import { ThemeProvider } from '@ui/theme'

import { Tooltip } from './tooltip.component'

type CustomRender = (element: React.ReactNode | React.ReactNode[]) => RenderResult

// Наш тултип зависит от размеров экрана, т.е. работает с `ResizeObserver`.
// Мы рендерим наши компоненты с помощью `jest`, а значит не в браузере.
// Поэтому приходится "мокать" этот функционал браузера.
// "Мокать" == заменять что-то реальное "фейком"
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}))

const customRender: CustomRender = (element) => render(<ThemeProvider>{element}</ThemeProvider>)

describe('Tooltip component', () => {
it('renders with text', async () => {
const { findByText, getByText } = customRender(
<Tooltip trigger='hover' container={<span>Tooltip text</span>}>
<span>Click me</span>
</Tooltip>
)

const triggerSpan = getByText('Click me')

expect(triggerSpan).toBeTruthy()

// Здесь мы запускаем эвент по клику на элемент с текстом.
// Таким образом триггерим рендер тултипа.
act(() => fireEvent.click(triggerSpan))

const tooltipText = await findByText('Tooltip text')

expect(tooltipText).toBeTruthy()
})
})

Как искать нужные компоненты при рендере в jest

Мы должны писать тесты так, будто на страницу пришёл пользователь и проводит действия. Когда мы оказываемся на любой странице, за что наш глаз цепляется? Это текст, кнопки, инпуты, формы... Все что "осязаемо" для глаза.

Вот как тестировать форму с точки зрения пользователя:

  • попал на страницу
  • увидел форму
  • увидел инпут
  • заполнил инпут
  • увидел еще инпут
  • заполнил инпут
  • ...
  • увидел кнопку для сабмита
  • нажал кнопку

Для симуляции поведения пользователя рекомендуется искать элементы так (оригинал). Нумерация в порядке приоритета:

  1. Запросы, доступные для всех: Запросы, которые отражают опыт использования визуальными/мышевыми пользователями, а также теми, кто использует вспомогательные технологии.
  • getByRole: Этот метод можно использовать для запроса любого элемента, который представлен в дереве доступности. С опцией name вы можете фильтровать возвращаемые элементы по их доступному имени. Это должно быть вашим главным приоритетом почти во всём. Нет многого, что вы не можете получить с его помощью (если не можете, возможно, ваш UI недоступен). Чаще всего это будет использоваться с опцией name следующим образом: getByRole('button', {name: /submit/i}). Проверьте список ролей.
  • getByLabelText: Этот метод действительно хорош для полей форм. Перемещаясь по форме веб-сайта, пользователи находят элементы, используя текст метки. Этот метод эмулирует это поведение, поэтому он должен быть вашим главным приоритетом.
  • getByPlaceholderText: Заполнитель не является заменой метки. Но если это всё, что у вас есть, то это лучше, чем альтернативы.
  • getByText: Вне форм текстовое содержимое является основным способом, с помощью которого пользователи находят элементы. Этот метод можно использовать для поиска неинтерактивных элементов (таких как div, span и параграфы).
  • getByDisplayValue: Текущее значение элемента формы может быть полезно при навигации по странице с заполненными значениями.
  1. Семантические запросы: Селекторы, соответствующие HTML5 и ARIA. Обратите внимание, что пользовательский опыт взаимодействия с этими атрибутами сильно различается в разных браузерах и вспомогательных технологиях.
  • getByAltText: Если ваш элемент поддерживает альтернативный текст (img, area, input и любой пользовательский элемент), то вы можете использовать это для поиска такого элемента.
  • getByTitle: Атрибут title не последовательно читается скринридерами и не виден по умолчанию для пользователей с видением.
  1. Тестовые идентификаторы
  • getByTestId: Пользователь не может видеть (или слышать) эти, поэтому это рекомендуется только в случаях, когда вы не можете сопоставить по роли или тексту, или это не имеет смысла (например, текст динамичен).
Шпаргалка запросов

tdd-query-list

Поиск по тексту

Многие методы дают доступ к поиску по тексту (текст, плейсхолдер, вэлью в инпутах...) либо уточняющий поиск (...ByRole(..., { name: '...' })). В этих методах нельзя искать по локали/переводу. Допустимые методы поиска:

Отдельно про data-testid

Этим пользуемся в самом крайнем случае. Правила по его неймингу:

...data-testid='component-subcomponent-subsubcomponent-...-id'

Например, для свитча, который:

  • имеет в детях нужный нам <span>...</span>
  • которого может быть много на одном фрагменте/странице и нужно обратиться к конкретному
<Switch testId={1} />
<span data-testid='switch-span-{props.testId}'>...</span>

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

Примеры тестов

https://gist.github.com/Nelfimov/ba3768ecf31f804b7f9378807be4c2fa - data-testid https://gist.github.com/Nelfimov/ade5263ea89d82a240368fe7aa8c09ba - хук https://gist.github.com/Nelfimov/aa79af1bb2cff9af4e20963f4ccb7cea - фрагмент с клик эвентами https://gist.github.com/Nelfimov/b7700fa608ca696dc6c9f708e624b1dd - компонент с копированием в clipboard по клику

E2E (End-to-end)

будет дополняться