Инструменты тестирования в Python: от простых assert до property-based проверок

Раздел: Разработка на Python -> Тестирование и отладка

Введение в тестирование на 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) == 0

Python библиотеки тестирования (библиотеки для тестирования 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 == expected

Pytest запустит тест трижды с разными параметрами. Если один из наборов падает, остальные продолжают выполняться.

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)  # Stop

Side_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()

Обратите внимание на многоточие ... - оно заменяет часть трассировки, делая тест устойчивым к изменениям в выводе.

Библиотеки для тестирования Python (unittest, pytest и др.) - comments

En
Python библиотеки тестирования (python)