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

Как писать тесты

  • автор:

Начинаем писать тесты (правильно)

Начинаем писать тесты (правильно) главное изображение

Как начать писать тесты? Сколько нужно писать? На что их нужно писать, а на что — не нужно? Стоит ли всегда применять TDD? Если вас интересуют ответы на эти вопросы, то вы читаете правильную статью. В своей жизни я написал не одну тысячу тестов всех мастей для разных платформ, использовал во все поля TDD и ставил процесс тестирования в командах, проектах и даже целых компаниях. И теперь я попробую обобщить этот опыт и поделиться им.

Тестирование, как и многое в программировании, стало культом карго. Вместо осознанного движения, разработчики пытаются следовать популярным методологиям, слепо верить тому, что пишут в документации, покрывать код на 100% тестами. Я был свидетелем удаления папки с тестами (в 40 тысяч строк кода) по причине того, что их стало невозможно поддерживать. Такое тестирование чаще приводит к обратному эффекту — разработка становится дороже, а процесс медленнее, и даже если наблюдается позитивный эффект, то он дается слишком дорого.

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

Подписывайтесь на канал Кирилла Мокевнина в Telegram — чтобы узнать больше о программировании и профессиональном пути разработчика

Начнем, пожалуй, с самого главного вопроса: зачем нам вообще нужно тестировать?

Чтобы быть уверенными в работоспособности нашего продукта. Заметьте, что я не написал «функций», «модуля», «кода» или «проекта». В конечном итоге имеет значение только то, что конечный продукт, которым пользуются (не всегда пользователи), работает, и делает он это хорошо. Хотя прямо сейчас это может показаться капитанством, но, как вы увидите позже, ориентация на цель позволит нам принимать правильные решения.

Следующий ключевой тезис не является особенностью процесса тестирования. Задачи можно условно поделить на два типа: они либо завершены, либо нет, а завершенность задач второго типа — это шкала, где 0 — это «ничего не сделано», а 1 — это сделано на 100%. При решении таких задач 100%-решение часто оказывается недостижимым из-за сверхвысоких накладных расходов.

Приведу прекрасный пример. Для многих сервисов критично такое понятие как SLA или, проще говоря, доступность сервиса. Например, на хостинговых площадках пишут что-то в духе «мы обеспечиваем доступность 99.9% наших серверов». Давайте прикинем, сколько часов за год хостинг может оказаться недоступен в рамках его SLA: 0.001 * 365 * 24 = 8.7 . В принципе, неплохо.

Предположим, что обеспечение такого уровня доступности обходится компании в 1000$ . А во сколько обойдется компании добавление каждой новой девятки в конце? То есть обеспечение 99.99 , 99.999 и так далее. Насколько мне известно, на таком уровне обеспечения происходит экспоненциальный (взрывной) рост стоимости. Я уже не говорю про то, что 100%-доступность является фантастикой.

Этот пример ярко демонстрирует то, что в задачах с плавающим результатом главным принципом является «максимальный результат за минимальные ресурсы». Другими словами, ищется баланс, при котором мы получаем результат, удовлетворяющий стейкхолдеров (заинтересованные лица), за приемлемый бюджет/сроки.

Теперь возвращаемся к нашим тестам и обнаруживаем, что тесты относятся именно к этому типу задач. Добавление первых тестов в проект дает невероятный эффект. Покрытие в 50% (половина кода вызывается в тестах) получается почти сразу, и по сравнению с отсутствием тестов — мы на два корпуса впереди. Дальше ситуация начинает меняться, и где-то на уровне 70-90% начинается резкое замедление роста покрытия, тесты становятся все более точечными, дорогими. Возрастает сложность их поддержки, рефакторинга.

Этот процесс бесконечен. Добиться 100% покрытия очень дорого и, скорее всего, неоправданно (см. пример выше). Кроме того, никакие тесты не дают вам полную гарантию работоспособности.

Кроме количества тестов и их качества, на стоимость также влияет то, какой тип тестов мы используем. Существует множество классификаций видов тестов, таких как: «по знанию системы», «по степени автоматизации», «по времени проведения тестирования». На этом этапе нас интересует только одна классификация: «по степени изолированности компонентов»:

  • Модульное тестирование
  • Интеграционное тестирование
  • Системное тестирование (приемочное)

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

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

Так вот, есть только шкала. Чем более простую и мелкую часть системы мы тестируем — тем дешевле тесты, чем более сложную (составную) — тем дороже. И ваша задача как специалиста — исходить не из того, чтобы соответствовать своим представлениям о видах тестирования, а писать тесты так, чтобы они в идеале покрывали большее число кейсов при небольших затратах. Я уверен, что на этой фразе некоторые разработчики напряглись, потому что в их картине мира нужно обязательно писать изолированные юнит-тесты, а приемочные должны писать только тестировщики. Не буду разводить полемику, просто скажу, что бывает по-разному. Есть проекты, в которых процент юнит-тестов (в самом жестком понимании) составляет доли процента от всех остальных тестов (как в Хекслете, хе-хе), а есть те, где пишут только приемочные тесты (отдельные тестировщики).

Теперь вы готовы, и я попробую ответить на вопросы, поставленные в начале статьи. Предположим, что вы пишете программу (утилиту командной строки), которая принимает на вход файл и слово, которое нужно найти в этом файле. В результате своей работы программа печатает на экран все строчки из файла, в которых встречается это слово. Такая утилита действительно существует и называется grep . С ней знакомо большинство разработчиков.

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

Основной наблюдаемый мной анти-паттерн в разработке подобных библиотек — это тесты на внутренние мелкие компоненты. Те самые юнит-тесты. Почему такой подход непродуктивен? Возможно, это и не очевидно, но такое тестирование, хоть и является модульным, но не является дешевым и качественным. Но, как…?

Мы уже говорили о том, что архитектура проекта еще неизвестна, и, как правило, внутреннее разделение на файлы/модули/классы/функции, меняется с космической скоростью. В течение часа все может быть переписано несколько раз. Но теперь вместе с кодом нужно постоянно править тесты, что начинает раздражать. Программист начинает сомневаться в том, что они вообще ему нужны, и нередко просто перестает их писать. Другие продолжают мучаться и переписывать их, хотя чаще происходит другое. Написанные тесты начинают вас сковывать и мозг шлет команды «ты потратил время, оставь все как есть». Постепенно рефакторить становится все сложнее и ленивее. Это очень похоже на ситуацию, когда предприниматель инвестировал деньги в новое направление и, даже если бизнес уже тонет, ему тяжело отказаться, ведь было потрачено столько сил и средств (в экономике это называют sunk cost fallacy , — прим. ред.).

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

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

Более низкий уровень — это функция, которая принимает на вход путь до файла и подстроку для поиска, а на выходе (не печатает на экран!) отдает готовый результат, так, чтобы осталось только напечатать его. Такой вид тестов обладает самым лучшим балансом «убедиться в том, что все работает/стоимость». Они косвенно затрагивают все используемые внутренности, не зависят от реализации, очень просты в написании и крайне дешевы в поддержке. По мере стабилизации архитектуры можно добавлять тесты более низкого уровня (если становится понятно, что сложность системы слишком высока).

TDD

Описанная методика особенно хорошо работает в связке с подходом, когда тесты пишутся до кода (вместе с кодом).

Существует миф о том, что тесты нужны только для регресса, то есть для уверенности, что новый код не сломал старый. Это далеко не так. Более того, это следствие написания тестов как таковых. В некоторых ситуациях первостепенная цель написания тестов — это ускорение разработки. Да-да, вы не ослышались, написание тестов до кода/одновременно с кодом, приводит к серьезному ускорению разработки. Чаще всего такие ситуации связаны с тем, что на вход подаются сложные данные, которые как-то трансформируются и прокидываются дальше. Тестировать руками (во время разработки) такой код очень сложно, нужно подготавливать данные, нужно проверять, что результат соответствует ожидаемому.

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

Второе серьезное преимущество TDD заключается в том, что при проектировании кода мы начинаем думать не о том, как сейчас клево насоздаем файлов и разнесем по ним функции, создав десятки абстракций, и начнем думать о важных вещах. О том, как будет использоваться моя библиотека. Удивительно, но начать смотреть с такого угла (а этому учат всех стартаперов, customer development во все поля) непросто, все время хочется окунуться в прекрасный мир архитектуры.

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

Дополнительные ссылки

  • Видео версия статьи
  • Бережливое тестирование

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

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

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

Открыть/закрыть навигацию по статье

  1. Кратко
  2. Что такое тест
    1. Примитивное тестирование
    2. Инструменты для тестирования во фронтенде
    1. Подготовка (Arrange)
    2. Выполнение (Act)
    3. Проверка (Assert)
    4. Идеальный тест
    1. Заставляют думать над крайними случаями
    2. Уменьшают количество регрессий
    3. Дают больше уверенности при рефакторинге
    4. Решают проблемы при обновлении зависимостей
    5. Автоматическая документация
    1. Нужно больше времени на начальных этапах
    2. Нужно продумать структуру для тестов и тестовых данных
    3. Нужно настроить CI
    1. Unit тесты
    2. Интеграционные тесты
    3. E2E тесты
    4. Приёмочные тесты

    Обновлено 21 декабря 2021

    Кратко

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

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

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

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

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

    Представим, что у нас есть функция 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 $ doesn't match the expected value $.` )> function testAdd()  const result = add(10, 5) const expected = 15 console.assert( result === expected, `The result $result> doesn't match the expected value $expected>.` ) >      

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

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

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

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

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

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

    Чтобы использовать Jest, его нужно установить в свой проект через npm . Подробнее об установке можно узнать на сайте с документацией 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 выше, мы писали именно юнит-тест.

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

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

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

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

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

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

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

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

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

    E2E тесты

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

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

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

    Одними из самых популярных и удобных фреймворков для E2E тестирования можно назвать Webdriver.IO и Cypress. В качестве примера тестирования с Cypress можем привести тестирование приложения для учёта расходов.

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

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

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

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

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

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

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

    Зачем и как писать тесты? — PHP: Автоматическое тестирование

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

    Представьте, что вы написали функцию capitalize($text) , которая делает заглавной первую букву переданной строки:

     capitalize('hello'); // 'Hello' 

    Вот один из вариантов ее реализации:

     namespace StringUtils; function capitalize($text)  $firstSymbol = strtoupper($text[0]); $restSubstring = substr($text, 1); return "$firstSymbol>$restSubstring>"; > 

    Что мы делаем после создания функции? Проверяем, как она работает. Например, открываем REPL и вызываем функцию с разными аргументами:

    -a Interactive shell php > echo capitalize('hello'); 'Hello' > echo capitalize('how are you?'); > 'How are you?' 

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

    Фактически, весь этот процесс и есть тестирование. Но не автоматическое, а ручное. Задача такого тестирования — убедиться, что код работает, как надо. И нам совершенно без разницы, как конкретно реализована эта функция. Это и есть главный ответ на вопрос, заданный в начале урока.

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

    Автоматические тесты

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

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

     // Дословно: выбросить новую ошибку // Исключения бросают throw new Exception('Описание исключения'); // Код, следующий за этим выражением, не выполнится, а сам скрипт завершится с ошибкой print_r('nothing'); 
     if (capitalize('hello') !== 'Hello')  // Если результат функции не равен ожидаемому значению, // выбрасываем исключение и завершаем выполнение теста throw new Exception('Функция работает неверно!'); > 

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

    В коде тесты, как правило, складывают в специальную директорию в корне проекта. Обычно она называется tests, хотя встречаются и другие варианты:

    Структура этой директории зависит от того, на базе чего пишутся тесты, например, на базе какого фреймворка. В простых случаях она отражает структуру исходного кода. Если предположить, что наша функция capitalize($text) определена в файле src/StringUtils.php, то ее тест лучше поместить в файл tests/StringUtilsTest.php. Слово Test в имени модуля с тестами используется только для более явного обозначения цели файла.

    Теперь при любых изменениях, затрагивающих эту функцию, важно не забывать запускать тесты:

    # Если все хорошо, код молча выполнится # Если есть ошибка, то будет выведено сообщение об ошибке 

    Как пишутся тесты

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

    Если поменялась сигнатура функции (входные или выходные параметры, ее имя), то придется переписывать тесты. Если сигнатура осталась той же, но поменялись внутренности функции:

     function capitalize($text)  $firstSymbol = mb_strtoupper($text[0]); $restSubstring = mb_substr($text, 1); return "$firstSymbol>$restSubstring>"; > 

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

    Сколько и какие нужно писать проверки?

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

    При написании тестов нужно ориентироваться на разнообразие входных данных. У любой функции есть один или несколько основных сценариев использования. Например, в случае capitalize() — это любое слово. Достаточно написать ровно одну проверку, которая покрывает этот сценарий. Дальше нужно смотреть на «пограничные случаи». Это ситуации, в которых код может повести себя по-особенному:

    • Работа с пустой строкой
    • Обработка null
    • Деление на ноль (в большинстве языков вызывает ошибку)
    • Специфические ситуации для конкретных алгоритмов

    Для capitalize() пограничным случаем будет пустая строка:

     if (capitalize('') !== '')  throw new Exception('Функция работает неверно!'); > 

    Добавив тест на пустую строку, мы увидим, что вызов показанной в начале урока функции capitalize() завершается с ошибкой. Внутри нее идет обращение к первому индексу строки без проверки его существования. Исправленная версия кода:

     function capitalize($text)  if ($text === '')  return ''; > $firstSymbol = mb_strtoupper($text[0]); $restSubstring = mb_substr($text, 1); return "$firstSymbol>$restSubstring>"; > 

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

    • Функция выполнилась так, что не был выполнен ни один условный блок
    • Функция выполнилась так, что был выполнен только первый условный блок
    • Функция выполнилась так, что был выполнен только второй условный блок
    • Функция выполнилась так, что были выполнены оба условных блока

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

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

     // В этой функции забыли отнять единицу от длины // Этот код сработает в некоторых ситуациях, когда последний элемент null или в массиве нет элементов // Но в остальных случаях вернет неверное значение function last($elements)  return $elements[count($elements)]; > 

    Проверка входных данных

    Особняком стоят ошибки типов входных данных. Например, в функцию capitalize() можно передать число вместо строки. Как она должна себя вести в таком случае? Нужно ли писать такой тест?

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

    Ответственность за передачу правильных данных в функцию capitalize() лежит не на ней, а на коде, который вызывает эту функцию. И если он хорошо протестирован, то подобная ошибка либо обнаружится, либо вообще не возникнет.

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

    Собирая все вместе

    В конечном итоге мы получили такую структуру директорий:

     if (StringUtils\capitalize('hello') !== 'Hello')  throw new \Exception('Функция работает неверно!'); > if (StringUtils\capitalize('') !== '')  throw new \Exception('Функция работает неверно!'); > echo 'Все тесты пройдены!'; 

    Открыть доступ

    Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

    • 130 курсов, 2000+ часов теории
    • 1000 практических заданий в браузере
    • 360 000 студентов

    Наши выпускники работают в компаниях:

    Язык программирования Rust

    Тесты — это функции Rust, которые проверяют, что не тестовый код работает ожидаемым образом. Содержимое тестовых функций обычно выполняет следующие три действия:

    1. Установка любых необходимых данных или состояния.
    2. Запуск кода, который вы хотите проверить.
    3. Утверждение, что результаты являются теми, которые вы ожидаете.

    Давайте рассмотрим функции предоставляемые в Rust специально для написания тестов, которые выполнят все эти действия, включая атрибут test , несколько макросов и атрибут should_panic .

    Структура тестирующей функции

    В простейшем случае в Rust тест — это функция, аннотированная атрибутом test . Атрибуты представляют собой метаданные о фрагментах кода Rust; один из примеров атрибут derive , который мы использовали со структурами в главе 5. Чтобы превратить функцию в тестирующую функцию добавьте #[test] в строку перед fn . Когда вы запускаете тесты командой cargo test , Rust создаёт бинарный модуль выполняющий функции аннотированные атрибутом test и сообщающий о том, успешно или нет прошла каждая тестирующая функция.

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

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

    Давайте создадим новый библиотечный проект под названием adder , который складывает два числа:

    $ cargo new adder --lib Created library `adder` project $ cd adder 

    Содержимое файла src/lib.rs вашей библиотеки adder должно выглядеть как в листинге 11-1.

    #[cfg(test)] mod tests < #[test] fn it_works() < let result = 2 + 2; assert_eq!(result, 4); >> 

    Листинг 11-1: Тестовый модуль и функция, сгенерированные автоматически с помощью cargo new

    Сейчас давайте проигнорируем первые две строчки кода и сосредоточимся на функции. Обратите внимание на синтаксис аннотации #[test] : этот атрибут указывает, что это тестовая функция, поэтому запускающий тестирование знает, что эту функцию следует рассматривать как тестовую. У нас также могут быть не тестируемые функции в модуле tests , которые помогут настроить общие сценарии или выполнить общие операции, поэтому нам всегда нужно указывать, какие функции являются тестами.

    В теле функции теста используется макрос assert_eq! , чтобы утверждать, что result , который содержит результат сложения 2 и 2, равен 4. Это утверждение служит примером формата для типичного теста. Давайте запустим его, чтобы убедиться, что этот тест пройден.

    Команда cargo test выполнит все тесты в выбранном проекте и сообщит о результатах как в листинге 11-2:

    $ cargo test Compiling adder v0.1.0 (file:///projects/adder) Finished test [unoptimized + debuginfo] target(s) in 0.57s Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4) running 1 test test tests::it_works . ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Doc-tests adder running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s 

    Листинг 11-2: Вывод информации о работе автоматически сгенерированных тестов

    Cargo скомпилировал и выполнил тест. Мы видим строку running 1 test . Следующая строка показывает имя сгенерированной тестовой функции, называемой it_works , и результат запуска этого теста равный ok . Текст test result: ok. означает, что все тесты пройдены успешно и часть вывода 1 passed; 0 failed сообщает общее количество тестов, которые прошли или были ошибочными.

    Можно пометить тест как игнорируемый, чтобы он не выполнялся в конкретном случае; мы рассмотрим это в разделе “Игнорирование некоторых тестов, если их специально не запрашивать” позже в этой главе. Поскольку в данный момент мы этого не сделали, в сводке показано, что 0 ignored . Мы также можем передать аргумент команде cargo test для запуска только тех тестов, имя которых соответствует строке; это называется фильтрацией, и мы рассмотрим это в разделе “Запуск подмножества тестов по имени” . Мы также не фильтровали выполняемые тесты, поэтому в конце сводки показано, что 0 filtered out .

    Статистика 0 measured предназначена для тестов производительности. На момент написания этой статьи такие тесты доступны только в ночной сборке Rust. Посмотрите документацию о тестах производительности, чтобы узнать больше.

    Следующая часть вывода тестов начинается с Doc-tests adder — это информация о тестах в документации. У нас пока нет тестов документации, но Rust может компилировать любые примеры кода, которые находятся в API документации. Такая возможность помогает поддерживать документацию и код в синхронизированном состоянии. Мы поговорим о написании тестов документации в секции «Комментарии документации как тесты» Главы 14. Пока просто проигнорируем часть Doc-tests вывода.

    Давайте начнём настраивать тест в соответствии с нашими собственными потребностями. Сначала поменяем название нашего теста it_works на exploration , вот так:

    #[cfg(test)] mod tests < #[test] fn exploration() < assert_eq!(2 + 2, 4); >> 

    Снова выполним команду cargo test . Вывод показывает наименование нашей тест-функции — exploration вместо it_works :

    $ cargo test Compiling adder v0.1.0 (file:///projects/adder) Finished test [unoptimized + debuginfo] target(s) in 0.59s Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4) running 1 test test tests::exploration . ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Doc-tests adder running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s 

    Добавим ещё один тест, но в этот раз специально сделаем так, чтобы этот новый тест не отработал! Тест терпит неудачу, когда что-то паникует в тестируемой функции. Каждый тест запускается в новом потоке и когда главный поток видит, что тестовый поток упал, то помечает тест как завершившийся аварийно. Мы говорили о простейшем способе вызвать панику в главе 9, используя для этого известный макрос panic! . Введём код тест-функции another , как в файле src/lib.rs из листинга 11-3.

    #[cfg(test)] mod tests < #[test] fn exploration() < assert_eq!(2 + 2, 4); >#[test] fn another() < panic!("Make this test fail"); >> 

    Листинг 11-3: Добавление второго теста, который завершится ошибкой, потому что мы вызываем panic! макрос

    Запустим команду cargo test . Вывод результатов показан в листинге 11-4, который сообщает, что тест exploration пройден, а another нет:

    $ cargo test Compiling adder v0.1.0 (file:///projects/adder) Finished test [unoptimized + debuginfo] target(s) in 0.72s Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4) running 2 tests test tests::another . FAILED test tests::exploration . ok failures: ---- tests::another stdout ---- thread 'tests::another' panicked at 'Make this test fail', src/lib.rs:10:9 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace failures: tests::another test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s error: test failed, to rerun pass `--lib` 

    Листинг 11-4. Результаты теста, когда один тест пройден, а другой нет

    Вместо ok , строка test tests::another сообщает FAILED . Две новые секции появились между отдельными результатами и сводкой: в первом отображается подробная причина каждого сбоя теста. В данном случае тест another не сработал, потому что panicked at ‘Make this test fail’ , произошло в строке 10 файла src/lib.rs. В следующем разделе перечисляют имена всех не пройденных тестов, что удобно, когда есть много тестов и много подробных результатов неудачных тестов. Мы можем использовать имя не пройденного теста для его дальнейшей отладки; мы больше поговорим о способах запуска тестов в разделе «Контролирование хода выполнения тестов» .

    Итоговая строка отображается в конце: общий результат нашего тестирования FAILED . У нас один тест пройден и один тест завершён аварийно.

    Теперь, когда вы увидели, как выглядят результаты теста при разных сценариях, давайте рассмотрим другие макросы полезные в тестах, кроме panic! .

    Проверка результатов с помощью макроса assert!

    Макрос assert! доступен из стандартной библиотеки и является удобным, когда вы хотите проверить что некоторое условие в тесте вычисляется в значение true . Мы передаём в макрос assert! аргумент, который вычисляется в логическое значение. Если оно true , то ничего не происходит и тест считается пройденным. Если же значение вычисляется в false , то макрос assert! вызывает макрос panic! , чтобы вызвать сбой теста. Использование макроса assert! помогает проверить, что код функционирует как ожидалось.

    В главе 5, листинге 5-15, мы использовали структуру Rectangle и метод can_hold , который повторён в листинге 11-5. Давайте поместим этот код в файл src/lib.rs и напишем несколько тестов для него используя макрос assert! .

    #[derive(Debug)] struct Rectangle < width: u32, height: u32, >impl Rectangle < fn can_hold(&self, other: &Rectangle) ->bool < self.width >other.width && self.height > other.height > > 

    Листинг 11-5: Использование структуры Rectangle и её метода can_hold из главы 5

    Метод can_hold возвращает логическое значение, что означает, что он является идеальным вариантом использования в макросе assert! . В листинге 11-6 мы пишем тест, который выполняет метод can_hold путём создания экземпляра Rectangle шириной 8 и высотой 7 и убеждаемся, что он может содержать другой экземпляр Rectangle имеющий ширину 5 и высоту 1.

    #[derive(Debug)] struct Rectangle  width: u32, height: u32, > impl Rectangle  fn can_hold(&self, other: &Rectangle) -> bool  self.width > other.width && self.height > other.height > > #[cfg(test)] mod tests < use super::*; #[test] fn larger_can_hold_smaller() < let larger = Rectangle < width: 8, height: 7, >; let smaller = Rectangle < width: 5, height: 1, >; assert!(larger.can_hold(&smaller)); > > 

    Листинг 11-6: Тест для метода can_hold , который проверяет что больший прямоугольник действительно может содержать меньший

    Также, в модуле tests обратите внимание на новую добавленную строку use super::*; . Модуль tests является обычным и подчиняется тем же правилам видимости, которые мы обсуждали в главе 7 «Пути для ссылки на элементы внутри дерева модуля» . Так как этот модуль tests является внутренним, нужно подключить тестируемый код из внешнего модуля в область видимости внутреннего модуля с тестами. Для этого используется глобальное подключение, так что все что определено во внешнем модуле становится доступным внутри tests модуля.

    Мы назвали наш тест larger_can_hold_smaller и создали два нужных экземпляра Rectangle . Затем вызвали макрос assert! и передали результат вызова larger.can_hold(&smaller) в него. Это выражение должно возвращать true , поэтому наш тест должен пройти. Давайте выясним!

    $ cargo test Compiling rectangle v0.1.0 (file:///projects/rectangle) Finished test [unoptimized + debuginfo] target(s) in 0.66s Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e) running 1 test test tests::larger_can_hold_smaller . ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Doc-tests rectangle running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s 

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

    #[derive(Debug)] struct Rectangle  width: u32, height: u32, > impl Rectangle  fn can_hold(&self, other: &Rectangle) -> bool  self.width > other.width && self.height > other.height > > #[cfg(test)] mod tests < use super::*; #[test] fn larger_can_hold_smaller() < // --snip-- let larger = Rectangle  width: 8, height: 7, >; let smaller = Rectangle  width: 5, height: 1, >; assert!(larger.can_hold(&smaller)); > #[test] fn smaller_cannot_hold_larger() < let larger = Rectangle < width: 8, height: 7, >; let smaller = Rectangle < width: 5, height: 1, >; assert!(!smaller.can_hold(&larger)); > > 

    Поскольку правильный результат функции can_hold в этом случае false , то мы должны инвертировать этот результат, прежде чем передадим его в assert! макро. Как результат, наш тест пройдёт, если can_hold вернёт false :

    $ cargo test Compiling rectangle v0.1.0 (file:///projects/rectangle) Finished test [unoptimized + debuginfo] target(s) in 0.66s Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e) running 2 tests test tests::larger_can_hold_smaller . ok test tests::smaller_cannot_hold_larger . ok test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Doc-tests rectangle running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s 

    Два теста работают. Теперь проверим, как отреагируют тесты, если мы добавим ошибку в код. Давайте изменим реализацию метода can_hold заменив одно из логических выражений знак сравнения с «больше чем» на противоположный «меньше чем» при сравнении ширины:

    #[derive(Debug)] struct Rectangle  width: u32, height: u32, > // --snip-- impl Rectangle < fn can_hold(&self, other: &Rectangle) ->bool < self.width < other.width && self.height >other.height > > #[cfg(test)] mod tests  use super::*; #[test] fn larger_can_hold_smaller()  let larger = Rectangle  width: 8, height: 7, >; let smaller = Rectangle  width: 5, height: 1, >; assert!(larger.can_hold(&smaller)); > #[test] fn smaller_cannot_hold_larger()  let larger = Rectangle  width: 8, height: 7, >; let smaller = Rectangle  width: 5, height: 1, >; assert!(!smaller.can_hold(&larger)); > > 

    Запуск тестов теперь производит следующее:

    $ cargo test Compiling rectangle v0.1.0 (file:///projects/rectangle) Finished test [unoptimized + debuginfo] target(s) in 0.66s Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e) running 2 tests test tests::larger_can_hold_smaller . FAILED test tests::smaller_cannot_hold_larger . ok failures: ---- tests::larger_can_hold_smaller stdout ---- thread 'tests::larger_can_hold_smaller' panicked at 'assertion failed: larger.can_hold(&smaller)', src/lib.rs:28:9 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace failures: tests::larger_can_hold_smaller test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s error: test failed, to rerun pass `--lib` 

    Наши тесты нашли ошибку! Так как в тесте larger.width равно 8 и smaller.width равно 5 сравнение ширины в методе can_hold возвращает результат false , поскольку число 8 не меньше чем 5.

    Проверка на равенство с помощью макросов assert_eq! и assert_ne!

    Общим способом проверки функциональности является использование сравнения результата тестируемого кода и ожидаемого значения, чтобы убедиться в их равенстве. Для этого можно использовать макрос assert! , передавая ему выражение с использованием оператора == . Важно также знать, что кроме этого стандартная библиотека предлагает пару макросов assert_eq! и assert_ne! , чтобы сделать тестирование более удобным. Эти макросы сравнивают два аргумента на равенство или неравенство соответственно. Макросы также печатают два значения входных параметров, если тест завершился ошибкой, что позволяет легче увидеть почему тест ошибочен. Противоположно этому, макрос assert! может только отобразить, что он вычислил значение false для выражения == , но не значения, которые привели к результату false .

    В листинге 11-7, мы напишем функцию add_two , которая прибавляет к входному параметру 2 и возвращает значение. Затем, протестируем эту функцию с помощью макроса assert_eq! :

    pub fn add_two(a: i32) -> i32 < a + 2 >#[cfg(test)] mod tests < use super::*; #[test] fn it_adds_two() < assert_eq!(4, add_two(2)); >> 

    Листинг 11-7: Тестирование функции add_two с помощью макроса assert_eq!

    Проверим, что тесты проходят!

    $ cargo test Compiling adder v0.1.0 (file:///projects/adder) Finished test [unoptimized + debuginfo] target(s) in 0.58s Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4) running 1 test test tests::it_adds_two . ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Doc-tests adder running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s 

    Первый аргумент, который мы передаём в макрос assert_eq! число 4 чей результат вызова равен add_two(2) . Строка для этого теста — test tests::it_adds_two . ok , а текст ok означает, что наш тест пройден!

    Давайте введём ошибку в код, чтобы увидеть, как она выглядит, когда тест, который использует assert_eq! завершается ошибкой. Измените реализацию функции add_two , чтобы добавлять 3 :

    pub fn add_two(a: i32) -> i32 < a + 3 >#[cfg(test)] mod tests  use super::*; #[test] fn it_adds_two()  assert_eq!(4, add_two(2)); > > 

    Попробуем выполнить данный тест ещё раз:

    $ cargo test Compiling adder v0.1.0 (file:///projects/adder) Finished test [unoptimized + debuginfo] target(s) in 0.61s Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4) running 1 test test tests::it_adds_two . FAILED failures: ---- tests::it_adds_two stdout ---- thread 'tests::it_adds_two' panicked at 'assertion failed: `(left == right)` left: `4`, right: `5`', src/lib.rs:11:9 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace failures: tests::it_adds_two test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s error: test failed, to rerun pass `--lib` 

    Наш тест нашёл ошибку! Тест it_adds_two не выполнился, отображается сообщение assertion failed: (left == right)« и показывает, что left было 4 , а right было 5 . Это сообщение полезно и помогает начать отладку: это означает left аргумент assert_eq! имел значение 4 , но right аргумент для вызова add_two(2) был со значением 5 .

    Обратите внимание, что в некоторых языках (таких как Java) в библиотеках кода для тестирования принято именовать входные параметры проверочных функций как «ожидаемое» ( expected ) и «фактическое» ( actual ). В Rust приняты следующие обозначения left и right соответственно, а порядок в котором определяются ожидаемое значение и производимое тестируемым кодом значение не имеют значения. Мы могли бы написать выражение в тесте как assert_eq!(add_two(2), 4) , что приведёт к отображаемому сообщению об ошибке assertion failed: (left == right)«, слева left было бы 5 , а справа right было бы 4 .

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

    В своей работе макросы assert_eq! и assert_ne! неявным образом используют операторы == и != соответственно. Когда проверка не срабатывает, макросы печатают значения аргументов с помощью отладочного форматирования и это означает, что значения сравниваемых аргументов должны реализовать типажи PartialEq и Debug . Все примитивные и большая часть типов стандартной библиотеки Rust реализуют эти типажи. Для структур и перечислений, которые вы реализуете сами будет необходимо реализовать типаж PartialEq для сравнения значений на равенство или неравенство. Для печати отладочной информации в виде сообщений в строку вывода консоли необходимо реализовать типаж Debug . Так как оба типажа являются выводимыми типажами, как упоминалось в листинге 5-12 главы 5, то эти типажи можно реализовать добавив аннотацию #[derive(PartialEq, Debug)] к определению структуры или перечисления. Смотрите больше деталей в Appendix C «Выводимые типажи» про эти и другие выводимые типажи.

    Создание сообщений об ошибках

    Также можно добавить пользовательское сообщение как дополнительный аргумент макросов для печати в сообщении об ошибке теста assert! , assert_eq! , и assert_ne! . Любые аргументы, указанные после обязательных аргументов, далее передаются в макрос format! (он обсуждается в разделе «Конкатенация с помощью оператора + или макроса format!» ), так что вы можете передать форматированную строку, которая содержит <> для заполнителей и значения, заменяющие эти заполнители. Пользовательские сообщения полезны для пояснения того, что означает утверждение (assertion); когда тест завершается неудачей, у вас будет лучшее представление о том, в чем проблема с кодом.

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

    pub fn greeting(name: &str) -> String < format!("Hello <>!", name) > #[cfg(test)] mod tests < use super::*; #[test] fn greeting_contains_name() < let result = greeting("Carol"); assert!(result.contains("Carol")); >> 

    Требования к этой программе ещё не были согласованы и мы вполне уверены, что текст Hello в начале приветствия ещё изменится. Мы решили, что не хотим обновлять тест при изменении требований, поэтому вместо проверки на точное равенство со значением возвращённым из greeting , мы просто будем проверять, что вывод содержит текст из входного параметра.

    Давайте внесём ошибку в этот код, изменив greeting так, чтобы оно не включало name и увидим, как выглядит сбой этого теста:

    pub fn greeting(name: &str) -> String < String::from("Hello!") >#[cfg(test)] mod tests  use super::*; #[test] fn greeting_contains_name()  let result = greeting("Carol"); assert!(result.contains("Carol")); > > 

    Запуск этого теста выводит следующее:

    $ cargo test Compiling greeter v0.1.0 (file:///projects/greeter) Finished test [unoptimized + debuginfo] target(s) in 0.91s Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a) running 1 test test tests::greeting_contains_name . FAILED failures: ---- tests::greeting_contains_name stdout ---- thread 'tests::greeting_contains_name' panicked at 'assertion failed: result.contains(\"Carol\")', src/lib.rs:12:9 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace failures: tests::greeting_contains_name test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s error: test failed, to rerun pass `--lib` 

    Сообщение содержит лишь информацию о том что сравнение не было успешным и в какой строке это произошло. В данном случае, более полезный текст сообщения был бы, если бы также выводилось значение из функции greeting . Изменим тестирующую функцию так, чтобы выводились пользовательское сообщение форматированное строкой с заменителем и фактическими данными из кода greeting :

    pub fn greeting(name: &str) -> String  String::from("Hello!") > #[cfg(test)] mod tests  use super::*; #[test] fn greeting_contains_name() < let result = greeting("Carol"); assert!( result.contains("Carol"), "Greeting did not contain name, value was `<>`", result ); > > 

    После того, как выполним тест ещё раз мы получим подробное сообщение об ошибке:

    $ cargo test Compiling greeter v0.1.0 (file:///projects/greeter) Finished test [unoptimized + debuginfo] target(s) in 0.93s Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a) running 1 test test tests::greeting_contains_name . FAILED failures: ---- tests::greeting_contains_name stdout ---- thread 'tests::greeting_contains_name' panicked at 'Greeting did not contain name, value was `Hello!`', src/lib.rs:12:9 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace failures: tests::greeting_contains_name test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s error: test failed, to rerun pass `--lib` 

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

    Проверка с помощью макроса should_panic

    В дополнение к проверке того, что наш код возвращает правильные, ожидаемые значения, важным также является проверить, что наш код обрабатывает ошибки, которые мы ожидаем. Например, рассмотрим тип Guess который мы создали в главе 9, листинга 9-10. Другой код, который использует Guess зависит от гарантии того, что Guess экземпляры будут содержать значения только от 1 до 100. Мы можем написать тест, который гарантирует, что попытка создать экземпляр Guess со значением вне этого диапазона вызывает панику.

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

    Листинг 11-8 показывает тест, который проверяет, что условия ошибки Guess::new произойдут, когда мы их ожидаем их.

    pub struct Guess < value: i32, >impl Guess < pub fn new(value: i32) ->Guess < if value < 1 || value >100 < panic!("Guess value must be between 1 and 100, got <>.", value); > Guess < value >> > #[cfg(test)] mod tests < use super::*; #[test] #[should_panic] fn greater_than_100() < Guess::new(200); >> 

    Листинг 11-8: Проверка того, что условие вызовет макрос panic!

    Атрибут #[should_panic] следует после #[test] и до объявления тестовой функции. Посмотрим на вывод результата, когда тест проходит:

    $ cargo test Compiling guessing_game v0.1.0 (file:///projects/guessing_game) Finished test [unoptimized + debuginfo] target(s) in 0.58s Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d) running 1 test test tests::greater_than_100 - should panic . ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Doc-tests guessing_game running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s 

    Выглядит хорошо! Теперь давайте внесём ошибку в наш код, убрав условие о том, что функция new будет паниковать если значение больше 100:

    pub struct Guess  value: i32, > // --snip-- impl Guess < pub fn new(value: i32) ->Guess < if value < 1 < panic!("Guess value must be between 1 and 100, got <>.", value); > Guess < value >> > #[cfg(test)] mod tests  use super::*; #[test] #[should_panic] fn greater_than_100()  Guess::new(200); > > 

    Когда мы запустим тест в листинге 11-8, он потерпит неудачу:

    $ cargo test Compiling guessing_game v0.1.0 (file:///projects/guessing_game) Finished test [unoptimized + debuginfo] target(s) in 0.62s Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d) running 1 test test tests::greater_than_100 - should panic . FAILED failures: ---- tests::greater_than_100 stdout ---- note: test did not panic as expected failures: tests::greater_than_100 test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s error: test failed, to rerun pass `--lib` 

    Мы получаем не очень полезное сообщение в этом случае, но когда мы смотрим на тестирующую функцию, мы видим, что она #[should_panic] . Аварийное выполнение, которое мы получили означает, что код в тестирующей функции не вызвал паники.

    Тесты, которые используют should_panic могут быть неточными, потому что они только указывают, что код вызвал панику. Тест с атрибутом should_panic пройдёт, даже если тест паникует по причине, отличной от той, которую мы ожидали. Чтобы сделать тесты с should_panic более точными, мы можем добавить необязательный параметр expected для атрибута should_panic . Такая детализация теста позволит удостовериться, что сообщение об ошибке содержит предоставленный текст. Например, рассмотрим модифицированный код для Guess в листинге 11-9, где new функция паникует с различными сообщениями в зависимости от того, является ли значение слишком маленьким или слишком большим.

    pub struct Guess  value: i32, > // --snip-- impl Guess < pub fn new(value: i32) ->Guess < if value < 1 < panic!( "Guess value must be greater than or equal to 1, got <>.", value ); > else if value > 100 < panic!( "Guess value must be less than or equal to 100, got <>.", value ); > Guess < value >> > #[cfg(test)] mod tests < use super::*; #[test] #[should_panic(expected = "less than or equal to 100")] fn greater_than_100() < Guess::new(200); >> 

    Листинг 11-9: Проверка panic! на наличие в его сообщении указанной подстроки

    Этот тест пройдёт, потому что значение, которое мы поместили для should_panic в параметр атрибута expected является подстрокой сообщения, с которым функция Guess::new вызывает панику. Мы могли бы указать полное, ожидаемое сообщение для паники, в этом случае это будет Guess value must be less than or equal to 100, got 200 . То что вы выберите для указания как ожидаемого параметра у should_panic зависит от того, какая часть сообщения о панике уникальна или динамична, насколько вы хотите, чтобы ваш тест был точным. В этом случае достаточно подстроки из сообщения паники, чтобы гарантировать выполнение кода в тестовой функции else if value > 100 .

    Чтобы увидеть, что происходит, когда тест should_panic неуспешно завершается с сообщением expected , давайте снова внесём ошибку в наш код, поменяв местами if value < 1 и else if value >100 блоки:

    pub struct Guess  value: i32, > impl Guess  pub fn new(value: i32) -> Guess  if value < 1 < panic!( "Guess value must be less than or equal to 100, got <>.", value ); > else if value > 100 < panic!( "Guess value must be greater than or equal to 1, got <>.", value ); > Guess > > #[cfg(test)] mod tests  use super::*; #[test] #[should_panic(expected = "less than or equal to 100")] fn greater_than_100()  Guess::new(200); > > 

    На этот раз, когда мы выполним should_panic тест, он потерпит неудачу:

    $ cargo test Compiling guessing_game v0.1.0 (file:///projects/guessing_game) Finished test [unoptimized + debuginfo] target(s) in 0.66s Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d) running 1 test test tests::greater_than_100 - should panic . FAILED failures: ---- tests::greater_than_100 stdout ---- thread 'tests::greater_than_100' panicked at 'Guess value must be greater than or equal to 1, got 200.', src/lib.rs:13:13 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace note: panic did not contain expected string panic message: `"Guess value must be greater than or equal to 1, got 200."`, expected substring: `"less than or equal to 100"` failures: tests::greater_than_100 test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s error: test failed, to rerun pass `--lib` 

    Сообщение об ошибке указывает, что этот тест действительно вызвал панику, как мы и ожидали, но сообщение о панике не включено ожидаемую строку ‘Guess value must be less than or equal to 100’ . Сообщение о панике, которое мы получили в этом случае, было Guess value must be greater than or equal to 1, got 200. Теперь мы можем начать выяснение, где ошибка!

    Использование Result в тестах

    Пока что мы написали тесты, которые паникуют, когда терпят неудачу. Мы также можем написать тесты которые используют Result ! Вот тест из листинга 11-1, переписанный с использованием Result и возвращающий Err вместо паники:

    #[cfg(test)] mod tests < #[test] fn it_works() ->Result  < if 2 + 2 == 4 < Ok(()) >else < Err(String::from("two plus two does not equal four")) >> > 

    Функция it_works теперь имеет возвращаемый тип Result . В теле функции, вместо вызова макроса assert_eq! , мы возвращаем Ok(()) когда тест успешно выполнен и Err со String внутри, когда тест не проходит.

    Написание тестов так, чтобы они возвращали Result позволяет использовать оператор «вопросительный знак» в теле тестов, который может быть удобным способом писать тесты, которые должны выполниться не успешно, если какая-либо операция внутри них возвращает вариант ошибки Err .

    Вы не можете использовать аннотацию #[should_panic] в тестах, использующих Result . Чтобы утверждать, что операция возвращает вариант Err , не используйте оператор вопросительного знака для значения Result . Вместо этого используйте assert!(value.is_err()) .

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *