Робот-тестировщик, сидит за клавиатурой, вы смотрите на него из монитора
Иллюстрация: Кира Кустова

Как и зачем писать тесты

Как тесты помогают писать чистый новый код и увереннее редактировать старый.

Время чтения: 13 мин

Кратко

Скопировано

«Тесты — это лишняя работа», «тесты писать необязательно» — такие мнения часто можно услышать в разговорах о тестировании. В этой статье мы постараемся развеять этот миф и рассмотрим плюсы тестирования и минусы его отсутствия.

Тесты делают код более прочным и живучим. Одновременно с этим тесты — это отличная документация, которая не врёт и не устаревает. Также тесты можно использовать как инструмент разработки программы.

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

Что такое тест

Скопировано

Тест — это код, который проверяет предположения о работе другого кода.

Представим, что у нас есть функция add(), которая складывает одно число с другим:

        
          
          function add(a, b) {  return a + b}
          function add(a, b) {
  return a + b
}

        
        
          
        
      

Мы предполагаем, что функция прибавляет аргумент b к аргументу a и возвращает нам результат. Мы можем проверить это, вызвав её:

        
          
          const result = add(10, 5)// result === 15
          const result = add(10, 5)
// result === 15

        
        
          
        
      

Но что будет, если мы передадим не два числа, а одно? А если передадим не числа? Или функция за время жизни проекта изменится? Чтобы проверить такие предположения, мы пишем тесты.

Примитивное тестирование

Скопировано

Самый простой тест, который мы можем написать — ручной. Сравним руками результат работы функции и ожидаемое значение:

        
          
          function testAdd() {  const result = add(10, 5)  const expected = 15  console.assert(    result === expected,    `The result ${result} doesn't match the expected value ${expected}.`  )}
          function testAdd() {
  const result = add(10, 5)
  const expected = 15

  console.assert(
    result === expected,
    `The result ${result} doesn't match the expected value ${expected}.`
  )
}

        
        
          
        
      

При запуске функции testAdd() она проверит, что вернёт функция add(). Если результат не будет соответствовать ожиданию, консоль покажет ошибку.

Конечно, такой тест никуда не годится 😃

  • Ему сильно не хватает описания – как понять, что именно мы проверяем?
  • Не хватает выразительности и лаконичности — сравнивать значения и выбрасывать ошибки руками не круто.
  • Не хватает интерактивности — чтобы перезапустить тест, нужно запустить функцию заново.

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

Инструменты для тестирования во фронтенде

Скопировано

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

Попробуем, используя его, переписать тест нашей функции add():

        
          
          describe('When given 2 numbers', () => {  it('returns the sum of those 2 numbers', () => {    const result = add(10, 5)    const expected = 15    expect(result).toEqual(expected)  })})
          describe('When given 2 numbers', () => {
  it('returns the sum of those 2 numbers', () => {
    const result = add(10, 5)
    const expected = 15

    expect(result).toEqual(expected)
  })
})

        
        
          
        
      

Разберём по строкам:

  1. На первой строке мы указываем описание теста — в каких условиях мы собираемся тестировать функцию.
  2. На второй строке указываем само предположение о результате — что функция должна нам вернуть.
  3. На строчках 3–6 выполняем сам тест.

Функция expect() помогает избежать работы с ошибками напрямую и предоставляет удобные методы для сравнения аргументов друг с другом.

Обвязка из describe() и it() помогает нам описать тест в виде самого настоящего текстового предположения, которое код теста проверит.

Теперь разберём, собственно, код теста.

Анатомия теста

Скопировано

Наш тест состоит из 3 строк:

        
          
          const result = add(10, 5)const expected = 15expect(result).toEqual(expected)
          const result = add(10, 5)
const expected = 15
expect(result).toEqual(expected)

        
        
          
        
      

Его можно разделить на 3 стадии, которые можно запомнить по мнемоникам:

  • ПВП: Подготовка, Выполнение, Проверка;
  • или по-английски AAA: Arrange, Act и Assert.

Подготовка (Arrange)

Скопировано

На стадии подготовки мы готовим исходные данные для функции и ожидаемый результат. В более сложных тестах на этой стадии мы бы готовили зависимости для функции и фиктивные объекты.

В нашем случае подготовкой можно назвать выбор аргументов 10 и 5, а также обозначение ожидаемого значения const expected = 15.

Выполнение (Act)

Скопировано

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

В нашем случае это строчка с вызовом функции: const result = add(10, 5).

Проверка (Assert)

Скопировано

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

В нашем случае проверка — это сравнение результатов на последней строке:

        
          
          expect(result).toEqual(expected);
          expect(result).toEqual(expected);

        
        
          
        
      

Идеальный тест

Скопировано

Идеальный тест состоит из всех трёх стадий ПВП. Часто — такой тест даже состоит из 3 строчек.

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

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

Плюсы тестов

Скопировано

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

Рассмотрим основные плюсы тестирования.

Заставляют думать над крайними случаями

Скопировано

Когда мы пишем программу, мы чаще думаем об основном сценарии работы (happy path), часто забывая о крайних случаях.

Тесты смещают фокус с основного сценария на «что может пойти не так». Когда мы пишем тесты, мы больше склонны искать ошибки и неадекватную работу функции. Чем больше крайних случаев мы обработаем, тем надёжнее будет код.

Уменьшают количество регрессий

Скопировано

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

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

Тесты закрывают такие ошибки, потому что падают при возникновении неучтённой ситуации и не дают ей отправиться в продакшен.

Дают больше уверенности при рефакторинге

Скопировано

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

Даже если мы уверены, что полностью знаем кусок, который рефакторим, мы не застрахованы от более простых ошибок:

        
          
          // 1.let a = 15;if (a == 20) {}// 2.let a = 15;if (a = 20) {}
          // 1.
let a = 15;
if (a == 20) {}

// 2.
let a = 15;
if (a = 20) {}

        
        
          
        
      

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

Решают проблемы при обновлении зависимостей

Скопировано

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

Автоматическая документация

Скопировано

Тесты не врут 😃
Они действительно показывают, как работает система.

Дополнительная документация может устареть, особенно часто это случается с комментариями. Если документация устарела, у нас появляется два источника правды: документация и код. Это плохо, потому что непонятно, чему верить, и как программа должна работать. Тесты же точно говорят, как программа работать должна и работает ли.

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

Издержки тестирования

Скопировано

Тестирование не бесплатное, за надёжность кода приходится платить.

Нужно больше времени на начальных этапах

Скопировано

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

Это, конечно, не так. Чем проект старше, тем больше мы получаем выгоды от написанных тестов.

Избавиться от ощущения ненужности тестов можно, если заранее закладывать время на них в план, а также считать их базовой гигиеной при работе. Часть команд и проектов сейчас не работают без тестов вовсе. Такие команды — это хорошая среда для того, чтобы привить себе привычку к тестированию на уровне автоматизма.

Нужно продумать структуру для тестов и тестовых данных

Скопировано

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

Чтобы не запутаться во всех фиктивных объектах и данных, для них нужно организовать структуру. Чем удобнее структура, тем проще писать тесты.

Грамотно организовать систему с фиктивными объектами сложно. Это требует навыков проектирования, знаний о хорошей архитектуре и опыта.

Нужно настроить CI

Скопировано

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

Настройка таких задач — это тоже дополнительная работа.

Виды тестов

Скопировано

Хорошо, вот мы взвесили все преимущества и недостатки тестирования. Если мы хотим внедрить тестирование в своём проекте, то какие тесты нам писать?

Тесты бывают разных видов и проверяют они тоже разные вещи. Рассмотрим основные типы тестов в виде пирамиды и поговорим о каждом.

Пирамида тестирования: в основании Unit-тесты, чуть выше интеграционные, на вершине — End-to-End

Пирамида тестирования: в основании Unit-тесты, чуть выше интеграционные, на вершине — End-to-End.

Unit тесты

Скопировано

В основании пирамиды лежат юнит-тесты. Их ещё называют модульными тестами или блочными тестами.

Такие тесты проверяют работу конкретного модуля, функции или части программы. Когда мы писали тест для функции add выше, мы писали именно юнит-тест.

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

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

Интеграционные тесты

Скопировано

Интеграционные тесты проверяют, как между собой взаимодействуют две и более части программы, которые работают над одной задачей (то есть как они интегрированы друг с другом, отсюда название).

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

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

Интеграционными тестами проверяют связующие модули (так называемое middleware):

  • юнит-тесты проверяют прямое назначение middleware в целом;
  • интеграционные — как middleware взаимодействует с конкретными модулями.

E2E тесты

Скопировано

Они же End-to-End тесты, они же системные тесты — это проверка работы программы в целом.

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

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

Приёмочные тесты

Скопировано

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

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

Писать или не писать

Скопировано

Даже при наличии издержек тестирование экономит силы, время и деньги в долгосрочной перспективе. Отказаться от тестирования можно, если:

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

В остальных случаях тесты лучше писать с самого начала.