Привет, Хабр! Меня зовут Андрей Хижняк, я фронтенд-разработчик в команде, разрабатывающей App Store внутри ManyChat.

Как и моим коллегам, мне нравится пробовать новые подходы, методологии и практики, заниматься повышением качества и скорости разработки. В начале этого года мы с командой решили попробовать одну из техник экстремального программирования — TDD.

От том, что из этого вышло, и будет моя статья, добро пожаловать под кат!



Итак, начнем с определений


  • Test-Driven Development (TDD) — одна из техник экстремального программирования, основанная на 3-х шаговом цикле разработки:
    1. Пишем тест на функциональность, которую собираемся добавить.
    2. Пишем код, чтобы тест прошел.
    3. Делаем рефакторинг теста и кода, если нужно.
  • Jest — восхитительный (как они сами себя называют) JavaScript framework для тестирования с акцентом на простоту.
  • React — думаю, в представлении не нуждается.

Зачем нужен TDD


Многие разработчики до сих пор сомневаются в практический пользе TDD. Мне, однако, кажутся убедительными исследования с тремя группами разработчиков из Microsoft и одной из IBM, которые внедрили TDD. Результаты этих исследований показали, что количество pre-release bugs всех четырех продуктов снизилось на 40–90% по сравнению с аналогичными продуктами, в которых практика TDD не использовалась.

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

  1. Высокий порог вхождения — начинающим разработчикам будет труднее понять такой подход к разработке.
  2. Перманентная дисциплина — нельзя писать код раньше тестов.
  3. Снижение скорости разработки, большие ресурсные инвестиции на старте.
  4. Непонимание самой техники, неправильное применение.

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

А теперь перейдем к плюсам:

  1. Качество — тестопригодный код, заранее покрытый тестами.
  2. Архитектура — TDD поощряет модульность (не поощряет связанность).
  3. Масштабируемость — модульный код покрытый тестами легко развивать и рефакторить.
  4. Устойчивость — коллеги ничего вам незаметно не сломают.
  5. Комфорт разработки — сначала пишется весь код, и только потом запускается проект для финального ручного тестирования и верстки.
  6. Ваш код делает ровно то, чего вы ожидаете от него в тестах.

Почему мы выбрали TDD, а не просто Unit-тесты


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

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

Чтобы увидеть преимущества TDD (test first) методологии перед обычными unit-тестами (test last), достаточно пошагово сравнить два этих подхода.



Первым шагом продумываем то, что собираемся написать


При test last подходе не нужно много думать — можно сразу переходить к написанию кода. Однако разработка через тесты устроена совсем иным образом. Процесс мышления отличается и требует серьезной дисциплины и навыков. В зависимости от своего уровня можно выбрать один из следующих способов начать:

  1. Написать код и закомментировать его. Будьте готовы выбросить или переписать его после написания теста (уровень Junior).
  2. Представить код, который собираетесь написать, но не писать его (уровень Middle).
  3. Представить примерную архитектуру того, к чему собираетесь прикоснуться: как сущности и интерфейсы будут взаимодействовать (уровень Senior).

В начале будут возникать объективные трудности, снизится скорость разработки — это нормально и, более того, неизбежно. Но опыт показывает, что со временем будет и повышаться уровень, и увеличиваться скорость в разработке через тесты. Нам понадобилось два месяца, чтобы вернуться на исходную скорость, но уже с TDD.

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

Вторым шагом пишем Unit-тест


Когда не нужно изучать код, который мы собираемся покрыть, писать тест очевидно проще. Нет кода — нет головной боли. Просто пишем unit-тест так, как хотим.

При test last подходе придется не только потратить значительное время на изучение существующего кода, даже если мы написали его недавно, но и довольно часто его исправлять, чтобы он был пригодным для тестирования. Когда мы пишем код, не всегда удается держать в голове предстоящее написание unit-тестов. Вопрос мотивации также не обойти стороной, ведь когда код написан, еще нужно побороться с прокрастинацией и не заскучать до смерти, чтобы писать unit-тесты для уже работающего кода.

Третьим шагом пишем код


С TDD подходом нужно просто «позеленить» тест и не придумывать ничего лишнего. В то же время при test last подходе, вполне вероятно, мы напишем больше кода, чем нужно, тем самым добавив потенциально мертвый код (который, возможно, никогда нам не понадобится). Кроме того, придется покрыть его unit-тестами, тем самым увеличив расходы на тестирование.

Четвертым шагом убеждаемся, что Unit-тест проходит


Оба подхода требуют примерно одинаковых усилий для запуска unit-тестов в целях проверки. Однако при test last нужно будет постоянно подстраивать unit-тесты так, чтобы они наконец прошли. Ведь изменение рабочего кода — это последнее, чего мы хотим на этом этапе. Если мы все-таки изменим рабочий код, то нужно будет убедиться, что он все еще работает, а это может быть очень затратно. В то время как с TDD тест уже написан, и мы просто ожидаем, что он пройдет.

Пятым шагом запускаем приложение для финальной проверки


Здесь мы получаем еще одно значительное преимущество TDD. С ним необходимо запускать приложение только 1-2 раза и в большинстве случаев оно будет работать так, как мы ожидаем, потому что unit-тесты уже проверили код. Остается только поправить верстку и провести необходимое ручное тестирование.

С другой стороны, при test last нужно запускать приложение множество раз при написании кода. Либо можно написать слишком много кода, а затем отлаживать и исправлять каждый шаг, либо писать небольшими частями и каждый раз их запускать. В любом случае выполнение, отладка и исправление выполняются намного дольше, чем просто запуск готовых unit-тестов, написанных через TDD.

И победителем становится...


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

TDD на практике


От «it('renders component', () ? {...})» до финального решения на примере модального окна с логикой


В этом примере я сознательно откажусь от использования Redux и кастомных решений для react-test-renderer и Jest, чтобы снизить порог вхождения. Однако если эти темы вам интересны, сообщите об этом в комментариях, и мы напишем отдельную статью.

Все тесты мы пишем с использованием react-test-renderer, а не testing-library, чтобы избежать затраты на зависимость от реального DOM. Это особенно важно, когда число unit-тестов в проекте уже превышает 4000.

Задача


Реализовать модальное окно публикации приложения.

Критерии приемки


  1. Как пользователь, я хочу видеть модальное окно с названием приложения в заголовке.
  2. Как пользователь, я хочу отправить приложение на публикацию.
  3. Как пользователь, я хочу чтобы модальное окно закрылось после отправки на публикацию.
  4. Как пользователь, я хочу отправить public приложение на публикацию повторно.

Реализация


Вместе с переходом на TDD мы также перешли на TypeScript, поэтому все примеры будут показаны именно на нем.

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

1.2
Первый тест будет проверять, что модальное окно (из нашего UI Kit'a) отрендерилось с заданной шириной:

// ApplicationPublishModal.test.tsx

import React from 'react'
import { create } from 'react-test-renderer'
import * as ManyUI from '@manychat/manyui'
// наш компонент - пока пустой
import ApplicationPublishModal from '.'

// нас не интересует реализация самого модального окна, поэтому делаем mock
ManyUI.Modal = jest.fn().mockImplementation(() => null)

describe('ApplicationPublishModal', () => {
  it('renders Modal with props open and width', () => {
    // arrange
    // достаточен mock модалки

    // act
    const renderer = create(<ApplicationPublishModal />)

    // assert
    const element = renderer.root.findByType(ManyUI.Modal)
    expect(element.props.open).toBe(true)
    expect(element.props.width).toBe(480)
  })
})

1.3

Код минимально необходимый для того, чтобы «позеленить» наш тест:

// ApplicationPublishModal.tsx

import React from 'react'
import { Modal } from '@manychat/manyui'

const ApplicationPublishModal: React.FC = () => {
  return <Modal open width={480} />
}

export default ApplicationPublishModal

1.4

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

2.1
Второй итерацией добавим в модальное окно заголовок, сформированный из названия приложения, полученного компонентом из props.

2.2

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

it('renders Modal with the correct title', () => {
  const renderer = create(<ApplicationPublishModal appName={'App Name'} />)

  const element = renderer.root.findByType(ManyUI.Modal)
  expect(element.props.title).toBe('Publish App Name app')
})

2.3

import React from 'react'
import { Modal } from '@manychat/manyui'

const ApplicationPublishModal: React.FC<{ appName: string }> = ({ appName }) => {
	const title = `Publish ${appName} app`

  return <Modal open width={480} title={title} />
}

export default ApplicationPublishModal

2.4

Убеждаемся, что тест прошел, и переходим к третьей итерации.

3.1
Теперь мы добавим кнопку, которая при клике будет вызывать метод onPublish, полученный компонентом из props, и закроет модальное окно.

3.2

Наш тест в таком случае должен проверять, что при клике по кнопке Publish вызывается метод onPublish и закрывается модальное окно:

// события, действия, меняющие состояние компонента, необходимо оборачивать в act
import { create, act } from 'react-test-renderer'

it('calls method onPublish and closes the modal when button Publish is clicked', () => {
  const onPublishSpy = jest.fn()
  const renderer = create(<ApplicationPublishModal appName={'App Name'} onPublish={onPublishSpy} />)
  const element = renderer.root.findByType(ManyUI.Modal)
	
	// оборачиваем в act вызов события onClick, меняющий состояние компонента 
  act(() => {
    element.props.buttons
      .find((button: { label: string }) => button.label === 'Publish')
      .onClick()
  })

  expect(onPublishSpy).toHaveBeenCalledTimes(1)
  expect(element.props.open).toBe(false)
})

3.3

import React, { useCallback, useState } from 'react'
import { Modal } from '@manychat/manyui'

const ApplicationPublishModal: React.FC<{ appName: string; onPublish: () => void }> = ({
  appName,
  onPublish,
}) => {
  const [open, setOpen] = useState(true)

  const title = `Publish ${appName} app`

  const handlePublish = useCallback(() => {
    onPublish()
    setOpen(false)
  }, [onPublish])

  return (
    <Modal
      open={open}
      width={480}
      title={title}
      buttons={[{ label: 'Publish', onClick: handlePublish }]}
    />
  )
}

export default ApplicationPublishModal

3.4

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

4.1
Итак, нам осталось добавить версионирование. Если в компонент придет prop — isPublic, тогда заголовок и название кнопки должны измениться на Republish.

4.2

it('renders button and title Republish when isPublic is true', () => {
  const renderer = create(<ApplicationPublishModal appName={'App Name'} isPublic />)

  const element = renderer.root.findByType(ManyUI.Modal)
  expect(element.props.title).toBe('Republish App Name app')
  expect(element.props.buttons[0].label).toBe('Republish')
})

4.3

import React, { useCallback, useState } from 'react'
import { Modal } from '@manychat/manyui'

const ApplicationPublishModal: React.FC<{
  appName: string
  onPublish: () => void
  isPublic?: boolean
}> = ({ appName, onPublish, isPublic }) => {
  const [open, setOpen] = useState(true)

  const actionLabel = `${isPublic ? 'Rep' : 'P'}ublish`
  const title = `${actionLabel} ${appName} app`

  const handlePublish = useCallback(() => {
    onPublish()
    setOpen(false)
  }, [onPublish])

  return (
    <Modal
      open={open}
      width={480}
      title={title}
      buttons={[{ label: actionLabel, onClick: handlePublish }]}
    />
  )
}

export default ApplicationPublishModal

4.4

И, наконец, проведем рефакторинг: добавим недостающие props в каждый тест, вынесем повторяющиеся значения в переменные.

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

В итоге, за 4 итерации по TDD, мы получили следующий код:

// ApplicationPublishModal.test.tsx

import React from 'react'
import { create, act } from 'react-test-renderer'
import * as ManyUI from '@manychat/manyui'
import ApplicationPublishModal from '.'

ManyUI.Modal = jest.fn().mockImplementation(() => null)

describe('ApplicationPublishModal', () => {
  const appName = 'App Name'

  it('renders Modal with props open and width', () => {
    const renderer = create(<ApplicationPublishModal appName={appName} onPublish={jest.fn()} />)

    const element = renderer.root.findByType(ManyUI.Modal)
    expect(element.props.open).toBe(true)
    expect(element.props.width).toBe(480)
  })

  it('renders Modal with the correct title', () => {
    const renderer = create(<ApplicationPublishModal appName={appName} onPublish={jest.fn()} />)

    const element = renderer.root.findByType(ManyUI.Modal)
    expect(element.props.title).toBe('Publish App Name app')
  })

  it('calls method onPublish and closes the modal when button Publish is clicked', () => {
    const onPublishSpy = jest.fn()
    const renderer = create(<ApplicationPublishModal appName={appName} onPublish={onPublishSpy} />)
    const element = renderer.root.findByType(ManyUI.Modal)

    act(() => {
      element.props.buttons
        .find((button: { label: string }) => button.label === 'Publish')
        .onClick()
    })

    expect(onPublishSpy).toHaveBeenCalledTimes(1)
    expect(element.props.open).toBe(false)
  })

  it('renders button and title Republish when isPublic is true', () => {
    const renderer = create(
      <ApplicationPublishModal appName={appName} onPublish={jest.fn()} isPublic />,
    )

    const element = renderer.root.findByType(ManyUI.Modal)
    expect(element.props.title).toBe('Republish App Name app')
    expect(element.props.buttons[0].label).toBe('Republish')
  })
})

// ApplicationPublishModal.tsx

import React, { useCallback, useState } from 'react'
import { Modal } from '@manychat/manyui'

const ApplicationPublishModal: React.FC<{
  appName: string
  onPublish: () => void
  isPublic?: boolean
}> = ({ appName, onPublish, isPublic }) => {
  const [open, setOpen] = useState(true)

  const actionLabel = `${isPublic ? 'Rep' : 'P'}ublish`
  const title = `${actionLabel} ${appName} app`

  const handlePublish = useCallback(() => {
    onPublish()
    setOpen(false)
  }, [onPublish])

  return (
    <Modal
      open={open}
      width={480}
      title={title}
      buttons={[{ label: actionLabel, onClick: handlePublish }]}
    />
  )
}

export default ApplicationPublishModal

Заключение


Что изменилось с момента внедрения TDD


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

Однако спустя всего месяц активного применения TDD, вкупе с парным программированием, скорость разработки выровнялась и пошла вверх. Уже был сформирован достаточный фундамент, чтобы для большинства кейсов можно было найти аналогичный пример. Мы ощутили на практике все плюсы раздела «Зачем нужен TDD» уже в первый месяц.

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

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

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