Инструменты тестирования в Python: от простых assert до property-based проверок
Введение в тестирование на Python
Тестирование кода - неотъемлемая часть разработки, позволяющая убедиться в корректности работы функций, классов и целых модулей. В экосистеме Python существует несколько популярных библиотек, каждая из которых подходит для разных сценариев. Рассмотрим их подробно, начиная с самого эффективного инструмента - pytest.
pytest - современный и гибкий фреймворк
Как написать простой тест, который проверяет результат функции?
Установка: pip install pytest. Создайте файл test_sample.py:
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0Python библиотеки тестирования (библиотеки для тестирования python (unittest, pytest и др.))
Запуск: pytest test_sample.py -v. Результат покажет зелёные точки для пройденных тестов. Основная идея - pytest находит все функции, начинающиеся с test_, и выполняет их, проверяя assert.
Как организовать подготовку данных для нескольких тестов?
Для этого используются фикстуры (fixtures). Пример conftest.py:
import pytest
@pytest.fixture
def user_data():
return {'name': 'Alice', 'age': 30}
def test_user_name(user_data):
assert user_data['name'] == 'Alice'
def test_user_age(user_data):
assert user_data['age'] == 30Фикстура создаёт объект один раз и передаёт его в каждый тест, что избавляет от дублирования кода.
Типичная ошибка: забыть передать фикстуру в аргументы теста - pytest сообщит об отсутствии параметра. Решение: всегда добавлять название фикстуры как аргумент в тестовую функцию.
Проблема: тесты могут выполняться не в том порядке, в котором они объявлены. Это нормально, так как порядок не гарантируется. Для зависимых тестов следует использовать фикстуры с областью видимости session или module.
Как проверить несколько наборов входных данных без повторения кода?
Параметризация:
import pytest
@pytest.mark.parametrize('a,b,expected', [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_add(a, b, expected):
assert a + b == expectedPytest запустит тест трижды с разными параметрами. Если один из наборов падает, остальные продолжают выполняться.
unittest - встроенный модуль из стандартной библиотеки
Как реализовать тесты, используя классы и наследование, без установки сторонних пакетов?
Библиотека unittest входит в состав Python. Пример:
import unittest
class TestMathOperations(unittest.TestCase):
def test_add(self):
self.assertEqual(2 + 3, 5)
def test_subtract(self):
self.assertEqual(5 - 3, 2)
if __name__ == '__main__':
unittest.main()Запуск: python -m unittest test_module.py. Методы assertEqual, assertTrue, assertRaises и другие покрывают большинство ситуаций.
Типичная ошибка: не унаследовать класс от unittest.TestCase - тогда тесты не будут найдены. Решение: всегда указывать наследование.
Проблема: тесты в unittest требуют более многословного синтаксиса по сравнению с pytest. Например, использование self.assertAlmostEqual() для чисел с плавающей точкой.
Как задать одинаковую подготовку для всех тестов класса?
Переопределите методы setUp и tearDown:
import unittest
class TestDatabase(unittest.TestCase):
def setUp(self):
self.connection = create_connection()
def tearDown(self):
self.connection.close()
def test_query(self):
result = self.connection.query('SELECT 1')
self.assertEqual(result, 1)doctest - тестирование через документацию
Как одновременно документировать функцию и проверять её работу?
Пример:
def square(x):
"""
Возвращает квадрат числа.
>>> square(3)
9
>>> square(-2)
4
"""
return x * x
if __name__ == '__main__':
import doctest
doctest.testmod()Запуск: python module.py -v. Doctest выполняет примеры из docstring и сверяет с ожидаемым выводом.
Типичная ошибка: несоответствие пробелов в ожидаемом выводе - doctest строго сравнивает строки. Решение: использовать опцию NORMALIZE_WHITESPACE.
nose2 - преемник nose
Как получить расширенный плагинный функционал, похожий на pytest, но с другим синтаксисом?
Установка: pip install nose2. Пример теста (функция с префиксом test):
def test_upper():
assert 'hello'.upper() == 'HELLO'Запуск: nose2 -v. Поддерживает плагины для покрытия, параллельного запуска.
Проблема: nose2 менее популярен, чем pytest, и имеет меньшее сообщество. Для новых проектов чаще выбирают pytest.
hypothesis - property-based testing
Как проверить утверждения для большого числа случайных данных?
Установка: pip install hypothesis. Пример с функциями сортировки:
from hypothesis import given
from hypothesis import strategies as st
def sort_list(lst):
return sorted(lst)
@given(st.lists(st.integers()))
def test_sort_is_idempotent(lst):
sorted_once = sort_list(lst)
sorted_twice = sort_list(sorted_once)
assert sorted_once == sorted_twice
@given(st.lists(st.integers()))
def test_sort_is_non_empty_for_non_empty(lst):
if lst:
assert sort_list(lst) # не пустой список после сортировкиHypothesis генерирует сотни случайных списков и проверяет свойство идемпотентности. При ошибке выводит минимальный воспроизводящий пример.
Типичная ошибка: слишком общие стратегии, которые генерируют невалидные данные. Решение: сужать стратегии через фильтры (st.filter()).
unittest.mock - подмена объектов
Как имитировать внешние сервисы при тестировании?
Пример подмены функции запроса к API:
from unittest.mock import patch
def get_user_name(user_id):
import requests
response = requests.get(f'https://api.example.com/users/{user_id}')
return response.json()['name']
@patch('requests.get')
def test_get_user_name(mock_get):
mock_get.return_value.json.return_value = {'name': 'Bob'}
assert get_user_name(1) == 'Bob'
mock_get.assert_called_once_with('https://api.example.com/users/1')Проблема: забыть указать путь к подменяемому объекту (где он импортируется, а не где определён). Решение: использовать путь из вызывающего модуля, например 'my_module.requests.get'.
Расширенные примеры использования библиотек тестирования
1. pytest: conftest с областью видимости модуля
# conftest.py
import pytest
@pytest.fixture(scope='module')
def db_connection():
print('\nПодключение к базе данных')
conn = create_connection()
yield conn
print('\nЗакрытие подключения')
conn.close()
# test_db.py
def test_select(db_connection):
result = db_connection.query('SELECT 1')
assert result == 1
def test_insert(db_connection):
result = db_connection.query('INSERT INTO test VALUES (1)')
assert result.rowcount == 1$ pytest -v -s test_db.py ============================= test session starts ============================== ... Подключение к базе данных .Закрытие подключения ============================= 2 passed in 0.12s ==============================
Фикстура с областью module выполняется один раз для всех тестов модуля, что экономит ресурсы.
2. pytest: monkeypatch для замены окружения
def get_os():
import platform
return platform.system()
def test_linux(monkeypatch):
monkeypatch.setattr('platform.system', lambda: 'Linux')
assert get_os() == 'Linux'
def test_windows(monkeypatch):
monkeypatch.setattr('platform.system', lambda: 'Windows')
assert get_os() == 'Windows'Monkeypatch временно подменяет атрибуты. После завершения теста изменения откатываются.
3. pytest: временные файлы через tmpdir
def write_file(path, content):
with open(path, 'w') as f:
f.write(content)
def test_write_file(tmpdir):
path = tmpdir.join('test.txt')
write_file(str(path), 'hello')
assert path.read() == 'hello'Pytest создаёт временную директорию, которая удаляется после теста.
4. unittest: комбинация setUp и tearDownClass
import unittest
class TestFixture(unittest.TestCase):
@classmethod
def setUpClass(cls):
print('\nSetUpClass - один раз для класса')
cls.shared_data = [1, 2, 3]
@classmethod
def tearDownClass(cls):
print('tearDownClass - очистка')
def setUp(self):
print('\nsetUp - перед каждым тестом')
def test_first(self):
self.assertIn(1, self.shared_data)
def test_second(self):
self.assertEqual(len(self.shared_data), 3)$ python -m unittest test_fixture.py SetUpClass - один раз для класса .setUp - перед каждым тестом .tearDownClass - очистка ---------------------------------------------------------------------- Ran 2 tests in 0.001s OK
5. unittest.mock: side_effect для имитации последовательных вызовов
from unittest.mock import Mock
m = Mock()
m.side_effect = [1, 2, 3, Exception('Stop')]
print(m(), m(), m()) # 1 2 3
try:
m()
except Exception as e:
print(e) # StopSide_effect позволяет задать последовательность возвращаемых значений или исключений.
6. hypothesis: стратегия для дат
from hypothesis import given, strategies as st
from datetime import date
def is_weekend(d):
return d.weekday() >= 5
@given(st.dates())
def test_weekend_property(d):
if is_weekend(d):
assert d.weekday() in (5, 6)
else:
assert d.weekday() not in (5, 6)Hypothesis генерирует даты из широкого диапазона, проверяя свойство.
7. doctest с обработкой исключений
def divide(a, b):
"""
Делит a на b.
>>> divide(10, 2)
5.0
>>> divide(10, 0)
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
"""
return a / b
if __name__ == '__main__':
import doctest
doctest.testmod()Обратите внимание на многоточие ... - оно заменяет часть трассировки, делая тест устойчивым к изменениям в выводе.