Какую главную задачу должны решать тесты? Этот вопрос невероятно важен. Ответ на него дает понимание, как правильно писать тесты. Именно на этот вопрос мы ответим в этом уроке.
Представьте, что вы написали функцию capitalize(text)
, которая делает заглавной первую букву переданной строки:
capitalize('hello') # 'Hello'
Вот один из вариантов ее реализации:
# main.py
def capitalize(text):
first_char = text[0].upper()
rest_substring = text[1:]
return f'{first_char}{rest_substring}'
Мы создали функцию — дальше нужно проверить, как она работает. Например, можно открыть REPL и вызвать функцию с разными аргументами:
python -i main.py
capitalize('hello, hexlet!')
# => 'Hello, hexlet!'
Мы убедились, что функция работает — по крайней мере для тех аргументов, которые мы передали в нее. Если во время проверки заметили ошибки, то исправляем функцию и повторяем все заново.
Фактически, весь этот процесс — это ручное тестирование. Его задача — убедиться, что код работает как надо. И нам совершенно без разницы, как конкретно реализована эта функция.
Это и есть ответ на вопрос, заданный в начале урока: тесты проверяют, что код или приложение работает корректно, при этом не имеет значения, как конкретно написан этот код.
Все, что требуется от автоматических тестов — повторить проверки, которые мы выполняли во время ручного тестирования. Для этого достаточно старого доброго if
и исключений. Даже если вы не знакомы с исключениями, ничего страшного. В этом курсе нам хватит двух вещей: для чего нужны исключения и какой у них синтаксис.
До сих пор в курсах Хекслета вы встречались с ошибками, которые возникают непроизвольно: вызов несуществующей функции, обращение к несуществующей константе и так далее. Но ошибки можно порождать самостоятельно с помощью исключений, что необходимо для нашей ситуации. Ошибки порождаются с помощью механизма исключений:
# Дословно: выбросить новое исключение. Исключения бросают.
# Код после этого выражения не выполнится, скрипт завершится с ошибкой
raise Exception('Бум! Произошла ошибка, останавливаемся')
print('nothing'); # Никогда не выполнится
# После запуска получим такой вывод:
# Traceback (most recent call last):
# File "main.py", line 8, in <module>
# raise Exception('Бум! Произошла ошибка, останавливаемся');
# Exception: Бум! Произошла ошибка, останавливаемся
А теперь рассмотрим пример теста:
# Если результат функции не равен ожидаемому значению
if capitalize('hello') != 'Hello':
# Выбрасываем исключение и завершаем выполнение теста
raise Exception('Функция работает неверно!')
Из примера выше видно, что тесты — это точно такой же код, как и любой другой. Он работает в том же окружении и подчиняется тем же правилам и стандартам кодирования. А еще он может содержать ошибки, но это не значит, что надо писать тесты на тесты. Избежать всех ошибок невозможно, да и не нужно — иначе стоимость разработки стала бы неоправданно высокой.
Обычно тесты складывают в специальную директорию в корне проекта. Чаще всего она называется tests, хотя встречаются и другие варианты:
package-name/
└── __init__.py
tests/
└── test_something.py
Обычно структура этой директории отражает структуру исходного кода. Например, если наша функция capitalize(text)
определена в файле package-name/capitalize.py, то ее тест лучше поместить в файл tests/test_capitalize.py.
Представим, что дальше мы будем вносить изменения в код, связанный с этой функцией. В таком случае важно не забывать запускать тесты:
python tests/test_capitalize.py
# Если все хорошо, код молча выполнится
# Если есть ошибка, то будет выведено сообщение об ошибке
Разработчики самостоятельно импортируют тестируемые функции, вызывают их с необходимыми аргументами и проверяют, что функции возвращают ожидаемые значения. Представим, что мы отредактировали код и поменяли сигнатуру функции — входные или выходные параметры, ее имя. В таком случае придется переписывать тесты.
А теперь рассмотрим другой пример. Тесты должны продолжать работать без изменений, если сигнатура осталась той же, но поменялись внутренности функции. Так происходит в этом коде:
def capitalize(text):
first_char, *rest_chars = text
rest_substring = ''.join(rest_chars)
return f'{first_char.upper()}{rest_substring}'
Хорошие тесты ничего не знают о внутреннем устройстве проверяемого кода. Это делает их более универсальными и надежными.
Нельзя написать тесты, которые гарантируют стопроцентную работоспособность кода. Для этого потребовалось бы проверить все возможные аргументы, а это физически невозможно. С другой стороны, без тестов вообще нет никаких гарантий, что программа сработает.
В работе с тестами нужно ориентироваться на разнообразие входных данных, ведь у любой функции не так много основных сценариев использования.
Например, в случае capitalize()
можно взять любое слово и написать одну проверку. Такой тест покроет большую часть возможных сценариев.
Дальше мы смотрим на пограничные случаи — ситуации, в которых код может вести себя по-особенному:
None
Для функции capitalize()
пограничным случаем будет пустая строка:
if capitalize('') != '':
raise Exception('Функция работает неверно!')
Добавив тест на пустую строку, мы увидим, что вызов функции capitalize()
завершается с ошибкой. Внутри нее идет обращение к первому индексу строки без проверки его существования. Нужно исправить код таким образом:
def capitalize(text):
if text == '':
return ''
first_char = text[0].upper()
rest_substring = text[1:]
return f'{first_char}{rest_substring}'
В большом числе ситуаций пограничные случаи требуют отдельной обработки, наличия условных конструкций. Тесты должны быть построены так, чтобы они затрагивали каждую такую конструкцию.
Не забывайте, что условные конструкции могут порождать неочевидные связи. Например, два независимых условных блока порождают четыре возможных сценария:
Комбинация всех возможных вариантов поведения функции называется цикломатической сложностью. Это число, которое показывает все возможные пути кода внутри функции. Цикломатическая сложность — хороший ориентир для понимания того, сколько и какие тесты нужно написать.
Иногда пограничные случаи не связаны с условными конструкциями. Особенно часто такие ситуации встречаются там, где есть вычисления границ слов или массивов. Такой код может работать в большинстве ситуаций, и только в некоторых может давать сбой:
# Возвращает элемент по указанному индексу
# Если индекса не существует, возвращается значение по умолчанию
def get_by_index(elements, index, default):
return elements[index] if index <= len(elements) else default
# В этом коде по ошибке поставили <= вместо <. Поэтому этот код иногда работает неправильно
# Работает правильно
get_by_index(['zero', 'one'], 1, 'value') # 'one'
# Работает неправильно
get_by_index(['zero', 'one'], 2, 'value') # Exception!
Отдельно рассмотрим ошибки типов входных данных. Например, в функцию capitalize()
можно передать число вместо строки. Как она должна себя вести в таком случае? Нужно ли писать такой тест?
Еще один интересный вопрос: нужно ли внутри capitalize()
обрабатывать такие ситуации? Не стоит этого делать, ведь пользы от этого будет мало. Все равно должны быть тесты, которые проверяют, что система работает в целом, а они обычно выявляют проблемы кода на более нижних уровнях.
Ответственность за передачу правильных данных в функцию capitalize()
лежит не на самой функции, а на коде, который вызывает эту функцию. И если он хорошо протестирован, то подобная ошибка либо обнаружится, либо вообще не возникнет.
Но даже если ошибка обрабатывается внутри функции, не надо пытаться написать тесты, покрывающие каждую ошибку. Это выливается в огромное число тестов, которые требуют поддержки и времени на написание. Нужно уметь вовремя остановиться и двигаться дальше, к покрытию другого кода.
В этом уроке мы ближе познакомились с автоматическими тестами. В итоге мы получили такую структуру директорий:
package-name/
└── capitalize.py
tests/
└── test_capitalize.py
Сам тест будет выглядеть так:
from capitalize import capitalize
if capitalize('hello') != 'Hello':
raise Exception('Функция работает неверно!')
if capitalize('') != '':
raise Exception('Функция работает неверно!')
print('Все тесты пройдены!')
Этот тест запускается такой командой:
PYTHONPATH=package-name python tests/test_capitalize.py
https://replit.com/@hexlet/python-testing-goal-capitalize
Если все написано правильно, то запуск тестов завершится с выводом строки «Все тесты пройдены!». Если в тестах или в коде есть ошибка, то сработает исключение и мы увидим сообщение, указывающее на это.
Вам ответят команда поддержки Хекслета или другие студенты.
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.
Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно
Наши выпускники работают в компаниях:
С нуля до разработчика. Возвращаем деньги, если не удалось найти работу.
Зарегистрируйтесь или войдите в свой аккаунт