Андрей Смирнов
Python-разработчик, эксперт по автоматизации и преподаватель в Школе программистов МШП
Меня зовут Андрей Смирнов, я занимаюсь Python-разработкой, автоматизацией технических процессов и преподаю промышленное программирование в Школе программистов МШП.
Не секрет, что разработчики создают программы, которые рано или поздно становятся очень масштабными (если смотреть на количество строчек кода). А с этим приходит и большая ответственность за качество.
Сейчас расскажу, как unittest и pytest помогут найти ошибки в программах и исключить их в будущем.
Итак, тестирование
Каждый, кто писал первые программы (будь то классический «hello, world» или же калькулятор), всегда запускал тесты, чтобы проверить их работу.
Сам факт запуска — самое первое, незримое касание технологии тестирования в вашей жизни. Рассмотрим его как процесс поиска ошибок на чуть более сложной программе.
Например, вам нужно ввести три числа (a, b, c) и найти корни квадратного уравнения. Для решения пишем код:
from math import sqrt
def square_eq_solver(a, b, c):
result = []
discriminant = b * b - 4 * a * c
if discriminant == 0:
result.append(-b / (2 * a))
else:
result.append((-b + sqrt(discriminant)) / (2 * a))
result.append((-b - sqrt(discriminant)) / (2 * a))
return result
def show_result(data):
if len(data) > 0:
for index, value in enumerate(data):
print(f'Корень номер {index+1} равен {value:.02f}')
else:
print('Уравнение с заданными параметрами не имеет корней')
def main():
a, b, c = map(int, input('Пожалуйста, введите три числа через пробел: ').split())
result = square_eq_solver(a, b, c)
show_result(result)
if __name__ == '__main__':
main()
Сразу оговорюсь: любую задачу, какой бы она ни была краткой, я рассматриваю с позиции «когда-нибудь она вырастет и станет очень объёмной». Поэтому всегда стараюсь разделять программу на различные подпрограммы (ввод/обработка/вывод).
Возможно, вы уже заметили ошибку в коде. Однако иногда она может быть скрыта настолько глубоко, что её просто так не обнаружишь. И в таком случае единственный способ вывести ее на свет — протестировать код. Как это сделать?
— зная алгоритм нахождения корней уравнения, определяем наборы входных данных, которые будут переданы на вход программе;
— зная входные данные, можно вручную просчитать, какой ответ должна дать программа;
— запускаем программу и передаем ей на вход исходные данные;
— получаем от нее ответ и сравниваем с тем, который должен быть получен. Если они совпадают — хорошо, идём к следующему набору данных, если нет, сообщаем об ошибке.
Например, для данной задачи можно подобрать следующие тесты:
- 10x**2 = 0 — единственный корень x=0
- 2x**2 + 5x — 3 = 0 — у такого уравнения два корня (x1 = 0.5, x2=-3)
- 10x**2+2 = 0 — у этого уравнения корней нет
Тесты подобрали, что дальше? Правильно, запускаем:
Тест номер 1
> python.exe example.py
Пожалуйста, введите три числа через пробел: 10 0 0
Корень номер 0 равен 0.00
Тест номер 2:
> python.exe example.py
Пожалуйста, введите три числа через пробел: 2 5 -3
Корень номер 1 равен 0.50
Корень номер 2 равен -3.00
Тест номер 3:
> python.exe example.py
Пожалуйста, введите три числа через пробел: 10 0 2
Traceback (most recent call last):
File "C:PyProjectstprogerexample.py", line 32, in <module>
main()
File "C:PyProjectstprogerexample.py", line 27, in main
result = square_eq_solver(a, b, c)
File "C:PyProjectstprogerexample.py", line 11, in square_eq_solver
result.append((-b + sqrt(discriminant)) / (2 * a))
ValueError: math domain error
Упс… В третьем тесте произошла ошибка. Как раз та, которую вы могли заметить в исходном коде программы — не обрабатывался случай с нулевым дискриминантом. В итоге, можно подкорректировать код функции так, чтобы этот вариант обрабатывался правильно:
def square_eq_solver(a, b, c):
result = []
discriminant = b * b - 4 * a * c
if discriminant == 0:
result.append(-b / (2 * a))
elif discriminant > 0: # <--- изменили условие, теперь
# при нулевом дискриминанте
# не будут вычисляться корни
result.append((-b + sqrt(discriminant)) / (2 * a))
result.append((-b - sqrt(discriminant)) / (2 * a))
return result
Запускаем все тесты повторно и они срабатывают нормально.
Но учтите, чтобы повторно проверить программу, потребуется потратить несколько минут и снова проверить все три варианта входных значений. Если таких вариантов будет много, вызывать их вручную будет очень накладно. И здесь на сцену выходит автоматизированное тестирование.
Программа автоматического тестирования запускается на основе заранее заготовленных входных/выходных данных и программы, которая будет их вызывать. По сути, это программа, тестирующая другие программы. И в рамках экосистемы языка Python есть несколько пакетов, позволяющих автоматизировать процесс тестирования.
Две самые популярные библиотеки — unittest и pytest. Попробуем каждую, чтобы объективно оценить синтаксис.
Начнем с unittest, потому что именно с нее многие знакомятся с миром тестирования. Причина проста: библиотека по умолчанию встроена в стандартную библиотеку языка Python.
Формат кода
По формату написания тестов она сильно напоминает библиотеку JUnit, используемую в языке Java для написания тестов:
- тесты должны быть написаны в классе;
- класс должен быть отнаследован от базового класса unittest.TestCase;
- имена всех функций, являющихся тестами, должны начинаться с ключевого слова test;
- внутри функций должны быть вызовы операторов сравнения (assertX) — именно они будут проверять наши полученные значения на соответствие заявленным.
Пример использования unittest для нашей задачи
import unittest
class SquareEqSolverTestCase(unittest.TestCase):
def test_no_root(self):
res = square_eq_solver(10, 0, 2)
self.assertEqual(len(res), 0)
def test_single_root(self):
res = square_eq_solver(10, 0, 0)
self.assertEqual(len(res), 1)
self.assertEqual(res, [0])
def test_multiple_root(self):
res = square_eq_solver(2, 5, -3)
self.assertEqual(len(res), 2)
self.assertEqual(res, [0.5, -3])
Запускается данный код следующей командой
python.exe -m unittest example.py
И в результате на экран будет выведено:
> python.exe -m unittest example.py
...
------------------------------------------------------------------
Ran 3 tests in 0.001s
OK
В случае, если в каком-нибудь из тестов будет обнаружена ошибка, unittest не замедлит о ней сообщить:
> python.exe -m unittest example.py
F..
==================================================================
FAIL: test_multiple_root (hello.SquareEqSolverTestCase)
------------------------------------------------------------------
Traceback (most recent call last):
File "C:PyProjectstprogerexample.py", line 101, in test_multiple_root
self.assertEqual(len(res), 3)
AssertionError: 2 != 3
------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (failures=1)
Unittest: аргументы “за”
- Является частью стандартной библиотеки языка Python: не нужно устанавливать ничего дополнительно;
- Гибкая структура и условия запуска тестов. Для каждого теста можно назначить теги, в соответствии с которыми будем запускаться либо одна, либо другая группа тестов;
- Быстрая генерация отчетов о проведенном тестировании, как в формате plaintext, так и в формате XML.
Unittest: аргументы “против”
- Для проведения тестирования придётся написать достаточно большое количество кода (по сравнению с другими библиотеками);
- Из-за того, что разработчики вдохновлялись форматом библиотеки JUnit, названия основных функций написаны в стиле camelCase (например setUp и assertEqual);
- В языке python согласно рекомендациям pep8 должен использоваться формат названий snake_case (например set_up и assert_equal).
Pytest
Возможно, наиболее популярный фреймворк с открытым исходным кодом из всех, представленных здесь.
Pytest позволяет провести модульное тестирование (тестирование отдельных компонентов программы), функциональное тестирование (тестирование способности кода удовлетворять бизнес-требования), тестирование API (application programming interface) и многое другое.
Формат кода
Написание тестов здесь намного проще, нежели в unittest. Вам нужно просто написать несколько функций, удовлетворяющих следующим условиям:
- Название функции должно начинаться с ключевого слова test;
- Внутри функции должно проверяться логическое выражение при помощи оператора assert.
Пример использования pytest для нашей задачи:
def test_no_root():
res = square_eq_solver(10, 0, 2)
assert len(res) == 0
def test_single_root():
res = square_eq_solver(10, 0, 0)
assert len(res) == 1
assert res == [0]
def test_multiple_root():
res = square_eq_solver(2, 5, -3)
assert len(res) == 3
assert res == [0.5, -3]
Запускается данный код следующей командой
pytest.exe example.py
И в результате на экран будет выведено:
> pytest.exe example.py
======================= test session starts ======================
platform win32 -- Python 3.9.6, pytest-7.1.2, pluggy-1.0.0
rootdir: C:PyProjectstproger
collected 3 items
example.py ... [100%]
======================== 3 passed in 0.03s =======================
В случае ошибки вывод будет несколько больше:
> pytest.exe example.py
======================= test session starts ======================
platform win32 -- Python 3.9.6, pytest-7.1.2, pluggy-1.0.0
rootdir: C:PyProjectstproger
collected 3 items
example.py ..F [100%]
============================ FAILURES ============================
_______________________ test_multiple_root _______________________
def test_multiple_root():
res = square_eq_solver(2, 5, -3)
> assert len(res) == 3
E assert 2 == 3
E + where 2 = len([0.5, -3.0])
example.py:116: AssertionError
===================== short test summary info ====================
FAILED example.py::test_multiple_root - assert 2 == 3
=================== 1 failed, 2 passed in 0.10s ==================
Pytest: аргументы “за”
- Позволяет писать компактные (по сравнению с unittest) наборы тестов;
- В случае возникновения ошибок выводится гораздо больше информации о них;
- Позволяет запускать тесты, написанные для других тестирующих систем;
- Имеет систему плагинов (и сотни этих самых плагинов), расширяющую возможности фреймворка. Примеры таких плагинов: pytest-cov, pytest-django, pytest-bdd;
- Позволяет запускать тесты в параллели (при помощи плагина pytest-xdist).
Pytest: аргументы “против”
- pytest не входит в стандартную библиотеку языка Python. Поэтому его придётся устанавливать отдельно при помощи команды pip install pytest;
- совместимость кода с другими фреймворками отсутствует. Так что, если напишете код под pytest, запустить его при помощи встроенного unittest не получится.
Ну и что лучше?
- Если вам нужно базовое юнит-тестирование и вы знакомы с фреймворками вида xUnit, тогда вам подойдёт unittest.
- Если нужен фреймворк, позволяющий создавать краткие и изящные тесты, реализующие сложную логику проверок, то pytest.
Post Scriptum
Тема контроля качества очень обширна. И даже к написанному мной коду очень легко придраться. Как минимум здесь отсутствует проверка на то, что вводимые данные обязательно должны быть целыми числами. Если ввести любое другое число или даже строку, она обязательно завершится с ошибкой.
Кстати, в этой программе я намеренно оставил ещё одну ошибку (на сей раз уже логическую), связанную с нахождением корня. Напишите в комментариях, с чем она может быть связана, и какой тест поможет её отловить 😉
Вернуться Дальше
Вы узнаете, как организовать тесты в классы, модули и каталоги. Затем я покажу вам, как использовать маркеры, чтобы отметить, какие тесты вы хотите запустить, и обсудить, как встроенные маркеры могут помочь вам пропустить тесты и отметить тесты, ожидая неудачи. Наконец, я расскажу о параметризации тестов, которая позволяет тестам вызываться с разными данными.
Примеры в этой книге написаны с использованием Python 3.6 и pytest 3.2. pytest 3.2 поддерживает Python 2.6, 2.7 и Python 3.3+.
Исходный код для проекта Tasks, а также для всех тестов, показанных в этой книге, доступен по ссылке на веб-странице книги в pragprog.com. Вам не нужно загружать исходный код, чтобы понять тестовый код; тестовый код представлен в удобной форме в примерах. Но что бы следовать вместе с задачами проекта, или адаптировать примеры тестирования для проверки своего собственного проекта (руки у вас развязаны!), вы должны перейти на веб-страницу книги и скачать работу. Там же, на веб-странице книги есть ссылка для сообщений errata и дискуссионный форум.
Под спойлером приведен список статей этой серии.
В предыдущей главе вы запустили pytest. Вы видели, как запустить его с файлами и каталогами и сколько из опций работали. В этой главе вы узнаете, как писать тестовые функции в контексте тестирования пакета Python. Если вы используете pytest для тестирования чего-либо, кроме пакета Python, большая часть этой главы будет полезна.
Мы напишем тесты для пакета Tasks. Прежде чем мы это сделаем, я расскажу о структуре распространяемого пакета Python и тестах для него, а также о том, как заставить тесты видеть тестируемый пакет. Затем я покажу вам, как использовать assert в тестах, как тесты обрабатывают непредвиденные исключения и тестируют ожидаемые исключения.
В конце концов, у нас будет много тестов. Таким образом, вы узнаете, как организовать тесты в классы, модули и каталоги. Затем я покажу вам, как использовать маркеры, чтобы отметить, какие тесты вы хотите запустить, и обсудить, как встроенные маркеры могут помочь вам пропустить тесты и отметить тесты, ожидая неудачи. Наконец, я расскажу о параметризации тестов, которая позволяет тестам вызываться с разными данными.
Прим.переводчика: Если вы используете версию Python 3.5 или 3.6 то при выполнении тестов Главы 2 могут возникнуть сообщения вот такого вида
Эта проблема лечится исправлением...codetasks_projsrctaskstasksdb_tinydb.py
и повторной установкой пакета tasks$ cd /path/to/code $ pip install ./tasks_proj/`
Исправить надо именованные параметры
eids
наdoc_ids
иeid
наdoc_id
в модуле...codetasks_projsrctaskstasksdb_tinydb.py
Пояснения Смотри
#83783
здесьПрим. от Georgy ggoliev В случае использования версии tinydb 4.1.1. Возникает ошибка
FAILED test_unique_id_1.py::test_unique_id — TypeError: contains() got an unexpected keyword argument 'doc_ids'
причина — в версии tinydb 4.0.0
TinyDB.contains(...)’s doc_ids parameter has been renamed to doc_id and now only takes a single document ID
Пояснения Смотри
#86727
здесьСуть исправления codetasks_projsrctaskstasksdb_tinydb.py def unique_id
while self._db.contains(doc_id=i):
Тестирование пакета
Чтобы узнать, как писать тестовые функции для пакета Python, мы будем использовать пример проекта Tasks, как описано в проекте Tasks на странице xii. Задачи представляет собой пакет Python, который включает в себя инструмент командной строки с тем же именем, задачи.
Приложение 4 «Packaging and Distributing Python Projects» на стр. 175 включает объяснение того, как распределять ваши проекты локально внутри небольшой команды или глобально через PyPI, поэтому я не буду подробно разбираться в том, как это сделать; однако давайте быстро рассмотрим, что находится в проекте «Tasks» и как разные файлы вписываются в историю тестирования этого проекта.
Ниже приведена файловая структура проекта Tasks:
tasks_proj/
├── CHANGELOG.rst
├── LICENSE
├── MANIFEST.in
├── README.rst
├── setup.py
├── src
│ └── tasks
│ ├── __init__.py
│ ├── api.py
│ ├── cli.py
│ ├── config.py
│ ├── tasksdb_pymongo.py
│ └── tasksdb_tinydb.py
└── tests
├── conftest.py
├── pytest.ini
├── func
│ ├── __init__.py
│ ├── test_add.py
│ └── ...
└── unit
├── __init__.py
├── test_task.py
└── ...
Я включил полный список проекта (за исключением полного списка тестовых файлов), чтобы указать, как тесты вписываются в остальную часть проекта, и указать на несколько файлов, которые имеют ключевое значение для тестирования, а именно conftest.py, pytest.ini, различные __init__.py
файлы и setup.py.
Все тесты хранятся в tests и отдельно от исходных файлов пакета в src. Это не требование pytest, но это лучшая практика.
Все файлы верхнего уровня, CHANGELOG.rst, LICENSE, README.rst, MANIFEST.in, и setup.py, более подробно рассматриваются в Приложении 4, Упаковка и распространение проектов Python, на стр. 175. Хотя setup.py важен для построения дистрибутива из пакета, а также для возможности установить пакет локально, чтобы пакет был доступен для импорта.
Функциональные и модульные тесты разделены на собственные каталоги. Это произвольное решение и не обязательно. Однако организация тестовых файлов в несколько каталогов позволяет легко запускать подмножество тестов. Мне нравится разделять функциональные и модульные тесты, потому что функциональные тесты должны ломаться, только если мы намеренно изменяя функциональность системы, в то время как модульные тесты могут сломаться во время рефакторинга или изменения реализации.
Проект содержит два типа файлов __init__.py
: найденные в каталоге src/
и те, которые находятся в tests/
. Файл src/tasks/__init__.py
сообщает Python, что каталог является пакетом. Он также выступает в качестве основного интерфейса для пакета, когда кто-то использует import tasks
. Он содержит код для импорта определенных функций из api.py
, так что cli.py
и наши тестовые файлы могут обращаться к функциям пакета, например tasks.add()
, вместо того, чтобы выполнять task.api.add ()
. Файлы tests/func/__init__.py
и tests/unit/__init__.py
пусты. Они указывают pytest подняться вверх на один каталог, чтобы найти корень тестового каталога и pytest.ini
-файл.
Файл pytest.ini
не является обязательным. Он содержит общую конфигурацию pytest для всего проекта. В вашем проекте должно быть не более одного из них. Он может содержать директивы, которые изменяют поведение pytest, например, настрйки списка параметров, которые всегда будут использоваться. Вы узнаете все о pytest.ini
в главе 6 «Конфигурация» на стр. 113.
Файл conftest.py также является необязательным. Он считается pytest как “local plugin” и может содержать hook functions и fixtures. Hook functions являются способом вставки кода в часть процесса выполнения pytest для изменения работы pytest. Fixtures — это setup и teardown функции, которые выполняются до и после тестовых функций и могут использоваться для представления ресурсов и данных, используемых тестами. (Fixtures обсуждаются в главе 3, pytest Fixtures, на стр. 49 и главе 4, Builtin Fixtures, на стр. 71, а hook functions бсуждаются в главе 5 «Плагины» на стр. 95.) Hook functions и fixtures, которые используются в тестах в нескольких подкаталогах, должны содержаться в tests/conftest.py. Вы можете иметь несколько файлов conftest.py; например, можно иметь по одному в тестах и по одному для каждой поддиректории tests.
Если вы еще этого не сделали, вы можете загрузить копию исходного кода для этого проекта на веб-сайте книги. Альтернативно, вы можете работать над своим проектом с аналогичной структурой.
Вот test_task.py:
ch2/tasks_proj/tests/unit/test_task.py
"""Test the Task data type."""
# -*- coding: utf-8 -*-
from tasks import Task
def test_asdict():
"""_asdict() должен возвращать словарь."""
t_task = Task('do something', 'okken', True, 21)
t_dict = t_task._asdict()
expected = {'summary': 'do something',
'owner': 'okken',
'done': True,
'id': 21}
assert t_dict == expected
def test_replace():
"""replace () должен изменить переданные данные в полях."""
t_before = Task('finish book', 'brian', False)
t_after = t_before._replace(id=10, done=True)
t_expected = Task('finish book', 'brian', True, 10)
assert t_after == t_expected
def test_defaults():
"""Использование вызова без параметров должно применить значения по умолчанию."""
t1 = Task()
t2 = Task(None, None, False, None)
assert t1 == t2
def test_member_access():
"""Проверка .field функциональность namedtuple."""
t = Task('buy milk', 'brian')
assert t.summary == 'buy milk'
assert t.owner == 'brian'
assert (t.done, t.id) == (False, None)
В файле test_task.py указан этот оператор импорта:
from tasks import Task
Лучший способ позволить тестам импортировать tasks или что-то импортировать из tasks — установить tasks локально с помощью pip. Это возможно, потому что есть файл setup.py для прямого вызова pip.
Установите tasks, запустив pip install .
или pip install -e .
из каталога tasks_proj
. Или другой вариант запустить pip install -e tasks_proj
из каталога на один уровень выше:
$ cd /path/to/code
$ pip install ./tasks_proj/
$ pip install --no-cache-dir ./tasks_proj/
Processing ./tasks_proj
Collecting click (from tasks==0.1.0)
Downloading click-6.7-py2.py3-none-any.whl (71kB)
...
Collecting tinydb (from tasks==0.1.0)
Downloading tinydb-3.4.0.tar.gz
Collecting six (from tasks==0.1.0)
Downloading six-1.10.0-py2.py3-none-any.whl
Installing collected packages: click, tinydb, six, tasks
Running setup.py install for tinydb ... done
Running setup.py install for tasks ... done
Successfully installed click-6.7 six-1.10.0 tasks-0.1.0 tinydb-3.4.0
Если вы хотите только выполнять тесты для tasks, эта команда подойдет. Если вы хотите иметь возможность изменять исходный код во время установки tasks, вам необходимо использовать установку с опцией -e (для editable «редактируемый»):
$ pip install -e ./tasks_proj/
Obtaining file:///path/to/code/tasks_proj
Requirement already satisfied: click in
/path/to/venv/lib/python3.6/site-packages (from tasks==0.1.0)
Requirement already satisfied: tinydb in
/path/to/venv/lib/python3.6/site-packages (from tasks==0.1.0)
Requirement already satisfied: six in
/path/to/venv/lib/python3.6/site-packages (from tasks==0.1.0)
Installing collected packages: tasks
Found existing installation: tasks 0.1.0
Uninstalling tasks-0.1.0:
Successfully uninstalled tasks-0.1.0
Running setup.py develop for tasks
Successfully installed tasks
Теперь попробуем запустить тесты:
$ cd /path/to/code/ch2/tasks_proj/tests/unit
$ pytest test_task.py
===================== test session starts ======================
collected 4 items
test_task.py ....
=================== 4 passed in 0.01 seconds ===================
Импорт сработал! Остальные тесты теперь могут безопасно использовать задачи импорта. Теперь напишем несколько тестов.
Использование операторов assert
Когда вы пишете тестовые функции, обычный оператор Python-а assert является вашим основным инструментом для сообщения о сбое теста. Простота этого в pytest блестящая. Это то, что заставляет многих разработчиков использовать pytest поверх других фреймворков.
Если вы использовали любую другую платформу тестирования, вы, вероятно, видели различные вспомогательные функции assert. Например, ниже приведен список некоторых форм assert и вспомогательных функций assert:
С помощью pytest вы можете использовать assert <выражение> с любым выражением. Если выражение будет вычисляться как False, когда оно будет преобразовано в bool, тест завершится с ошибкой.
pytest включает функцию, называемую assert rewriting, которая перехватывает assert calls и заменяет их тем, что может рассказать вам больше о том, почему ваши утверждения не удались. Давайте посмотрим, насколько полезно это переписывание, если посмотреть на несколько ошибок утверждения:
ch2/tasks_proj/tests/unit/test_task_fail.py
"""Используем the Task type для отображения сбоев тестов."""
from tasks import Task
def test_task_equality():
"""Разные задачи не должны быть равными."""
t1 = Task('sit there', 'brian')
t2 = Task('do something', 'okken')
assert t1 == t2
def test_dict_equality():
"""Различные задачи, сравниваемые как dicts, не должны быть равны."""
t1_dict = Task('make sandwich', 'okken')._asdict()
t2_dict = Task('make sandwich', 'okkem')._asdict()
assert t1_dict == t2_dict
Все эти тесты терпят неудачу, но интересна информация в трассировке:
(venv33) ...bopytest-codecodech2tasks_projtestsunit>pytest test_task_fail.py
============================= test session starts =============================
collected 2 items
test_task_fail.py FF
================================== FAILURES ===================================
_____________________________ test_task_equality ______________________________
def test_task_equality():
"""Different tasks should not be equal."""
t1 = Task('sit there', 'brian')
t2 = Task('do something', 'okken')
> assert t1 == t2
E AssertionError: assert Task(summary=...alse, id=None) == Task(summary='...alse, id=None)
E At index 0 diff: 'sit there' != 'do something'
E Use -v to get the full diff
test_task_fail.py:9: AssertionError
_____________________________ test_dict_equality ______________________________
def test_dict_equality():
"""Different tasks compared as dicts should not be equal."""
t1_dict = Task('make sandwich', 'okken')._asdict()
t2_dict = Task('make sandwich', 'okkem')._asdict()
> assert t1_dict == t2_dict
E AssertionError: assert OrderedDict([...('id', None)]) == OrderedDict([(...('id', None)])
E Omitting 3 identical items, use -vv to show
E Differing items:
E {'owner': 'okken'} != {'owner': 'okkem'}
E Use -v to get the full diff
test_task_fail.py:16: AssertionError
========================== 2 failed in 0.30 seconds ===========================
Вот это да! Это очень много информации. Для каждого неудачного теста точная строка ошибки отображается с помощью > указателя на отказ. Строки E показывают дополнительную информацию о сбое assert, чтобы помочь вам понять, что пошло не так.
Я намеренно поставил два несовпадения в test_task_equality()
, но только первое было показано в предыдущем коде. Давайте попробуем еще раз с флагом -v
, как предложено в сообщении об ошибке :
(venv33) ...bopytest-codecodech2tasks_projtestsunit>pytest -v test_task_fail.py
============================= test session starts =============================
collected 2 items
test_task_fail.py::test_task_equality FAILED
test_task_fail.py::test_dict_equality FAILED
================================== FAILURES ===================================
_____________________________ test_task_equality ______________________________
def test_task_equality():
"""Different tasks should not be equal."""
t1 = Task('sit there', 'brian')
t2 = Task('do something', 'okken')
> assert t1 == t2
E AssertionError: assert Task(summary=...alse, id=None) == Task(summary='...alse, id=None)
E At index 0 diff: 'sit there' != 'do something'
E Full diff:
E - Task(summary='sit there', owner='brian', done=False, id=None)
E ? ^^^ ^^^ ^^^^
E + Task(summary='do something', owner='okken', done=False, id=None)
E ? +++ ^^^ ^^^ ^^^^
test_task_fail.py:9: AssertionError
_____________________________ test_dict_equality ______________________________
def test_dict_equality():
"""Different tasks compared as dicts should not be equal."""
t1_dict = Task('make sandwich', 'okken')._asdict()
t2_dict = Task('make sandwich', 'okkem')._asdict()
> assert t1_dict == t2_dict
E AssertionError: assert OrderedDict([...('id', None)]) == OrderedDict([(...('id', None)])
E Omitting 3 identical items, use -vv to show
E Differing items:
E {'owner': 'okken'} != {'owner': 'okkem'}
E Full diff:
E {'summary': 'make sandwich',
E - 'owner': 'okken',
E ? ^...
E
E ...Full output truncated (5 lines hidden), use '-vv' to show
test_task_fail.py:16: AssertionError
========================== 2 failed in 0.28 seconds ===========================
Ну, я думаю, что это чертовски круто! pytest не только смог найти оба различия, но и показал нам, где именно эти различия. В этом примере используется только equality assert; на веб-сайте pytest.org можно найти еще много разновидностей оператора assert с удивительной информацией об отладке трассировки.
Ожидание Исключений (expected exception)
Исключения(Exceptions) могут возникать в нескольких местах Tasks API. Давайте быстро заглянем в функции, найденные в tasks/api.py:
def add(task): # type: (Task) -> int
def get(task_id): # type: (int) -> Task
def list_tasks(owner=None): # type: (str|None) -> list of Task
def count(): # type: (None) -> int
def update(task_id, task): # type: (int, Task) -> None
def delete(task_id): # type: (int) -> None
def delete_all(): # type: () -> None
def unique_id(): # type: () -> int
def start_tasks_db(db_path, db_type): # type: (str, str) -> None
def stop_tasks_db(): # type: () -> None
Существует соглашение между CLI-кодом в cli.py и кодом API в api.py относительно того, какие типы будут передаваться в функции API. Вызовы API — это место, где я ожидаю, что исключения будут подняты, если тип неверен. Чтобы удостовериться, что эти функции вызывают исключения, если они вызваны неправильно, используйте неправильный тип в тестовой функции, чтобы преднамеренно вызвать исключения TypeError и использовать с pytest.raises (expected exception), например:
ch2/tasks_proj/tests/func/test_api_exceptions.py
"""Проверка на ожидаемые исключения из-за неправильного использования API."""
import pytest
import tasks
def test_add_raises():
"""add() должно возникнуть исключение с неправильным типом param."""
with pytest.raises(TypeError):
tasks.add(task='not a Task object')
В test_add_raises()
, с pytest.raises(TypeError)
: оператор сообщает, что все, что находится в следующем блоке кода, должно вызвать исключение TypeError. Если исключение не вызывается, тест завершается неудачей. Если тест вызывает другое исключение, он завершается неудачей.
Мы только что проверили тип исключения в test_add_raises()
. Можно также проверить параметры исключения. Для start_tasks_db(db_path, db_type)
, не только db_type должен быть строкой, это действительно должна быть либо ‘tiny’ или ‘mongo’. Можно проверить, чтобы убедиться, что сообщение об исключении является правильным, добавив excinfo:
ch2/tasks_proj/tests/func/test_api_exceptions.py
def test_start_tasks_db_raises():
"""Убедитесь, что не поддерживаемая БД вызывает исключение."""
with pytest.raises(ValueError) as excinfo:
tasks.start_tasks_db('some/great/path', 'mysql')
exception_msg = excinfo.value.args[0]
assert exception_msg == "db_type must be a 'tiny' or 'mongo'"
Это позволяет нам более внимательно рассмотреть это исключение. Имя переменной после as (в данном случае excinfo) заполняется сведениями об исключении и имеет тип ExceptionInfo.
В нашем случае, мы хотим убедиться, что первый (и единственный) параметр исключения соответствует строке.
Marking Test Functions
pytest обеспечивает классный механизм, позволяющий помещать маркеры в тестовые функции. Тест может иметь более одного маркера, а маркер может быть в нескольких тестах.
Маркеры обретут для вас смысл после того, как вы увидите их в действии. Предположим, мы хотим запустить подмножество наших тестов в качестве быстрого «smoke test», чтобы получить представление о том, есть ли какой-то серьезный разрыв в системе. Smoke tests по соглашению не являются всеобъемлющими, тщательными наборами тестов, но выбранным подмножеством, которое можно быстро запустить и дать разработчик достойное представление о здоровье всех частей системы.
Чтобы добавить набор тестов smoke в проект Tasks, нужно добавить @mark.pytest.smoke
для некоторых тестов. Давайте добавим его к нескольким тестам test_api_exceptions.py
(обратите внимание, что маркеры smoke и get не встроены в pytest; я просто их придумал):
ch2/tasks_proj/tests/func/test_api_exceptions.py
@pytest.mark.smoke
def test_list_raises():
"""list() должно возникнуть исключение с неправильным типом param."""
with pytest.raises(TypeError):
tasks.list_tasks(owner=123)
@pytest.mark.get
@pytest.mark.smoke
def test_get_raises():
"""get() должно возникнуть исключение с неправильным типом param."""
with pytest.raises(TypeError):
tasks.get(task_id='123')
Теперь давайте выполним только те тесты, которые помечены -m marker_name
:
(venv33) ...bopytest-codecodech2tasks_projtests>cd func
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v -m "smoke" test_api_exceptions.py
============================= test session starts =============================
collected 7 items
test_api_exceptions.py::test_list_raises PASSED
test_api_exceptions.py::test_get_raises PASSED
============================= 5 tests deselected ==============================
=================== 2 passed, 5 deselected in 0.18 seconds ====================
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v -m "get" test_api_exceptions.py
============================= test session starts =============================
collected 7 items
test_api_exceptions.py::test_get_raises PASSED
============================= 6 tests deselected ==============================
=================== 1 passed, 6 deselected in 0.13 seconds ====================
Помните, что -v
сокращенно от --verbose
и позволяет нам видеть имена тестов, которые выполняются. Использование-m ‘smoke’ запускает оба теста, помеченные @pytest.mark.smoke.
Использование -m
‘get’ запустит один тест, помеченный @pytest.mark.get
. Довольно простой.
Все становится чудесатей и чудесатей! Выражение после -m
может использовать and
, or
и not
комбинировать несколько маркеров:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v -m "smoke and get" test_api_exceptions.py
============================= test session starts =============================
collected 7 items
test_api_exceptions.py::test_get_raises PASSED
============================= 6 tests deselected ==============================
=================== 1 passed, 6 deselected in 0.13 seconds ====================
Это мы провели тест только с маркерами smoke
и get
. Мы можем использовать и not
:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v -m "smoke and not get" test_api_exceptions.py
============================= test session starts =============================
collected 7 items
test_api_exceptions.py::test_list_raises PASSED
============================= 6 tests deselected ==============================
=================== 1 passed, 6 deselected in 0.13 seconds ====================
Добавление -m 'smoke and not get'
выбрало тест, который был отмечен с помощью @pytest.mark.smoke
, но не @pytest.mark.get
.
Заполнение Smoke Test
Предыдущие тесты еще не кажутся разумным набором smoke test
. Мы фактически не касались базы данных и не добавляли никаких задач. Конечно smoke test
должен был бы сделать это.
Давайте добавим несколько тестов, которые рассматривают добавление задачи, и используем один из них как часть нашего набора тестов smoke:
ch2/tasks_proj/tests/func/test_add.py
"""Проверьте функцию API tasks.add ()."""
import pytest
import tasks
from tasks import Task
def test_add_returns_valid_id():
"""tasks.add(valid task) должен возвращать целое число."""
# GIVEN an initialized tasks db
# WHEN a new task is added
# THEN returned task_id is of type int
new_task = Task('do something')
task_id = tasks.add(new_task)
assert isinstance(task_id, int)
@pytest.mark.smoke
def test_added_task_has_id_set():
"""Убедимся, что поле task_id установлено tasks.add()."""
# GIVEN an initialized tasks db
# AND a new task is added
new_task = Task('sit in chair', owner='me', done=True)
task_id = tasks.add(new_task)
# WHEN task is retrieved
task_from_db = tasks.get(task_id)
# THEN task_id matches id field
assert task_from_db.id == task_id
Оба этих теста имеют комментарий GIVEN к инициализированной БД tasks, но в тесте нет инициализированной базы данных. Мы можем определить fixture для инициализации базы данных перед тестом и очистки после теста:
ch2/tasks_proj/tests/func/test_add.py
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
"""Connect to db before testing, disconnect after."""
# Setup : start db
tasks.start_tasks_db(str(tmpdir), 'tiny')
yield # здесь происходит тестирование
# Teardown : stop db
tasks.stop_tasks_db()
Фикстура, tmpdir, используемая в данном примере, является встроенной (builtin fixture). Вы узнаете все о встроенных фикстурах в главе 4, Builtin Fixtures, на странице 71, и вы узнаете о написании собственных фикстур и о том, как они работают в главе 3, pytest Fixtures, на странице 49, включая параметр autouse, используемый здесь.
autouse, используемый в нашем тесте, показывает, что все тесты в этом файле будут использовать fixture. Код перед yield
выполняется перед каждым тестом; код после yield
выполняется после теста. При желании yield может возвращать данные в тест. Вы рассмотрите все это и многое другое в последующих главах, но здесь нам нужно каким-то образом настроить базу данных для тестирования, поэтому я больше не могу ждать, и должен показть вам сей прибор (фикстуру конечно!). (pytest также поддерживает старомодные функции setup и teardown, такие как те, что используется в unittest и nose, но они не так интересны. Однако, если вам все же интересно, они описаны в Приложении 5, xUnit Fixtures, на стр. 183.)
Давайте пока отложим обсуждение фикстур и перейдем к началу проекта и запустим наш smoke test suite:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>cd ..
(venv33) ...bopytest-codecodech2tasks_projtests>cd ..
(venv33) ...bopytest-codecodech2tasks_proj>pytest -v -m "smoke"
============================= test session starts =============================
collected 56 items
tests/func/test_add.py::test_added_task_has_id_set PASSED
tests/func/test_api_exceptions.py::test_list_raises PASSED
tests/func/test_api_exceptions.py::test_get_raises PASSED
============================= 53 tests deselected =============================
=================== 3 passed, 53 deselected in 0.49 seconds ===================
Тут показано, что помеченные тесты из разных файлов могут выполняться вместе.
Пропуск Тестов (Skipping Tests)
Хотя маркеры, обсуждаемые в методах проверки маркировки, на стр. 31 были именами по вашему выбору, pytest включает в себя несколько полезных встроенных маркеров: skip
, skipif
, и xfail
. В этом разделе я расскажу про skip
и skipif
, а в следующем -xfail
.
Маркеры skip
и skipif
позволяют пропускать тесты, которые не нужно выполнять. Для примера, допустим, мы не знали, как должна работать tasks.unique_id()
. Каждый вызов её должен возвращает другой номер? Или это просто номер, который еще не существует в базе данных?
Во-первых, давайте напишем тест (заметим, что в этом файле тоже есть фикстура initialized_tasks_db
; просто она здесь не показана):
ch2/tasks_proj/tests/func/
test_unique_id_1.py
"""Test tasks.unique_id()."""
import pytest
import tasks
def test_unique_id():
"""Вызов unique_id () дважды должен возвращать разные числа."""
id_1 = tasks.unique_id()
id_2 = tasks.unique_id()
assert id_1 != id_2
Затем дайте ему выполниться:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest test_unique_id_1.py
============================= test session starts =============================
collected 1 item
test_unique_id_1.py F
================================== FAILURES ===================================
_______________________________ test_unique_id ________________________________
def test_unique_id():
"""Calling unique_id() twice should return different numbers."""
id_1 = tasks.unique_id()
id_2 = tasks.unique_id()
> assert id_1 != id_2
E assert 1 != 1
test_unique_id_1.py:11: AssertionError
========================== 1 failed in 0.30 seconds ===========================
Хм. Может быть, мы ошиблись. Посмотрев на API немного больше, мы видим, что docstring говорит «»»Return an integer that does not exist in the db.»»», что означает Возвращает целое число, которое не существует в DB. Мы могли бы просто изменить тест. Но вместо этого давайте просто отметим первый, который будет пропущен:
ch2/tasks_proj/tests/func/
test_unique_id_2.py
@pytest.mark.skip(reason='misunderstood the API')
def test_unique_id_1():
"""Вызов unique_id () дважды должен возвращать разные числа."""
id_1 = tasks.unique_id()
id_2 = tasks.unique_id()
assert id_1 != id_2
def test_unique_id_2():
"""unique_id() должен вернуть неиспользуемый id."""
ids = []
ids.append(tasks.add(Task('one')))
ids.append(tasks.add(Task('two')))
ids.append(tasks.add(Task('three')))
# захват уникального id
uid = tasks.unique_id()
# убеждаемся, что его нет в списке существующих идентификаторов
assert uid not in ids
Отметить тест, который нужно пропустить, так же просто, как добавить @pytest.mark.skip()
чуть выше тестовой функции.
Повторим :
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v test_unique_id_2.py
============================= test session starts =============================
collected 2 items
test_unique_id_2.py::test_unique_id_1 SKIPPED
test_unique_id_2.py::test_unique_id_2 PASSED
===================== 1 passed, 1 skipped in 0.19 seconds =====================
Теперь предположим, что по какой-то причине мы решили, что первый тест также должен быть действительным, и мы намерены сделать эту работу в версии 0.2.0 пакета. Мы можем оставить тест на месте и использовать вместо этого skipif:
ch2/tasks_proj/tests/func/
test_unique_id_3.py
@pytest.mark.skipif(tasks.__version__ < '0.2.0',
reason='not supported until version 0.2.0')
def test_unique_id_1():
"""Вызов unique_id () дважды должен возвращать разные числа."""
id_1 = tasks.unique_id()
id_2 = tasks.unique_id()
assert id_1 != id_2
Выражение, которое мы передаем в skipif()
, может быть любым допустимым выражением Python. В этом конкретном, нашем случае, мы проверяем версию пакета. Мы включили причины как в skip, так и в skipif. Это не требуется в skip, но это требуется в skipif. Мне нравится включать обоснование причины (reason) для каждого skip, skipif или xfail. Вот вывод измененного кода:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest test_unique_id_3.py
============================= test session starts =============================
collected 2 items
test_unique_id_3.py s.
===================== 1 passed, 1 skipped in 0.20 seconds =====================
s.
показывает, что один тест был пропущен(skipped), и один тест прошел(passed). Мы можем посмотреть, какой из них где-куда опцией -v
:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v test_unique_id_3.py
============================= test session starts =============================
collected 2 items
test_unique_id_3.py::test_unique_id_1 SKIPPED
test_unique_id_3.py::test_unique_id_2 PASSED
===================== 1 passed, 1 skipped in 0.19 seconds =====================
Но мы все еще не знаем почему. Мы можем взглянуть на эти причины с -rs
:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -rs test_unique_id_3.py
============================= test session starts =============================
collected 2 items
test_unique_id_3.py s.
=========================== short test summary info ===========================
SKIP [1] functest_unique_id_3.py:8: not supported until version 0.2.0
===================== 1 passed, 1 skipped in 0.22 seconds =====================
Параметр -r chars
содержит такой текст справки:
$ pytest --help
...
-r chars
show extra test summary info as specified by chars
(показать дополнительную сводную информацию по тесту, обозначенному символами)
(f)ailed, (E)error, (s)skipped, (x)failed, (X)passed,
(p)passed, (P)passed with output, (a)all except pP.
...
Это не только полезно для понимания пробных пропусков, но также вы можете использовать его и для других результатов тестирования.
Маркировка тестов ожидающих сбоя
С помощью маркеров skip
и skipif
тест даже не выполняется, если он пропущен. С помощью маркера xfail
мы указываем pytest запустить тестовую функцию, но ожидаем, что она потерпит неудачу. Давайте изменим наш тест unique_id ()
снова, чтобы использовать xfail
:
ch2/tasks_proj/tests/func/
test_unique_id_4.py
@pytest.mark.xfail(tasks.__version__ < '0.2.0',
reason='not supported until version 0.2.0')
def test_unique_id_1():
"""Вызов unique_id() дважды должен возвращать разные номера."""
id_1 = tasks.unique_id()
id_2 = tasks.unique_id()
assert id_1 != id_2
@pytest.mark.xfail()
def test_unique_id_is_a_duck():
"""Продемонстрирация xfail."""
uid = tasks.unique_id()
assert uid == 'a duck'
@pytest.mark.xfail()
def test_unique_id_not_a_duck():
"""Продемонстрирация xpass."""
uid = tasks.unique_id()
assert uid != 'a duck'
Running this shows:
Первый тест такой же, как и раньше, но с xfail
. Следующие два теста такие же и отличаются только == vs.! =. Поэтому один из них должен пройти.
Выполнение этого показывает:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest test_unique_id_4.py
============================= test session starts =============================
collected 4 items
test_unique_id_4.py xxX.
=============== 1 passed, 2 xfailed, 1 xpassed in 0.36 seconds ================
X для XFAIL, что означает «ожидаемый отказ (expected to fail)». Заглавная X предназначен для XPASS или «ожидается, что он не сработает, но пройдет (expected to fail but passed.)».
--verbose
перечисляет более подробные описания:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v test_unique_id_4.py
============================= test session starts =============================
collected 4 items
test_unique_id_4.py::test_unique_id_1 xfail
test_unique_id_4.py::test_unique_id_is_a_duck xfail
test_unique_id_4.py::test_unique_id_not_a_duck XPASS
test_unique_id_4.py::test_unique_id_2 PASSED
=============== 1 passed, 2 xfailed, 1 xpassed in 0.36 seconds ================
Вы можете настроить pytest так, чтобы тесты, которые прошли, но были помечены xfail
, сообщались как FAIL. Это делается в pytest.ini:
[pytest]
xfail_strict=true
Я буду обсуждать pytest.ini подробнее в главе 6, Конфигурация, на стр. 113.
Выполнение подмножества тестов
Я говорил о том, как вы можете размещать маркеры в тестах и запускать тесты на основе маркеров. Подмножество тестов можно запустить несколькими другими способами. Можно выполнить все тесты или выбрать один каталог, файл, класс в файле или отдельный тест в файле или классе. Вы еще не видели тестовых классов, поэтому посмотрите на них в этом разделе. Можно также использовать выражение для сопоставления имен тестов. Давайте взглянем на это.
A Single Directory
Чтобы запустить все тесты из одного каталога, используйте каталог как параметр для pytest:
(venv33) ...bopytest-codecodech2tasks_proj>pytest testsfunc --tb=no
============================= test session starts =============================
collected 50 items
testsfunctest_add.py ..
testsfunctest_add_variety.py ................................
testsfunctest_api_exceptions.py .......
testsfunctest_unique_id_1.py F
testsfunctest_unique_id_2.py s.
testsfunctest_unique_id_3.py s.
testsfunctest_unique_id_4.py xxX.
==== 1 failed, 44 passed, 2 skipped, 2 xfailed, 1 xpassed in 1.75 seconds =====
Важная хитрость заключается в том, что использование -v
показывает синтаксис для запуска определенного каталога, класса и теста.
(venv33) ...bopytest-codecodech2tasks_proj>pytest -v testsfunc --tb=no
============================= test session starts =============================
…
collected 50 items
testsfunctest_add.py::test_add_returns_valid_id PASSED
testsfunctest_add.py::test_added_task_has_id_set PASSED
testsfunctest_add_variety.py::test_add_1 PASSED
testsfunctest_add_variety.py::test_add_2[task0] PASSED
testsfunctest_add_variety.py::test_add_2[task1] PASSED
testsfunctest_add_variety.py::test_add_2[task2] PASSED
testsfunctest_add_variety.py::test_add_2[task3] PASSED
testsfunctest_add_variety.py::test_add_3[sleep-None-False] PASSED
...
testsfunctest_unique_id_2.py::test_unique_id_1 SKIPPED
testsfunctest_unique_id_2.py::test_unique_id_2 PASSED
...
testsfunctest_unique_id_4.py::test_unique_id_1 xfail
testsfunctest_unique_id_4.py::test_unique_id_is_a_duck xfail
testsfunctest_unique_id_4.py::test_unique_id_not_a_duck XPASS
testsfunctest_unique_id_4.py::test_unique_id_2 PASSED
==== 1 failed, 44 passed, 2 skipped, 2 xfailed, 1 xpassed in 2.05 seconds =====
Вы увидите синтаксис, приведенный здесь в следующих нескольких примерах.
Одиночный тест File/Module
Чтобы запустить файл, полный тестов, перечислите файл с относительным путем в качестве параметра к pytest:
$ cd /path/to/code/ch2/tasks_proj
$ pytest tests/func/test_add.py
=========================== test session starts ===========================
collected 2 items tests/func/test_add.py ..
======================== 2 passed in 0.05 seconds =========================
Мы уже делали это и не один раз.
Одиночная тестовая функция
Чтобы запустить одну тестовую функцию, добавьте ::
и имя тестовой функции:
$ cd /path/to/code/ch2/tasks_proj
$ pytest -v tests/func/test_add.py::test_add_returns_valid_id
=========================== test session starts ===========================
collected 3 items
tests/func/test_add.py::test_add_returns_valid_id PASSED
======================== 1 passed in 0.02 seconds =========================
Используйте -v
, чтобы увидеть, какая функция была запущена.
Одиночный Test Class
Here’s an example:
Тестовые классы — это способ группировать тесты, которые по смыслу группируются вместе.
Вот пример:
ch2/tasks_proj/tests/func/
test_api_exceptions.py
class TestUpdate():
"""Тест ожидаемых исключений с tasks.update()."""
def test_bad_id(self):
"""non-int id должен поднять excption."""
with pytest.raises(TypeError):
tasks.update(task_id={'dict instead': 1},
task=tasks.Task())
def test_bad_task(self):
"""A non-Task task должен поднять excption."""
with pytest.raises(TypeError):
tasks.update(task_id=1, task='not a task')
Так как это два связанных теста, которые оба тестируют функцию update()
, целесообразно сгруппировать их в класс. Чтобы запустить только этот класс, сделайте так же, как мы сделали с функциями и добавьте ::
, затем имя класса в параметр вызова:
(venv33) ...bopytest-codecodech2tasks_proj>pytest -v tests/func/test_api_exceptions.py::TestUpdate
============================= test session starts =============================
collected 2 items
testsfunctest_api_exceptions.py::TestUpdate::test_bad_id PASSED
testsfunctest_api_exceptions.py::TestUpdate::test_bad_task PASSED
========================== 2 passed in 0.12 seconds ===========================
A Single Test Method of a Test Class
Если вы не хотите запускать весь тестовый класс, а только один метод — просто добавьте ещё раз ::
и имя метода:
$ cd /path/to/code/ch2/tasks_proj
$ pytest -v tests/func/test_api_exceptions.py::TestUpdate::test_bad_id
===================== test session starts ======================
collected 1 item
tests/func/test_api_exceptions.py::TestUpdate::test_bad_id PASSED
=================== 1 passed in 0.03 seconds ===================
Синтаксис группировки, отображаемый подробным списком
Помните, что синтаксис для запуска подмножества тестов по каталогу, файлу, функции, классу и методу не нужно запоминать. Формат такой же, как и список тестовых функций при запуске
pytest -v
.
Набор тестов на основе базового имени теста
Параметр -k
позволяет передать выражение для выполнения тестов, имена которых заданы выражением в качестве подстроки имени теста. Для создания сложных выражений можно использовать and
, or
и not
в выражении. Например, мы можем запустить все функции с именем _raises
:
(venv33) ...bopytest-codecodech2tasks_proj>pytest -v -k _raises
============================= test session starts =============================
collected 56 items
tests/func/test_api_exceptions.py::test_add_raises PASSED
tests/func/test_api_exceptions.py::test_list_raises PASSED
tests/func/test_api_exceptions.py::test_get_raises PASSED
tests/func/test_api_exceptions.py::test_delete_raises PASSED
tests/func/test_api_exceptions.py::test_start_tasks_db_raises PASSED
============================= 51 tests deselected =============================
=================== 5 passed, 51 deselected in 0.54 seconds ===================
Мы можем использовать and
и not
что бы исключить test_delete_raises()
из сессии:
(venv33) ...bopytest-codecodech2tasks_proj>pytest -v -k "_raises and not delete"
============================= test session starts =============================
collected 56 items
tests/func/test_api_exceptions.py::test_add_raises PASSED
tests/func/test_api_exceptions.py::test_list_raises PASSED
tests/func/test_api_exceptions.py::test_get_raises PASSED
tests/func/test_api_exceptions.py::test_start_tasks_db_raises PASSED
============================= 52 tests deselected =============================
=================== 4 passed, 52 deselected in 0.44 seconds ===================
В этом разделе вы узнали, как запускать определенные тестовые файлы, каталоги, классы и функции и как использовать выражения с -k
для запуска определенных наборов тестов. В следующем разделе вы узнаете, как одна тестовая функция может превратиться во множество тестовых случаев, позволяя тесту работать несколько раз с различными тестовыми данными.
[Parametrized Testing]: Параметризованное тестирование
Передача отдельных значений через функцию и проверка выходных данных, чтобы убедиться в их правильности, является распространенным явлением в тестировании программного обеспечения. Однако единичного вызова функции с одним набором значений и одной проверкой правильности недостаточно для полной проверки большинства функций. Параметризованное тестирование-это способ отправить несколько наборов данных через один и тот же тест и иметь отчет pytest, если какой-либо из наборов не удался.
Чтобы помочь понять проблему, которую пытается решить параметризованное тестирование, давайте возьмем простой тест для add()
:
ch2/tasks_proj/tests/func/
test_add_variety.py
"""Проверка функции API tasks.add()."""
import pytest
import tasks
from tasks import Task
def test_add_1():
"""tasks.get () использует id, возвращаемый из add() works."""
task = Task('breathe', 'BRIAN', True)
task_id = tasks.add(task)
t_from_db = tasks.get(task_id)
# все, кроме идентификатора, должно быть одинаковым
assert equivalent(t_from_db, task)
def equivalent(t1, t2):
"""Проверяет эквивалентность двух задач."""
# Сравнить все, кроме поля id
return ((t1.summary == t2.summary) and
(t1.owner == t2.owner) and
(t1.done == t2.done))
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
"""Подключает к БД перед тестированием, отключает после."""
tasks.start_tasks_db(str(tmpdir), 'tiny')
yield
tasks.stop_tasks_db()
При создании объекта tasks его полю id
присваивается значение None
. После добавления и извлечения из базы данных будет задано поле id
. Поэтому мы не можем просто использовать ==
, чтобы проверить, правильно ли была добавлена и получена наша задача. Вспомогательная функция equivalent()
проверяет все, кроме поля id
. фикстура autouse
используется, чтобы убедиться, что база данных доступна. Давайте убедимся, что тест прошел:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v test_add_variety.py::test_add_1
============================= test session starts =============================
collected 1 item
test_add_variety.py::test_add_1 PASSED
========================== 1 passed in 0.69 seconds ===========================
Тест кажется допустимым. Тем не менее, это просто проверка одной примерной задачи. Что делать, если мы хотим проверить множество вариантов задачи? Нет проблем. Мы можем использовать @pytest.mark.parametrize(argnames, argvalues)
для передачи множества данных через один и тот же тест, например:
ch2/tasks_proj/tests/func/
test_add_variety.py
@pytest.mark.parametrize('task',
[Task('sleep', done=True),
Task('wake', 'brian'),
Task('breathe', 'BRIAN', True),
Task('exercise', 'BrIaN', False)])
def test_add_2(task):
"""Демонстрирует параметризацию с одним параметром."""
task_id = tasks.add(task)
t_from_db = tasks.get(task_id)
assert equivalent(t_from_db, task)
Первый аргумент parametrize()
— это строка с разделенным запятыми списком имен — ‘task’, в нашем случае. Второй аргумент — это список значений, который в нашем случае представляет собой список объектов Task. pytest будет запускать этот тест один раз для каждой задачи и сообщать о каждом отдельном тесте:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v test_add_variety.py::test_add_2
============================= test session starts =============================
collected 4 items
test_add_variety.py::test_add_2[task0] PASSED
test_add_variety.py::test_add_2[task1] PASSED
test_add_variety.py::test_add_2[task2] PASSED
test_add_variety.py::test_add_2[task3] PASSED
========================== 4 passed in 0.69 seconds ===========================
Использование parametrize()
работает как нам надо. Однако давайте передадим задачи как кортежи, чтобы поглядеть, как будут работать несколько параметров теста:
ch2/tasks_proj/tests/func/
test_add_variety.py
@pytest.mark.parametrize('summary, owner, done',
[('sleep', None, False),
('wake', 'brian', False),
('breathe', 'BRIAN', True),
('eat eggs', 'BrIaN', False),
])
def test_add_3(summary, owner, done):
"""Демонстрирует параметризацию с несколькими параметрами."""
task = Task(summary, owner, done)
task_id = tasks.add(task)
t_from_db = tasks.get(task_id)
assert equivalent(t_from_db, task)
При использовании типов, которые легко преобразовать в строки с помощью pytest, идентификатор теста использует значения параметров в отчете, чтобы сделать его доступным для чтения:
(venv35) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v test_add_variety.py::test_add_3
============================= test session starts =============================
platform win32 -- Python 3.5.2, pytest-3.5.1, py-1.5.3, pluggy-0.6.0 --
cachedir: ...pytest_cache
rootdir: ...bopytest-codecodech2tasks_projtests, inifile: pytest.ini
collected 4 items
test_add_variety.py::test_add_3[sleep-None-False] PASSED [ 25%]
test_add_variety.py::test_add_3[wake-brian-False] PASSED [ 50%]
test_add_variety.py::test_add_3[breathe-BRIAN-True] PASSED [ 75%]
test_add_variety.py::test_add_3[eat eggs-BrIaN-False] PASSED [100%]
========================== 4 passed in 0.37 seconds ===========================
Если хотите, вы можете использовать весь тестовый идентификатор, называемый узлом в терминологии pytest, для повторного запуска теста:
(venv35) c:BOOKbopytest-codecodech2tasks_projtestsfunc>pytest -v test_add_variety.py::test_add_3[sleep-None-False]
============================= test session starts =============================
test_add_variety.py::test_add_3[sleep-None-False] PASSED [100%]
========================== 1 passed in 0.22 seconds ===========================
Обязательно используйте кавычки, если в идентификаторе есть пробелы:
(venv35) c:BOOKbopytest-codecodech2tasks_projtestsfunc>pytest -v "test_add_variety.py::test_add_3[eat eggs-BrIaN-False]"
============================= test session starts =============================
collected 1 item
test_add_variety.py::test_add_3[eat eggs-BrIaN-False] PASSED [100%]
========================== 1 passed in 0.56 seconds ===========================
Теперь вернемся к списку версий задач, но переместим список задач в переменную вне функции:
ch2/tasks_proj/tests/func/
test_add_variety.py
tasks_to_try = (Task('sleep', done=True),
Task('wake', 'brian'),
Task('wake', 'brian'),
Task('breathe', 'BRIAN', True),
Task('exercise', 'BrIaN', False))
@pytest.mark.parametrize('task', tasks_to_try)
def test_add_4(task):
"""Немного разные."""
task_id = tasks.add(task)
t_from_db = tasks.get(task_id)
assert equivalent(t_from_db, task)
Это удобно и код выглядит красиво. Но читаемость вывода трудно интерпретировать:
(venv35) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v test_add_variety.py::test_add_4
============================= test session starts =============================
collected 5 items
test_add_variety.py::test_add_4[task0] PASSED [ 20%]
test_add_variety.py::test_add_4[task1] PASSED [ 40%]
test_add_variety.py::test_add_4[task2] PASSED [ 60%]
test_add_variety.py::test_add_4[task3] PASSED [ 80%]
test_add_variety.py::test_add_4[task4] PASSED [100%]
========================== 5 passed in 0.34 seconds ===========================
Удобочитаемость версии с несколькими параметрами хороша, как и список объектов задачи. Чтобы пойти на компромисс, мы можем использовать необязательный параметр ids для parametrize()
, чтобы сделать наши собственные идентификаторы для каждого набора данных задачи. Параметр ids
должен быть списком строк той же длины, что и количество наборов данных. Однако, поскольку мы присвоили нашему набору данных имя переменной tasks_to_try
, мы можем использовать его для генерации идентификаторов:
ch2/tasks_proj/tests/func/
test_add_variety.py
task_ids = ['Task({},{},{})'.format(t.summary, t.owner, t.done)
for t in tasks_to_try]
@pytest.mark.parametrize('task', tasks_to_try, ids=task_ids)
def test_add_5(task):
"""Demonstrate ids."""
task_id = tasks.add(task)
t_from_db = tasks.get(task_id)
assert equivalent(t_from_db, task)
Давайте запустим это и посмотрим, как это выглядит:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v test_add_variety.py::test_add_5
============================= test session starts =============================
collected 5 items
test_add_variety.py::test_add_5[Task(sleep,None,True)] PASSED
test_add_variety.py::test_add_5[Task(wake,brian,False)0] PASSED
test_add_variety.py::test_add_5[Task(wake,brian,False)1] PASSED
test_add_variety.py::test_add_5[Task(breathe,BRIAN,True)] PASSED
test_add_variety.py::test_add_5[Task(exercise,BrIaN,False)] PASSED
========================== 5 passed in 0.45 seconds ===========================
И эти идентификаторы можно использовать для выполнения тестов:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v "test_add_variety.py::test_add_5[Task(exercise,BrIaN,False)]"
============================= test session starts =============================
collected 1 item
test_add_variety.py::test_add_5[Task(exercise,BrIaN,False)] PASSED
========================== 1 passed in 0.21 seconds ===========================
Нам определенно нужны кавычки для этих идентификаторов; в противном случае круглые и квадратные скобки будут путать shell. Вы также можете применить parametrize()
к классам. При этом одни и те же наборы данных будут отправлены всем методам теста в классе:
ch2/tasks_proj/tests/func/
test_add_variety.py
@pytest.mark.parametrize('task', tasks_to_try, ids=task_ids)
class TestAdd():
"""Демонстрация параметризации тестовых классов."""
def test_equivalent(self, task):
"""Похожий тест, только внутри класса."""
task_id = tasks.add(task)
t_from_db = tasks.get(task_id)
assert equivalent(t_from_db, task)
def test_valid_id(self, task):
"""Мы можем использовать одни и те же данные или несколько тестов."""
task_id = tasks.add(task)
t_from_db = tasks.get(task_id)
assert t_from_db.id == task_id
Вот он в действии:
(venv33) ...bopytest-codecodech2tasks_projtestsfunc>pytest -v test_add_variety.py::TestAdd
============================= test session starts =============================
collected 10 items
test_add_variety.py::TestAdd::test_equivalent[Task(sleep,None,True)] PASSED
test_add_variety.py::TestAdd::test_equivalent[Task(wake,brian,False)0] PASSED
test_add_variety.py::TestAdd::test_equivalent[Task(wake,brian,False)1] PASSED
test_add_variety.py::TestAdd::test_equivalent[Task(breathe,BRIAN,True)] PASSED
test_add_variety.py::TestAdd::test_equivalent[Task(exercise,BrIaN,False)] PASSED
test_add_variety.py::TestAdd::test_valid_id[Task(sleep,None,True)] PASSED
test_add_variety.py::TestAdd::test_valid_id[Task(wake,brian,False)0] PASSED
test_add_variety.py::TestAdd::test_valid_id[Task(wake,brian,False)1] PASSED
test_add_variety.py::TestAdd::test_valid_id[Task(breathe,BRIAN,True)] PASSED
test_add_variety.py::TestAdd::test_valid_id[Task(exercise,BrIaN,False)] PASSED
========================== 10 passed in 1.16 seconds ==========================
Вы также можете идентифицировать параметры, включив идентификатор рядом со значением параметра при передаче списка в декоратор @pytest.mark.parametrize()
. Вы делаете это с помощью синтаксиса pytest.param(<value>, id="something")
:
В действии:
(venv35) ...bopytest-codecodech2tasks_projtestsfunc
$ pytest -v test_add_variety.py::test_add_6
======================================== test session starts =========================================
collected 3 items
test_add_variety.py::test_add_6[just summary] PASSED [ 33%]
test_add_variety.py::test_add_6[summaryowner] PASSED [ 66%]
test_add_variety.py::test_add_6[summaryownerdone] PASSED [100%]
================================ 3 passed, 6 warnings in 0.35 seconds ================================
Это полезно, когда id
не может быть получен из значения параметра.
Упражнения
- Загрузите проект для этой главы,
task_proj
, с веб-страницы этой главы и убедитесь, что вы можете установить его локально с помощьюpip install /path/to/tasks_proj
. - Изучите каталог тестов.
- Запустите pytest с одним файлом.
- Запускать pytest против одного каталога, например
tasks_proj/tests/func
. Используйте pytest для запуска тестов по отдельности, а также полный каталог одновременно. Там есть несколько неудачных тестов. Вы понимаете, почему они терпят неудачу? - Добавляйте xfail или пропускайте маркеры к ошибочным тестам, пока не сможете запустить pytest из каталога tests без аргументов и ошибок.
- У нас нет тестов для
tasks.count()
, среди прочих функций. Выберите непроверенную функцию API и подумайте, какие тестовые случаи нам нужны, чтобы убедиться, что она работает правильно. - Что произойдет при попытке добавить задачу с уже установленным идентификатором? Есть некоторые отсутствующие тесты исключения в
test_api_exceptions.py
. Посмотрите, можете ли вы заполнить недостающие исключения. (Это нормально посмотретьapi.py
для этого упражнения.)
Что дальше
В этой главе вы не увидели многих возможностей pytest. Но, даже с тем, что здесь описано, вы можете начать грузить свои тестовые комплекты. Во многих примерах вы использовали фикстуру с именем initialized_tasks_db
. Фикстуры могут отделять полученные и/или генерированные тестовые данные от реальных внутренностей тестовой функции.
Они также могут отделить общий код, чтобы несколько тестовых функций могли использовать одну и ту же настройку. В следующей главе вы глубоко погрузитесь в чудесный мир фикстур pytest.
Вернуться Дальше
Тестирование, это основа серьёзной разработки программного обеспечения. Существует много видов тестирования, но наиболее важный вид, это модульное тестирование. Модульное тестирование даёт уверенность в том, что вы сможете использовать хорошо протестированные блоки в качестве базовых элементов, полагаться на них и использовать при создании программы. Они увеличивают ваш инструментарий из проверенного кода за пределами ваших конструкций и стандартной библиотеки. Кроме того Python предоставляет отличную поддержку для написания модульных тестов.
Действующий пример
Прежде чем погрузиться в принципы, эвристики и руководства, давайте посмотрим репрезентативный модульный тест в действии. Класс SelfDrivingCar
это частичное выполнение логики вождения автопилота автомобиля. Главным образом он контролирует скорость автомобиля. Он рспознаёт объекты впереди, ограничение скорости, а также прибытие или нет в пункт назначения.
1 |
class SelfDrivingCar(object): |
2 |
|
3 |
def __init__(self): |
4 |
|
5 |
self.speed = 0 |
6 |
|
7 |
self.destination = None |
8 |
|
9 |
|
10 |
|
11 |
def _accelerate(self): |
12 |
|
13 |
self.speed += 1 |
14 |
|
15 |
|
16 |
|
17 |
def _decelerate(self): |
18 |
|
19 |
if self.speed > 0: |
20 |
|
21 |
self.speed -= 1 |
22 |
|
23 |
|
24 |
|
25 |
def _advance_to_destination(self): |
26 |
|
27 |
distance = self._calculate_distance_to_object_in_front() |
28 |
|
29 |
if distance < 10: |
30 |
|
31 |
self.stop() |
32 |
|
33 |
|
34 |
|
35 |
elif distance < self.speed / 2: |
36 |
|
37 |
self._decelerate() |
38 |
|
39 |
elif self.speed < self._get_speed_limit(): |
40 |
|
41 |
self._accelerate() |
42 |
|
43 |
|
44 |
|
45 |
def _has_arrived(self): |
46 |
|
47 |
pass
|
48 |
|
49 |
|
50 |
|
51 |
def _calculate_distance_to_object_in_front(self): |
52 |
|
53 |
pass
|
54 |
|
55 |
|
56 |
|
57 |
def _get_speed_limit(self): |
58 |
|
59 |
pass
|
60 |
|
61 |
|
62 |
|
63 |
def stop(self): |
64 |
|
65 |
self.speed = 0 |
66 |
|
67 |
|
68 |
|
69 |
def drive(self, destination): |
70 |
|
71 |
self.destination = destination |
72 |
|
73 |
while not self._has_arrived(): |
74 |
|
75 |
self._advance_to_destination() |
76 |
|
77 |
|
78 |
self.stop() |
79 |
|
80 |
def __init__(self): |
81 |
|
82 |
self.speed = 0 |
83 |
|
84 |
self.destination = None |
85 |
|
86 |
|
87 |
|
88 |
def _accelerate(self): |
89 |
|
90 |
self.speed += 1 |
91 |
|
92 |
|
93 |
|
94 |
def _decelerate(self): |
95 |
|
96 |
if self.speed > 0: |
97 |
|
98 |
self.speed -= 1 |
99 |
|
100 |
|
101 |
|
102 |
def _advance_to_destination(self): |
103 |
|
104 |
distance = self._calculate_distance_to_object_in_front() |
105 |
|
106 |
if distance < 10: |
107 |
|
108 |
self.stop() |
109 |
|
110 |
|
111 |
|
112 |
elif distance < self.speed / 2: |
113 |
|
114 |
self._decelerate() |
115 |
|
116 |
elif self.speed < self._get_speed_limit(): |
117 |
|
118 |
self._accelerate() |
119 |
|
120 |
|
121 |
|
122 |
def _has_arrived(self): |
123 |
|
124 |
pass
|
125 |
|
126 |
|
127 |
|
128 |
def _calculate_distance_to_object_in_front(self): |
129 |
|
130 |
pass
|
131 |
|
132 |
|
133 |
|
134 |
def _get_speed_limit(self): |
135 |
|
136 |
pass
|
137 |
|
138 |
|
139 |
|
140 |
def stop(self): |
141 |
|
142 |
self.speed = 0 |
143 |
|
144 |
|
145 |
|
146 |
def drive(self, destination): |
147 |
|
148 |
self.destination = destination |
149 |
|
150 |
while not self._has_arrived(): |
151 |
|
152 |
self._advance_to_destination() |
153 |
|
154 |
self.stop() |
155 |
Вот модульный тест для метода stop()
чтобы раззадорить ваш аппетит. Я расскажу подробности позже.
1 |
from unittest import TestCase |
2 |
|
3 |
|
4 |
|
5 |
class SelfDrivingCarTest(TestCase): |
6 |
|
7 |
def setUp(self): |
8 |
|
9 |
self.car = SelfDrivingCar() |
10 |
|
11 |
|
12 |
|
13 |
def test_stop(self): |
14 |
|
15 |
self.car.speed = 5 |
16 |
|
17 |
self.car.stop() |
18 |
|
19 |
# Verify the speed is 0 after stopping
|
20 |
|
21 |
self.assertEqual(0, self.car.speed) |
22 |
|
23 |
|
24 |
|
25 |
# Verify it is Ok to stop again if the car is already stopped
|
26 |
|
27 |
self.car.stop() |
28 |
|
29 |
self.assertEqual(0, self.car.speed) |
Руководство по модульному тестированию
Основные идеи
Написание хороших модульных тестов это тяжелый труд. Написание модульных тестов занимает время. Когда вы меняете код, необходимо изменять тесты. Иногда в вашем тесте будут ошибки. Это означает, что вы должны быть по-настоящему идейным. Польза огромна, даже для небольших проектов, но это не бесплатно.
Будьте дисциплинированы
Вы должны быть дисциплинированным. Будьте последовательным. Убедитесь, что все тесты выполнены. Не отказывайтесь от тестов, только потому что вы «знаете», что код в порядке.
Автоматизируйте
Чтобы помочь вам быть дисциплинированным, необходимо автоматизировать модульные тесты. Тесты должны запускаться автоматически на значимых этапах, таких как проектирование или развертывание. В идеале ваша система управления версиями должна отклонять код, который не прошел все тесты.
Непротестированный код плохой по определению
Если вы не проверили его, вы не сможете сказать, что он работает. Это значит, что вы должны рассматривать его как плохой. Если это критический код, не разворачивайте его в производство.
Пояснения
Что такое модуль?
Модуль в смысле тестирования это файл, содержащий набор определённых функций или класс. Если у вас есть файл с несколькими классами, вы должны написать модульный тест для каждого из них.
Делать TDD или не делать TDD
Тест драйв разработка, это практика, где вы пишете тесты, до того как вы пишете код. Есть несколько преимуществ этого подхода, но я рекомендую отказаться от него, если у вас есть возможность написать тесты позже.
Причина заключается в том, что я проектирую код. Я пишу код, смотрю на него, переписываю, еще раз смотрю и быстро переписываю ещё раз. Написание тестов сначала ограничивает и замедляет меня.
После того, как я сделаю первоначальный дизайн, я сразу напишу тесты, до интеграции со всей системой. Тем не менее, это отличный способ найти себя в создании модульных тестов, и это гарантирует, что весь ваш код будет проверен.
Unittest модуль
Модуль Unittest поставляется со стандартной библиотекой Python. Она предоставляет собой класс под названием TestCase
, из которого можно вызвать ваш класс. Затем можно переопределить метод setUp()
чтобы подготовить среду до начала тестирования и/или метод класса classSetUp()
чтобы подготовить среду для всех тестов (не очищающуюся между разными тестами). Существуют соответствующие методы tearDown()
и classTearDown()
, которые также можно переопределить.
Ниже приведены соответствующие разделы из нашего класса SelfDrivingCarTest
. Я использую только метод setUp()
. Я создаю новый экземпляр SelfDrivingCar
и сохраняю его в self.car
, поэтому он доступен для каждого теста.
1 |
from unittest import TestCase |
2 |
|
3 |
|
4 |
|
5 |
class SelfDrivingCarTest(TestCase): |
6 |
|
7 |
def setUp(self): |
8 |
|
9 |
self.car = SelfDrivingCar() |
Следующий шаг — написать специфические методы теста для тестирования кода внутри теста — в этом случае класс SelfDrivingCar
— делает то, что он должен делать. Структура тестового метода довольно обычная:
- Подготовка среды (необязательно).
- Подготовьте ожидаемый результат.
- Вызовите код теста.
- Убедитесь, что фактический результат совпадает с ожидаемым результатом.
Обратите внимание, что результат не должен быть результатом метода. Он может быть изменением состояния класса, сторонним эффектом, например как добавление новой строки в базе данных, записью файла или отправкой сообщения по электронной почте.
Например метод stop()
класса SelfDrivingCar
не возвращает ничего, но он меняет внутреннее состояние, устанавливая скорость на 0. Метод assertEqual()
, предоставляемый базовым классом TestCase
используется здесь для проверки, того что вызов stop()
работает, как и требуется.
1 |
def test_stop(self): |
2 |
|
3 |
self.car.speed = 5 |
4 |
|
5 |
self.car.stop() |
6 |
|
7 |
# Verify the speed is 0 after stopping
|
8 |
|
9 |
self.assertEqual(0, self.car.speed) |
10 |
|
11 |
|
12 |
|
13 |
# Verify it is Ok to stop again if the car is already stopped
|
14 |
|
15 |
self.car.stop() |
16 |
|
17 |
self.assertEqual(0, self.car.speed) |
Здесь на самом деле два теста. Первый тест, чтобы убедиться, что если скорость автомобиля равна 5 и stop()
вызывается, то скорость становится равна 0. И еще один тест, чтобы убедиться, что ничего не случится, если вызвать stop()
снова, когда автомобиль уже остановился.
Позже я расскажу о других тестах для дополнительных функциональных возможностей.
Doctest модуль
Doctest модуль очень интерестный. Он позволяет использовать интерактивные примеры в docstring и проверять результаты, включая исключения.
Я не применяют и не рекомендую использовать doctest для крупномасштабных систем. Хорошее тестирование требует много труда. Тест-код обычно намного больше, чем тестируемый код. Docstrings — это не совсем подходящий инструмент для написания комплексных тестов. Хотя они классные Вот как выглядит factorial
функция для doc-тестов:
1 |
import math |
2 |
|
3 |
|
4 |
|
5 |
def factorial(n): |
6 |
|
7 |
"""Return the factorial of n, an exact integer >= 0.
|
8 |
|
9 |
|
10 |
|
11 |
If the result is small enough to fit in an int, return an int.
|
12 |
|
13 |
Else return a long.
|
14 |
|
15 |
|
16 |
|
17 |
>>> [factorial(n) for n in range(6)]
|
18 |
|
19 |
[1, 1, 2, 6, 24, 120]
|
20 |
|
21 |
>>> [factorial(long(n)) for n in range(6)]
|
22 |
|
23 |
[1, 1, 2, 6, 24, 120]
|
24 |
|
25 |
>>> factorial(30)
|
26 |
|
27 |
265252859812191058636308480000000L
|
28 |
|
29 |
>>> factorial(30L)
|
30 |
|
31 |
265252859812191058636308480000000L
|
32 |
|
33 |
>>> factorial(-1)
|
34 |
|
35 |
Traceback (most recent call last):
|
36 |
|
37 |
...
|
38 |
|
39 |
ValueError: n must be >= 0
|
40 |
|
41 |
|
42 |
|
43 |
Factorials of floats are OK, but the float must be an exact integer:
|
44 |
|
45 |
>>> factorial(30.1)
|
46 |
|
47 |
Traceback (most recent call last):
|
48 |
|
49 |
...
|
50 |
|
51 |
ValueError: n must be exact integer
|
52 |
|
53 |
>>> factorial(30.0)
|
54 |
|
55 |
265252859812191058636308480000000L
|
56 |
|
57 |
|
58 |
|
59 |
It must also not be ridiculously large:
|
60 |
|
61 |
>>> factorial(1e100)
|
62 |
|
63 |
Traceback (most recent call last):
|
64 |
|
65 |
...
|
66 |
|
67 |
OverflowError: n too large
|
68 |
|
69 |
"""
|
70 |
|
71 |
if not n >= 0: |
72 |
|
73 |
raise ValueError("n must be >= 0") |
74 |
|
75 |
if math.floor(n) != n: |
76 |
|
77 |
raise ValueError("n must be exact integer") |
78 |
|
79 |
if n+1 == n: # catch a value like 1e300 |
80 |
|
81 |
raise OverflowError("n too large") |
82 |
|
83 |
result = 1 |
84 |
|
85 |
factor = 2 |
86 |
|
87 |
while factor <= n: |
88 |
|
89 |
result *= factor |
90 |
|
91 |
factor += 1 |
92 |
|
93 |
return result |
94 |
|
95 |
|
96 |
|
97 |
|
98 |
|
99 |
if __name__ == "__main__": |
100 |
|
101 |
import doctest |
102 |
|
103 |
doctest.testmod() |
Как вы видите, docstring намного больше, чем код функции. Это не улучшает читаемость кода.
Запуск тестов
OK. Вы написали модульные тесты. Для большой системы у вас будет десятки / сотни / тысячи модулей и классов, возможно,размещенных в разных папках. Как вы будете запускаете все тесты?
Модуль unittest дает различные возможности для проведения групповых тестов и их программирования. Проверка Загрузки и Выполнения тестов /Loading and Running Tests. Но самый простой способ — открытие теста. Данный параметр был добавлен только в Python 2.7. В Pre-2.7 вы могли использовать nose, чтобы найти и запустить тесты. У nose есть несколько других преимуществ, таких как запуск тестовых функций без необходимости создания класса для ваших тестовых случаев. Но для целей в этой статьи, давайте придерживаться unittest.
Чтобы найти и запустить тесты на основе unittest, просто введите в командной строке:
python -m unittest discover
Unittest будет проверять все файлы и подкаталоги, запускать все найденные тесты и обеспечит хороший отчет, а также покажет время выполнения. Если вы хотите увидеть, какие тесты выполняются, вы можете добавить флаг -v:
python -m unittest discover -v
Существует несколько флагов, которые управляют операцией:
1 |
python -m unittest -h |
2 |
|
3 |
Usage: python -m unittest [options] [tests] |
4 |
|
5 |
|
6 |
|
7 |
Options: |
8 |
|
9 |
-h, --help Show this message |
10 |
|
11 |
-v, --verbose Verbose output |
12 |
|
13 |
-q, --quiet Minimal output |
14 |
|
15 |
-f, --failfast Stop on first failure |
16 |
|
17 |
-c, --catch Catch control-C and display results |
18 |
|
19 |
-b, --buffer Buffer stdout and stderr during test runs |
20 |
|
21 |
|
22 |
|
23 |
Examples: |
24 |
|
25 |
python -m unittest test_module - run tests from test_module |
26 |
|
27 |
python -m unittest module.TestClass - run tests from module.TestClass |
28 |
|
29 |
python -m unittest module.Class.test_method - run specified test method |
30 |
|
31 |
|
32 |
|
33 |
[tests] can be a list of any number of test modules, classes and test |
34 |
|
35 |
methods. |
36 |
|
37 |
|
38 |
|
39 |
Alternative Usage: python -m unittest discover [options] |
40 |
|
41 |
|
42 |
|
43 |
Options: |
44 |
|
45 |
-v, --verbose Verbose output |
46 |
|
47 |
-f, --failfast Stop on first failure |
48 |
|
49 |
-c, --catch Catch control-C and display results |
50 |
|
51 |
-b, --buffer Buffer stdout and stderr during test runs |
52 |
|
53 |
-s directory Directory to start discovery ('.' default) |
54 |
|
55 |
-p pattern Pattern to match test files ('test*.py' default) |
56 |
|
57 |
-t directory Top level directory of project (default to |
58 |
|
59 |
start directory) |
60 |
|
61 |
|
62 |
|
63 |
For test discovery all test modules must be importable from the top |
64 |
|
65 |
level directory of the project. |
Определение степени покрытия кода
Определение степени покрытия кода часто игнорируют. Само понятие означает, сколько кода действительно проверено тестами. Например, если у вас есть функция с инструкцией if-else
, и вы проверяете только ветвьif
, то вы не знаете, работает ли ветка else
или нет. В следующем примере кода функция add()
проверяет тип своих аргументов. Если оба являются целыми числами, они просто добавляют их.
Если оба являются строками, то он пытается преобразовать их в целые числа и добавить. В противном случае он вызывает исключение. Функция test_add()
проверяет функцию add()
с аргументами, которые являются как целыми числами, так и аргументами, которые являются плавающим значением и проверяют правильное поведение в каждом случае. Но степень покрытия теста является незавершенной. В случае, если строковые аргументы были не протестированы. В результате тест проходит успешно, но ошибка в ветке, где аргументы были строками, не были обнаружены (см. «intg»?).
1 |
import unittest |
2 |
|
3 |
|
4 |
|
5 |
def add(a, b): |
6 |
|
7 |
"""This function adds two numbers a, b and returns their sum
|
8 |
|
9 |
|
10 |
|
11 |
a and b may integers
|
12 |
|
13 |
"""
|
14 |
|
15 |
if isinstance(a, int) and isinstance(b, int): |
16 |
|
17 |
return a + b |
18 |
|
19 |
elseif isinstance(a, str) and isinstance(b, str): |
20 |
|
21 |
return int(a) + intg(b) |
22 |
|
23 |
else: |
24 |
|
25 |
raise Exception('Invalid arguments') |
26 |
|
27 |
|
28 |
|
29 |
class Test(unittest.TestCase): |
30 |
|
31 |
def test_add(self): |
32 |
|
33 |
self.assertEqual(5, add(2, 3)) |
34 |
|
35 |
self.assertEqual(15, add(-6, 21)) |
36 |
|
37 |
self.assertRaises(Exception, add, 4.0, 5.0) |
38 |
|
39 |
|
40 |
|
41 |
unittest.main() |
Вот вывод:
1 |
---------------------------------------------------------------------- |
2 |
|
3 |
Ran 1 test in 0.000s |
4 |
|
5 |
|
6 |
|
7 |
OK |
8 |
|
9 |
|
10 |
|
11 |
Process finished with exit code 0 |
Практические Unit Tests
Написание тестов промышленной мощности нелегкая и непростая задача. Есть несколько вещей, которые нужно учитывать и компромиссы, на которые следует пойти.
Разработка тестирования
Если ваш код — это мешанина или набор нелепых строк, где разные уровни абстракции смешиваются друг с другом, и каждый фрагмент кода зависит от другого кода, вам будет трудно его протестировать. Кроме того, всякий раз, когда вы что-то будете менять, вам также придется обновить кучу тестов.
Хорошей новостью является то, что разработка программного обеспечения общего назначения — это именно то, что вам нужно для проверки. В частности, хорошо продуманный модульный код, в котором каждый компонент несет четкую ответственность и взаимодействует с другими компонентами через четко определенные интерфейсы, создаст удобные модульные тесты.
Например, наш класс SelfDrivingCar
отвечает за высокоуровневую работу автомобиля: ехать, останавливаться, перемещаться. Он имеет метод calculate_distance_to_object_in_front()
, который еще не реализован. Эта функциональность, вероятно, должна быть реализована полностью отдельной подсистемой. Он может включать в себя считывание данных с различных датчиков, взаимодействие с другими автомобилями с помощью самостоятельного управления, целый стек «машинного зрения» для анализа изображений с нескольких камер.
Давайте посмотрим, как это работает на практике. SelfDrivingCar
примет аргумент, называемый object_detector,
который имеет метод calculate_distance_to_object_in_front()
, и он делегирует эту функцию объекту. Теперь нет необходимости в проверке, потому что object_detector
отвечает (и должен быть протестирован) за него. Вам по-прежнему нужно, чтобы модуль тестировал то, что вы правильно используете object_detector
.
1 |
class SelfDrivingCar(object): |
2 |
|
3 |
def __init__(self, object_detector): |
4 |
|
5 |
self.object_detector |
6 |
|
7 |
self.speed = 0 |
8 |
|
9 |
self.destination = None |
10 |
|
11 |
|
12 |
|
13 |
def _calculate_distance_to_object_in_front(self): |
14 |
|
15 |
return self.object_detector.calculate_distance_to_object_in_front() |
Затраты и выгоды
Количество усилий, которые вы вкладываете в тестирование, должно быть сопоставимо с затратами на неудачу, насколько стабильным является код и насколько легко его можно исправить, если проблемы обнаружены в строке.
Например, наш класс автопилота автомобилей критически важен. Если метод stop()
не работает должным образом, наш автомобиль может убить людей, уничтожить имущество и пустить под откос другие автомобили. Если вы создаете автопилот для автомобиль, я подозреваю, предполагаю, что ваши юнит-тесты для метода stop()
будут более тщательными, чем мои.
С другой стороны, если одна кнопка в вашем веб-приложении на странице, которая расположена тремя уровнями ниже главной страницы, немного мерцает, когда кто-то ее щелкает, вы можете это исправить, но, вероятно, вы не будете добавлять отдельный модульный тест для этого случая. Данный метод не оправдывает себя экономически.
Мышление тестирования
Мышление тестирования очень важно. Один из принципов, который я использую, состоит в том, что каждый кусок кода имеет как минимум двух пользователей: код, который использует его, и пользователь, который его тестирует. Это простое правило помогает с разработкой и зависимостей. Если вы помните, что вам нужно написать тест для своего кода, вы не добавите много зависимостей, которые трудно восстановить во время тестирования.
Например, предположим, что ваш код должен что-то вычислять. Для этого ему необходимо загрузить данные из базы, прочитать файл конфигурации и динамически обратиться к некоторыми REST API для получения актуальной информации. Все это может потребоваться по разным причинам, но при этом, ели вы разместите всё это в одной функции, это очень затруднит тестирование. Это возможно звучит смешно, но гораздо лучше изначально структурировать ваш код.
Чистые функции
Самым простым кодом для тестирования являются чистые функции. Чистые функции — это функции, которые имеют доступ только к значениям их параметров, не имеют побочных эффектов и возвращают один и тот же результат при вызове с теми же аргументами. Они не изменяют состояние вашей программы, не получают доступа к файловой системе или сети. Их преимуществ слишком много, чтобы их сейчас перечислять.
Почему их легко проверить? Потому что нет необходимости устанавливать специальную среду для их тестирования. Вы просто передаете аргументы и проверяете результат. Вы также знаете, что до тех пор, пока тестируемый код не изменится, ваш тест тоже не должен изменяться.
Сравните его с функцией, которая считывает XML-файл конфигурации. Ваш тест должен будет создать XML-файл и передать его имя файла в тестируемый код. Не трудная задача. Но предположим, что кто-то решил, что XML просто ужасен, и все файлы конфигурации должны находиться в JSON. Они занимаются своим делом и конвертируют все файлы конфигурации в JSON. Они проводят все тесты, включая ваши, и все они успешно завершаются!
Почему? Потому что код не изменился. Он все еще ожидает файл конфигурации XML, и ваш тест по-прежнему создает XML-файл для него. Но при выполнении ваш код получит файл JSON, который он не сможет проанализировать.
Обработка ошибок тестирования
Обработка ошибок — это еще одна вещь, которая важна для тестирования. Она также является частью разработки. Кто несет ответственность за правильность ввода? Каждая функция и метод должны быть понятны. Если это ответственность функции, то она должна проверять вводные данные, но если это ответственность клиента, то функция выполняется, предполагая, что вводные данные правильные. Общая точность системы будет обеспечена путем тестирования для клиента, для того, чтобы убедиться, что он передает правильные данные в вашу функцию.
Как правило, вы хотите проверить ввод в общедоступном интерфейсе, потому что вам не обязательно знаеть, кто будет обращаться к вашему коду. Давайте посмотрим на метод drive()
автопилота автомобиля. Этот метод ожидает параметр «destination». Параметр «destination» будет использоваться позже в навигации, но метод «drive» ничего не делает, чтобы мы смогли убедиться в его корректности.
Предположим, что цель должна быть параметрами широты и долготы. Существуют всевозможные тесты, которые можно выполнить, чтобы убедиться, что они действуют(например, определить пункт назначения в середине моря). Для наших целей давайте просто убедимся, что это параметры в диапазоне от 0,0 до 90,0 по широте и от -180,0 до 180,0 по долготе.
Вот обновленный класс SelfDrivingCar
. Я просто выполнил некоторые из нереализованных методов, потому что метод drive()
вызывает некоторые из этих методов прямо или косвенно.
1 |
class SelfDrivingCar(object): |
2 |
|
3 |
def __init__(self, object_detector): |
4 |
|
5 |
self.object_detector = object_detector |
6 |
|
7 |
self.speed = 0 |
8 |
|
9 |
self.destination = None |
10 |
|
11 |
|
12 |
|
13 |
def _accelerate(self): |
14 |
|
15 |
self.speed += 1 |
16 |
|
17 |
|
18 |
|
19 |
def _decelerate(self): |
20 |
|
21 |
if self.speed > 0: |
22 |
|
23 |
self.speed -= 1 |
24 |
|
25 |
|
26 |
|
27 |
def _advance_to_destination(self): |
28 |
|
29 |
distance = self._calculate_distance_to_object_in_front() |
30 |
|
31 |
if distance < 10: |
32 |
|
33 |
self.stop() |
34 |
|
35 |
|
36 |
|
37 |
elif distance < self.speed / 2: |
38 |
|
39 |
self._decelerate() |
40 |
|
41 |
elif self.speed < self._get_speed_limit(): |
42 |
|
43 |
self._accelerate() |
44 |
|
45 |
|
46 |
|
47 |
def _has_arrived(self): |
48 |
|
49 |
return True |
50 |
|
51 |
|
52 |
|
53 |
def _calculate_distance_to_object_in_front(self): |
54 |
|
55 |
return self.object_detector.calculate_distance_to_object_in_front() |
56 |
|
57 |
|
58 |
|
59 |
def _get_speed_limit(self): |
60 |
|
61 |
return 65 |
62 |
|
63 |
|
64 |
|
65 |
def stop(self): |
66 |
|
67 |
self.speed = 0 |
68 |
|
69 |
|
70 |
|
71 |
def drive(self, destination): |
72 |
|
73 |
self.destination = destination |
74 |
|
75 |
while not self._has_arrived(): |
76 |
|
77 |
self._advance_to_destination() |
78 |
|
79 |
self.stop() |
Чтобы проверить, как обработались ошибки в тесте, я передаю неверные аргументы и думаю, что они должным образом будут отвергнуты. Вы можете сделать это, используя self.assertRaises()
метод unittest.TestCase.
Этот метод очень удачный, если код под тестированием действительно вызывает исключение.
Давайте посмотрим на это в действии. Метод test_drive()
пропускает широту и долготу вне допустимого диапазона и ждет, что метод drive()
вызовет исключение.
1 |
from unittest import TestCase |
2 |
|
3 |
from self_driving_car import SelfDrivingCar |
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
class MockObjectDetector(object): |
10 |
|
11 |
def calculate_distance_to_object_in_front(self): |
12 |
|
13 |
return 20 |
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
class SelfDrivingCarTest(TestCase): |
20 |
|
21 |
def setUp(self): |
22 |
|
23 |
self.car = SelfDrivingCar(MockObjectDetector()) |
24 |
|
25 |
|
26 |
|
27 |
def test_stop(self): |
28 |
|
29 |
self.car.speed = 5 |
30 |
|
31 |
self.car.stop() |
32 |
|
33 |
# Verify the speed is 0 after stopping
|
34 |
|
35 |
self.assertEqual(0, self.car.speed) |
36 |
|
37 |
|
38 |
|
39 |
# Verify it is Ok to stop again if the car is already stopped
|
40 |
|
41 |
self.car.stop() |
42 |
|
43 |
self.assertEqual(0, self.car.speed) |
44 |
|
45 |
|
46 |
|
47 |
def test_drive(self): |
48 |
|
49 |
# Valid destination
|
50 |
|
51 |
self.car.drive((55.0, 66.0)) |
52 |
|
53 |
|
54 |
|
55 |
# Invalid destination wrong range
|
56 |
|
57 |
self.assertRaises(Exception, self.car.drive, (-55.0, 200.0)) |
Тест не выполняется, поскольку метод drive ()
не проверяет его аргументы на достоверность и не вызывает исключения. Вы получите хороший отчет с полной информацией о том, что не удалось, где и почему.
1 |
python -m unittest discover -v |
2 |
|
3 |
test_drive (untitled.test_self_driving_car.SelfDrivingCarTest) ... FAIL |
4 |
|
5 |
test_stop (untitled.test_self_driving_car.SelfDrivingCarTest) ... ok |
6 |
|
7 |
|
8 |
|
9 |
====================================================================== |
10 |
|
11 |
FAIL: test_drive (untitled.test_self_driving_car.SelfDrivingCarTest) |
12 |
|
13 |
---------------------------------------------------------------------- |
14 |
|
15 |
Traceback (most recent call last): |
16 |
|
17 |
File "/Users/gigi/PycharmProjects/untitled/test_self_driving_car.py", line 29, in test_drive |
18 |
|
19 |
self.assertRaises(Exception, self.car.drive, (-55.0, 200.0)) |
20 |
|
21 |
AssertionError: Exception not raised |
22 |
|
23 |
|
24 |
|
25 |
---------------------------------------------------------------------- |
26 |
|
27 |
Ran 2 tests in 0.000s |
28 |
|
29 |
|
30 |
|
31 |
FAILED (failures=1) |
Чтобы исправить это, давайте обновим метод drive()
, чтобы фактически проверить диапазон его аргументов:
1 |
def drive(self, destination): |
2 |
|
3 |
lat, lon = destination |
4 |
|
5 |
if not (0.0 <= lat <= 90.0): |
6 |
|
7 |
raise Exception('Latitude out of range') |
8 |
|
9 |
if not (-180.0 <= lon <= 180.0): |
10 |
|
11 |
raise Exception('Latitude out of range') |
12 |
|
13 |
|
14 |
|
15 |
self.destination = destination |
16 |
|
17 |
while not self._has_arrived(): |
18 |
|
19 |
self._advance_to_destination() |
20 |
|
21 |
self.stop() |
Теперь все тесты проходят.
1 |
python -m unittest discover -v |
2 |
|
3 |
test_drive (untitled.test_self_driving_car.SelfDrivingCarTest) ... ok |
4 |
|
5 |
test_stop (untitled.test_self_driving_car.SelfDrivingCarTest) ... ok |
6 |
|
7 |
|
8 |
|
9 |
---------------------------------------------------------------------- |
10 |
|
11 |
Ran 2 tests in 0.000s |
12 |
|
13 |
|
14 |
|
15 |
OK |
16 |
Тестирование частных методов
Должны ли вы проверять каждую функцию и метод? В частности, следует ли тестировать частные методы, называемые только вашим кодом? Обычный неудовлетворительный ответ: «В зависимости от ситуации».
Я постараюсь помочь вам и рассказать, от чего это зависит. Вы точно знаете, кто называет ваш частный метод — это ваш собственный код. В том случае, если ваши тесты служат для общедоступных методов, которые вызывают ваш частный метод и являются комплексными, которые вы уже полностью протестировали. Но если частный метод очень сложный, вы можете протестировать его отдельно. Решайте сами.
Как организовать ваши модульные тесты
В большой системе не всегда ясно, как провести тесты. Должен ли быть один большой файл со всеми тестами для пакета или отдельный тестовый файл для каждого класса? Должны ли тесты быть в том же файле, что и тестируемый код, или в том же каталоге?
Вот система, которую я использую. Тесты должны быть полностью отделены от тестируемого кода (следовательно, я не использую doctest). В идеале ваш код должен быть в пакете. Тесты для каждого пакета должны находиться в каталоге вашего дочернего узла. В каталоге тестов должен быть один файл для каждого модуля вашего пакета с именем test_<module name>
.
Например, если в вашем пакете есть три модуля: module_1.py
, module_2.py
и module_3.py
, вы должны иметь три тестовых файла: test_module_1.py
, test_module_2.py
и test_module_3.py
в каталоге тестов.
Этот подход имеет ряд преимуществ. Это дает определённость уже при просмотре каталогов, сразу видно, что вы не забыли протестировать. Это также помогает сохранять тесты в приемлимых размерах. Предполагая, что ваши модули имеют приемлимый размер, тестовый код для каждого модуля будет в собственном файле, который может быть немного больше, чем тестируемый модуль, но все же удобно размещён в одном файле.
Заключение
Юнит- тесты являются основой качествнного кода. В этом уроке я изучил некоторые принципы и рекомендации для работы с модульным тестированием и объяснил нескольких лучших практик. Чем больше система, которую вы строите, тем более важными становятся модульные тесты. Но одних модульных тестов недостаточно. Другие типы тестов также необходимы для крупномасштабных систем: интеграционные тесты, тесты производительности, тесты нагрузки, тестирование на уязвимость, тесты на прием данных и другие.
Пишете код на Python? Будет полезно знать о принципах тестирования Python-кода ваших приложений. Изучайте статью и применяйте навыки в работе.
Многие считают что язык программирования Python − это просто. Такое впечатление складывается после прочитанной книги по Python, статьи или видео-туториала. Возможно, он действительно проще, чем другие технологии, вот только без трудностей не бывает даже тут. Но и их можно избежать, если понять принципы тестирования Python-кода.
Как всё устроено
Сразу к делу. Вот как будет проходить проверка функции sum() (1,2,3) равна шести:
>>> assert sum([1, 2, 3]) == 6, "Should be 6"
Тест не выведет ничего на REPL, так как значения верны. Но если результат sum() неверен, это приведет к ошибке AssertionError и сообщению “Should be 6”.
>>> assert sum([1, 1, 1]) == 6, "Should be 6" Traceback (most recent call last): File "<stdin>", line 1, in <module> AssertionError: Should be 6
В REPL вы видите AssertionError, потому что результат не соответствует 6. Переместите код в новый файл, названный test_sum.py и выполните снова:
def test_sum(): assert sum([1, 2, 3]) == 6, "Should be 6" if __name__ == "__main__": test_sum() print("Everything passed")
Вы написали пример теста, утверждение и точку входа.
$ python test_sum.py Everything passed
sum() принимает любое повторяющееся значение в качестве первого аргумента. Вы проверили список, теперь проверьте так же и tuple. Создайте новый файл test_sum_2.py:
def test_sum(): assert sum([1, 2, 3]) == 6, "Should be 6" def test_sum_tuple(): assert sum((1, 2, 2)) == 6, "Should be 6" if __name__ == "__main__": test_sum() test_sum_tuple() print("Everything passed")
Когда вы выполняете test_sum_2.py, скрипт выдает ошибку, так как sum() от (1,2,2) не равна 6:
$ python test_sum_2.py Traceback (most recent call last): File "test_sum_2.py", line 9, in <module> test_sum_tuple() File "test_sum_2.py", line 5, in test_sum_tuple assert sum((1, 2, 2)) == 6, "Should be 6" AssertionError: Should be 6
Для более масштабных вещей используют running tests. Это специальные приложения для запуска тестов, проверки вывода и предоставления инструментов для отладки и диагностики тестов и приложений.
Выбор Test Runner
Unittest
Unittest содержит как структуру тестирования Python, так и test runners. У него есть несколько требований:
- Нужно помещать свои тесты в классы как методы.
- Нужно использовать ряд специальных методов утверждения в unittest − TestCase вместо assert.
Для преобразования в unittest:
- Импортируйте его из стандартной библиотеки.
- Создайте класс TestSum, который наследуется от класса TestCase.
- Преобразуйте тестовые функции в методы путем добавления self в качестве первого аргумента.
- Изменить утверждение на использование метода self.assertEqual() в классе TestCase.
- Изменить точку входа в командной строке для вызова unittest.main().
- Создайте test_sum_unittest.py:
import unittest class TestSum(unittest.TestCase): def test_sum(self): self.assertEqual(sum([1, 2, 3]), 6, "Should be 6") def test_sum_tuple(self): self.assertEqual(sum((1, 2, 2)), 6, "Should be 6") if __name__ == '__main__': unittest.main()
$ python test_sum_unittest.py .F ====================================================================== FAIL: test_sum_tuple (__main__.TestSum) ---------------------------------------------------------------------- Traceback (most recent call last): File "test_sum_unittest.py", line 9, in test_sum_tuple self.assertEqual(sum((1, 2, 2)), 6, "Should be 6") AssertionError: Should be 6 ---------------------------------------------------------------------- Ran 2 tests in 0.001s FAILED (failures=1)
Nose
Совместим с любыми тестами, написанными с использованием unittest. Чтобы начать тестирование Python-кода, установите его из PyPl и выполните в командной строке. Он попытается обнаружить все скрипты с именем test*.py, наследующие от unittest.
$ pip install nose2 $ python -m nose2 .F ====================================================================== FAIL: test_sum_tuple (__main__.TestSum) ---------------------------------------------------------------------- Traceback (most recent call last): File "test_sum_unittest.py", line 9, in test_sum_tuple self.assertEqual(sum((1, 2, 2)), 6, "Should be 6") AssertionError: Should be 6 ---------------------------------------------------------------------- Ran 2 tests in 0.001s FAILED (failures=1)
Pytest
Pytest также поддерживает выполнение тестов unittest, а его преимущество заключается в написании своих тестов. Они представляют собой ряд функций в файле Python.
Кроме того, он отличается:
- Поддержкой встроенного утверждения assert вместо использования специальных методов self.assert*().
- Возможностью повторного запуска с пропущенного теста.
- Наличием системы дополнительных плагинов.
Написание тестового примера TestSum для pytest будет выглядеть так:
def test_sum(): assert sum([1, 2, 3]) == 6, "Should be 6" def test_sum_tuple(): assert sum((1, 2, 2)) == 6, "Should be 6"
Написание вашего первого теста
Если вы только начали изучать Python с нуля, обязательно затроньте и темы дебага/тестирования. Понимание принципов тестирования Python включает в себя принципы написания собственных тестов. Создайте новую папку проекта и внутри нее, под названием my_sum, еще одну. Внутри my_sum создайте пустой файл с именем __init__.py:
project/ │ └── my_sum/ └── __init__.py
Откройте my_sum/__init__.py и создайте новую функцию sum(), которая обрабатывает повторения.
def sum(arg): total = 0 for val in arg: total += val return total
В этом коде создается переменная с именем total, которая повторяет все значения в arg и добавляет их к total.
Где писать тест
Создайте в корне файл test.py, который будет содержать ваш первый тест:
project/ │ ├── my_sum/ │ └── __init__.py | └── test.py
Как структурировать простой тест?
Прежде чем перейти к написанию тестов, вы должны понять следующее:
- Что вы хотите проверить?
- Вы пишете unit test или integration test?
После убедитесь, что структура теста соответствует следующему порядку:
- Создание структуры ввода.
- Выполнение кода и определение вывода.
- Сравнивание полученного с ожидаемым результатом.
Для этого приложения вы должны проверить sum(). Есть много вариантов поведения функции, которые нужно учитывать:
- Может ли функция суммировать целые числа?
- Может ли она использовать set или tuple?
- Что происходит, когда вы вводите неверное значение, например, переменную или целую строчку?
- Что происходит, когда значение отрицательно?
Начнем с суммы целых чисел.
import unittest from my_sum import sum class TestSum(unittest.TestCase): def test_list_int(self): """ Test that it can sum a list of integers """ data = [1, 2, 3] result = sum(data) self.assertEqual(result, 6) if __name__ == '__main__': unittest.main()
Код импортирует sum() из папки my_sum, затем определяет новый класс теста TestSum, наследуемый от unittest, а TestCase определяет тестовый метод .test_list_int() для проверки списка целых чисел.
Метод .test_list_int() будет:
- Описывать переменные списка чисел.
- Назначать результат my_sum.sum(data) для результирующей переменной.
- Проверять, что значение равно шести, используя метод .assertEqual() в классе unittestTestCase.
- Определять точку ввода в командную строку, где выполняется unittest test–runner .main().
Как писать утверждения и проверки assertions
Последним этапом теста является проверка вывода на основе известного ответа. Это называется утверждением − assertion. Есть несколько общих принципов их написания:
- Удостоверьтесь, что тесты могут повторяться.
- Попробуйте проверять результаты, которые относятся к входным данным, например, проверка результата суммы значений в sum().
Unittest поставляется со множеством методов для проверки значений и переменных. Вот некоторые из наиболее используемых:
Проверка Test Runners
if __name__ == '__main__': unittest.main()
Это точка входа в командную строку. Она означает, что если вы выполните скрипт самостоятельно, запустив python.test.py в командной строке, он вызовет unittest.main(), после чего запустятся все классы, которые наследуются от unittest.TestCase в этом файле.
$ python -m unittest test
Вы можете предоставить дополнительные опции для изменения вывода. Один из них – “–v”:
$ python -m unittest -v test test_list_int (test.TestSum) ... ok ---------------------------------------------------------------------- Ran 1 tests in 0.000s
Вместо предоставления имени модуля, содержащего тесты, можно запросить автоматическое обнаружение:
$ python -m unittest discover
Если у вас есть несколько тестов, и вы следуете шаблону test*.py, можно указать имя каталога, используя –s flag:
$ python -m unittest discover -s tests
Если исходный код отсутствует в корне каталога и содержится в подкаталоге, можно сообщить Unittest, где выполнить тесты, чтобы он правильно импортировал модули с –t flag:
$ python -m unittest discover -s tests -t src
Результаты тестирования
sum() должна иметь возможность принимать другие списки числовых типов (дроби).
В верхней части файла test.py добавьте оператор импорта:
from fractions import Fraction
Добавьте тест с утверждением, ожидающим неправильное значение. В этом случае ожидание sum() от (¼, ¼ и ⅖) будет равно 1.
import unittest from my_sum import sum class TestSum(unittest.TestCase): def test_list_int(self): """ Test that it can sum a list of integers """ data = [1, 2, 3] result = sum(data) self.assertEqual(result, 6) def test_list_fraction(self): """ Test that it can sum a list of fractions """ data = [Fraction(1, 4), Fraction(1, 4), Fraction(2, 5)] result = sum(data) self.assertEqual(result, 1) if __name__ == '__main__': unittest.main()
Если вы снова выполните тест с python –m unittest test, вы увидите следующее:
$ python -m unittest test F. ====================================================================== FAIL: test_list_fraction (test.TestSum) ---------------------------------------------------------------------- Traceback (most recent call last): File "test.py", line 21, in test_list_fraction self.assertEqual(result, 1) AssertionError: Fraction(9, 10) != 1 ---------------------------------------------------------------------- Ran 2 tests in 0.001s FAILED (failures=1)
Выполнение тестов в PyCharm
Если вы используете PyCharm IDE, вы можете запустить Unittest или pytest, выполнив следующие шаги:
- В окне инструментов проекта выберите каталог тестов
- В контекстном меню выберите команду запуск для Unittest.
Выполнение тестов из кода Visual Studio
Если у вас установлен плагин Python, вы можете настроить конфигурацию своих тестов, открыв командную палитру с помощью Ctrl+Shift+P и набрав «Python test»:
Выберите Debug All Unit Tests. VSCode выдаст подсказку для настройки тестовой среды. Нажмите на шестеренку, чтобы выбрать unittest и домашний каталог.
Тестирование для Django и Flask
Как использовать Django Test Runner
Шаблон startapp в Django создаст файл test.py внутри каталога приложений. Если его нет, создайте:
from django.test import TestCase class MyTestCase(TestCase): # Ваш метод
Основное отличие состоит в том, что наследовать нужно от django.test.TestCase вместо unittest.TestCase. Эти классы имеют один и тот же API, но Django TestCase устанавливает все необходимое для тестирования.
Чтобы выполнить свой тестовый пакет вместо использования unittest в командной строке, используйте метод manage.py:
$ python manage.py test
Если вы нуждаетесь в нескольких тестовых файлах, замените test.py на папку с именем test, поместите внутрь пустой файл с именем __init__.py и создайте файлы test_*.Py. Django обнаружит и выполнит их.
Как использовать unittest и Flask
Flask требует, чтобы приложение было импортировано и установлено в тестовом режиме. Можно создать копию тестового клиента и использовать его для запросов приложения.
Все экземпляры тестового клиента выполняются в методе setUp. В следующем примере my_app − имя приложения.
import my_app import unittest class MyTestCase(unittest.TestCase): def setUp(self): my_app.app.testing = True self.app = my_app.app.test_client() def test_home(self): result = self.app.get('/') # Make your assertions
Сложные сценарии тестирования
Сбои
Ранее, когда мы делали список сценариев для проверки sum(), возник вопрос: что происходит при вводе неверного значения? Тест провалится.
Существует способ обработки ожидаемых ошибок. Можно использовать .assertRaises() в качестве контекстного менеджера, а затем выполнить тест внутри блока:
import unittest from my_sum import sum class TestSum(unittest.TestCase): def test_list_int(self): """ Test that it can sum a list of integers """ data = [1, 2, 3] result = sum(data) self.assertEqual(result, 6) def test_list_fraction(self): """ Test that it can sum a list of fractions """ data = [Fraction(1, 4), Fraction(1, 4), Fraction(2, 5)] result = sum(data) self.assertEqual(result, 1) def test_bad_type(self): data = "banana" with self.assertRaises(TypeError): result = sum(data) if __name__ == '__main__': unittest.main()
Теперь этот тест будет пройден только если sum(data) вызовет TypeError. Позже условие можно будет изменить.
Структура
Существуют и побочные эффекты: они усложняют тестирование, поскольку при каждом выполнении результаты могут разниться.
Спасительные методы:
- Реструктурирование кода.
- Использование способа mocking для методов функции.
- Использование integration test вместо unit test.
Написание integration tests
До этого времени мы занимались в основном unit testing. Двигаемся дальше.
Integration testing – тестирование нескольких компонентов приложения для проверки их совместной работоспособности. Integration testing может требовать разные сценарии работы:
- Вызов HTTP REST API
- Вызов Python API
- Вызов веб–службы
- Запуск командной строки
Каждый из этих типов integration tests может быть записан так же, как и unit test. Существенное отличие состоит в том, что Integration tests проверяют сразу несколько компонентов. Можно разделить тесты на integration и unit − разбить их по папкам:
project/ │ ├── my_app/ │ └── __init__.py │ └── tests/ | ├── unit/ | ├── __init__.py | └── test_sum.py | └── integration/ ├── __init__.py └── test_integration.py
Можно указать путь к тестам:
$ python -m unittest discover -s tests/integration
Тестирование data-driven приложений
Многие integration tests требуют базовые данные, содержащие определенные значения. Например, может потребоваться тест, который проверяет правильность отображения приложения с более чем 100 клиентами в базе данных, написанной на японском.
Хорошим решением будет хранение тестовых данных в отдельной папке под названием «fixtures», чтобы указать, где именно содержится нужная информация.
Вот пример этой структуры, если данные состоят из файлов JSON:
project/ │ ├── my_app/ │ └── __init__.py │ └── tests/ | └── unit/ | ├── __init__.py | └── test_sum.py | └── integration/ | ├── fixtures/ | ├── test_basic.json | └── test_complex.json | ├── __init__.py └── test_integration.py
В тесте можно использовать метод .setUp() для загрузки тестовых данных из файла. Помните, что у вас может быть несколько тестов в одном файле Python, и unittest discovery будет выполнять их все. Для каждого набора тестовых данных может быть один тестовый пример:
import unittest class TestBasic(unittest.TestCase): def setUp(self): # Load test data self.app = App(database='fixtures/test_basic.json') def test_customer_count(self): self.assertEqual(len(self.app.customers), 100) def test_existence_of_customer(self): customer = self.app.get_customer(id=10) self.assertEqual(customer.name, "Org XYZ") self.assertEqual(customer.address, "10 Red Road, Reading") class TestComplexData(unittest.TestCase): def setUp(self): # load test data self.app = App(database='fixtures/test_complex.json') def test_customer_count(self): self.assertEqual(len(self.app.customers), 10000) def test_existence_of_customer(self): customer = self.app.get_customer(id=9999) self.assertEqual(customer.name, u"バナナ") self.assertEqual(customer.address, "10 Red Road, Akihabara, Tokyo") if __name__ == '__main__': unittest.main()
Тестирование в нескольких средах
До сих пор вы работали только с одной версией Python, используя виртуальную среду с определенным набором зависимостей. Tox − приложение, которое автоматизирует процесс тестирования Python в нескольких средах.
Установка Tox
$ pip install tox
Настройка Tox для ваших нужд
Tox настраивается через файл конфигурации в каталоге проекта. Он содержит следующее:
- Команда запуска для выполнения тестов
- Дополнительные пакеты, необходимые для выполнения
- Разные версии Python для тестирования
Вместо изучения синтаксиса конфигурации Tox, можно начать с использования приложения быстрого запуска:
$ tox-quickstart
Средство конфигурации Tox создаст файл, похожий на следующий в tox.ini:
[tox] envlist = py27, py36 [testenv] deps = commands = python -m unittest discover
Прежде чем запустить Tox, нужно создать файл setup.py, который будет содержать порядок установки пакета.
Вместо этого, можно добавить строку в файл tox.ini в заголовке [tox]:
[tox] envlist = py27, py36 skipsdist=True
Если вы не будете создавать файл setup.py, но ваше приложение зависит от PyPl, вам нужно указать это в нескольких строках в разделе [testenv]. Например, для Django потребуется следующее:
[testenv] deps = django
Теперь можно запустить Tox и создать две виртуальные среды: одну для Python 2.7 и одну для Python 3.6. Каталог Tox называется .tox/. Внутри него Tox выполнит обнаружение python – m unittest для каждой виртуальной среды.
Этот процесс также можно запустить, вызвав Tox в командной строке. На этом заканчиваем рассказ о принципах тестирования Python-кода.
Заключение
Python сделал тестирование доступным: unittest и собственные методы позволяют качественно тестировать код.
По мере развития навыков, можете постепенно перейти к использованию pytest и других более продвинутых функций.
- Инструменты для анализа кода Python. Часть 1
- Инструменты для анализа кода Python. Часть 2
Лучшие книги по Python:
- 13 лучших книг по Python для начинающих и продолжающих
- ТОП-10 книг по Python: эффективно, емко, доходчиво
Источник: Основы тестирования Python on Realpython
Продолжаем погружаться в работу тестировщика, он же — QA, quality assurance engineer. Его задача — проверить код на наличие ошибок и работу программы в разных условиях.
Мы уже писали о том, что ещё делают тестировщики и какие инструменты для этого используют:
- Кто такой инженер по тестированию и стоит ли на него учиться
- Зарплата 113 тысяч за то, чтобы ломать программы
- Тестируем и исправляем калькулятор на JavaScript
- Словарь тестировщика: автотесты, юнит-тесты и другие важные слова
- Какой софт нужен, чтобы стать тестировщиком
Сегодня мы попробуем написать автотесты — чаще всего именно этим занимаются тестировщики на работе.
Что такое автотесты
Автотесты — это когда одна программа проверяет работу другой программы. Работает это примерно так:
- У нас есть код программы с нужными функциями.
- Мы пишем новую программу, которая вызывает наши функции и смотрит на результат.
- Если результат совпадает с тем, что должно быть, — тест считается пройденным.
- Если результат не совпадает — тест не пройден и нужно разбираться.
Чтобы всё было наглядно, покажем работу автотестов на реальном коде.
Исходная программа
Допустим, мы пишем интерактивную текстовую игру — в ней всё оформляется текстом, и развитие игры зависит от ответов пользователя. Мы сделали отдельный модуль, который делает четыре вещи:
- получает имя игрока;
- принудительно делает в имени большую букву (вдруг кто-то случайно ввёл с маленькой);
- добавляет к нему приветствие;
- сформированную строку отправляет как результат работы функции.
# Собираем приветствие
def hello(name):
# делаем первую букву имени большой
out = name.title()
# формируем приветствие
out = 'Привет, ' + out + '.'
# возвращаем его как результат работы функции
return out
Эта функция хранится в файле hello_function.py
— так мы разбиваем программу на модули, каждый из которых делает что-то своё.
Напишем начало основной программы, которая запрашивает имя, формирует приветствие и добавляет к нему стартовую фразу:
# импортируем функцию из другого файла
from hello_function import hello
# объясняем, что нужно сделать пользователю
print("Введите имя, чтобы начать игру")
# спрашиваем имя
name = input("Как вас зовут: ")
# обрабатываем имя и формируем приветствие
result = hello(name)
# добавляем вторую строку
print(result + " nДобро пожаловать в «Код»!")
Сохраним это в новом файле start.py и запустим его:
Вроде работает, но хорошо бы проверить, а всегда ли приветствие будет формироваться правильно? Можно сделать вручную, а можно написать автотест.
Пишем автотест
Первое, что нам нужно сделать, — подключить стандартный модуль для автотестов unittest
. Есть модули покруче, но для наших проектов стандартного хватит с запасом. Также получаем доступ к функции hello()
из файла hello_function.py
— работу именно этой функции мы будем проверять автотестом.
# подключаем модуль для автотестов
import unittest
# импортируем функцию из другого файла
from hello_function import hello
А теперь самое важное: нам нужно объявить класс и функцию, внутри которой и будет находиться наш тест. Причём название функции должно начинаться с test_
, чтобы она выполнялась автоматически.
Внутри функции делаем такое:
- формируем данные, которые мы отправляем в тестируемую функцию;
- прописываем ожидаемый результат.
Этими действиями мы как будто вызываем ту функцию и смотрим, получилось ли то, что нам нужно, или нет. При этом нам не нужно обрабатывать результаты тестов — за нас это сделает модуль unittest.
Для запуска тестов добавляем в конец кода стандартный вызов. Читайте комментарии, чтобы лучше вникнуть в код:
# подключаем модуль для автотестов
import unittest
# импортируем функцию из другого файла
from hello_function import hello
# объявляем класс с тестом
class HelloTestCase(unittest.TestCase):
# функция, которая проверит, как формируется приветствие
def test_hello(self):
# отправляем тестовую строку в функцию
result = hello("миша")
# задаём ожидаемый результат
self.assertEqual(result, "Привет, Миша.")
# запускаем тестирование
if __name__ == '__main__':
unittest.main()
После запуска мы увидим такое. Ответ «OK» означает, что наш тест сработал и завершился без ошибок:
Ещё такие тесты позволяют найти ошибки в самом коде. Допустим, разработчик забыл добавить принудительный перевод большой буквы в имени, и тогда тест не пройдёт. Если получится, система даже подсветит, что именно не совпало в результате, — в нашем случае это первая буква имени.
Что дальше
Мы написали самый простой тест и всего с одним условием. При этом мы не проверили тестами работу основной программы — в реальном проекте это тоже нужно было бы сделать. Чтобы попрактиковаться, мы сделаем тесты для одного из наших старых проектов на Python. Заодно проверим, нет ли там каких ошибок, которые мы не заметили.
Вёрстка:
Кирилл Климентьев