pytest, unittest, mocking, property-based testing, coverage, async tests
Тесты — это спецификация, которую можно запустить. Без тестов рефакторинг — это гадание.
/\
/ \ E2E (медленно, дорого, мало)
/────\
/ Inт \ Интеграционные (средне)
/────────\
/ Unit \ Юнит-тесты (быстро, дёшево, много)
/────────────\
| Тип | Что тестирует | Скорость | Изоляция |
|---|---|---|---|
| Unit | Функцию / класс в изоляции | Миллисекунды | Полная (моки) |
| Integration | Взаимодействие модулей | Секунды | Частичная |
| E2E | Сценарий пользователя | Десятки секунд | Нет |
unittest — встроенный фреймворкБазовый, входит в стандартную библиотеку. Хорош для простых случаев.
import unittest
class TestMathOperations(unittest.TestCase):
def setUp(self):
"""Вызывается перед каждым тестом."""
self.calculator = Calculator()
def tearDown(self):
"""Вызывается после каждого теста."""
self.calculator.reset()
def test_addition(self):
self.assertEqual(self.calculator.add(2, 3), 5)
def test_division_by_zero(self):
with self.assertRaises(ZeroDivisionError):
self.calculator.divide(10, 0)
def test_negative_numbers(self):
self.assertEqual(self.calculator.add(-1, -2), -3)
self.assertGreater(self.calculator.add(1, 1), 0)
@unittest.skip("Пока не реализовано")
def test_complex_numbers(self):
pass
@classmethod
def setUpClass(cls):
"""Один раз перед всеми тестами класса."""
cls.connection = create_db()
@classmethod
def tearDownClass(cls):
"""Один раз после всех тестов класса."""
cls.connection.close()
# Основные assertion методы:
# assertEqual(a, b) — a == b
# assertNotEqual(a, b) — a != b
# assertTrue(x) — bool(x) is True
# assertFalse(x) — bool(x) is False
# assertIsNone(x) — x is None
# assertIsNotNone(x)
# assertIn(a, b) — a in b
# assertRaises(Exc, func) — func() поднимает Exc
# assertAlmostEqual(a, b) — для float
# assertRegex(text, pat) — re.search(pat, text)
if __name__ == '__main__':
unittest.main()python -m unittest test_module.py
python -m unittest test_module.TestClass.test_method
python -m unittest discover -s tests/ # автопоискpytest — современный фреймворкПроще, мощнее, меньше boilerplate. Де-факто стандарт.
pip install pytest# Нет наследования от TestCase!
# Просто функции с именем test_*
def test_simple():
assert 1 + 1 == 2
def test_list():
result = [x**2 for x in range(3)]
assert result == [0, 1, 4]
# pytest автоматически перехватывает assert и показывает детали:
# > assert result == [0, 1, 5]
# E AssertionError: assert [0, 1, 4] == [0, 1, 5]
# E At index 2 diff: 4 != 5pytest # все тесты
pytest tests/ # директория
pytest tests/test_math.py # файл
pytest tests/test_math.py::test_add # конкретный тест
pytest -k "add or subtract" # по ключевому слову
pytest -v # verbose
pytest -x # остановить при первой ошибке
pytest --tb=short # короткий traceback
pytest -s # не захватывать stdout
pytest --lf # только провалившиеся (last failed)import pytest
def test_division_by_zero():
with pytest.raises(ZeroDivisionError):
1 / 0
def test_exception_message():
with pytest.raises(ValueError, match="negative"):
validate_age(-1)
def test_exception_details():
with pytest.raises(ValueError) as exc_info:
parse_date("not-a-date")
assert exc_info.value.args[0] == "Invalid date format"
assert exc_info.type is ValueErrordef test_deprecation_warning():
with pytest.warns(DeprecationWarning, match="use new_func"):
old_func()
def test_no_warnings():
with pytest.warns(None) as record:
safe_function()
assert len(record) == 0Фикстуры заменяют setUp/tearDown. Объявляются один раз, используются по имени.
import pytest
import tempfile
import os
@pytest.fixture
def temp_dir():
"""Временная директория для тестов."""
with tempfile.TemporaryDirectory() as d:
yield d # d доступна в тесте; after yield — cleanup
@pytest.fixture
def sample_data():
return {"users": [{"id": 1, "name": "Alice"}], "total": 1}
def test_create_file(temp_dir):
path = os.path.join(temp_dir, "test.txt")
open(path, "w").close()
assert os.path.exists(path)
def test_process_data(sample_data):
result = process(sample_data)
assert result["total"] == 1@pytest.fixture(scope="function") # по умолчанию: каждый тест
def db_connection():
conn = connect()
yield conn
conn.close()
@pytest.fixture(scope="class") # один раз на класс
def expensive_resource():
return load_model()
@pytest.fixture(scope="module") # один раз на файл
def server():
s = start_test_server()
yield s
s.stop()
@pytest.fixture(scope="session") # один раз на весь запуск pytest
def database():
db = create_test_database()
yield db
db.drop()@pytest.fixture(params=["sqlite", "postgres", "mysql"])
def db(request):
"""Тест будет запущен для каждой БД."""
return create_db(request.param)
def test_query(db):
result = db.query("SELECT 1")
assert result is not None
# Запустится 3 раза: test_query[sqlite], test_query[postgres], test_query[mysql]conftest.py — общие фикстуры# tests/conftest.py
# Фикстуры здесь автоматически доступны всем тестам в директории
import pytest
from myapp import create_app, db
@pytest.fixture(scope="session")
def app():
app = create_app({"TESTING": True, "DATABASE_URL": "sqlite:///:memory:"})
yield app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def auth_headers(client):
resp = client.post("/login", json={"user": "test", "pass": "test"})
token = resp.json["token"]
return {"Authorization": f"Bearer {token}"}import pytest
from math import isclose
@pytest.mark.parametrize("input, expected", [
(0, 0),
(1, 1),
(-1, 1),
(2, 4),
(-3, 9),
])
def test_square(input, expected):
assert square(input) == expected
# Параметризация с id — для удобного вывода
@pytest.mark.parametrize("op,a,b,expected", [
pytest.param("add", 1, 2, 3, id="add-positive"),
pytest.param("add", -1, -2, -3, id="add-negative"),
pytest.param("div", 10, 2, 5.0, id="div-normal"),
pytest.param("div", 1, 0, None, id="div-zero",
marks=pytest.mark.xfail(raises=ZeroDivisionError)),
])
def test_calc(op, a, b, expected):
result = calculate(op, a, b)
assert isclose(result, expected)
# Двумерная параметризация (декартово произведение)
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
assert multiply(x, y) == x * y
# Запустится 6 раз (3 × 2)import pytest
# Пропустить тест
@pytest.mark.skip(reason="Известная проблема, тикет #123")
def test_broken():
pass
# Ожидаемый провал
@pytest.mark.xfail(reason="Ожидаемый баг")
def test_known_bug():
assert buggy_function() == 42 # ожидается провал
# xfail со строгостью: если ВДРУГ прошёл — тоже ошибка
@pytest.mark.xfail(strict=True)
def test_must_fail():
assert False
# Пропустить на определённой платформе
import sys
@pytest.mark.skipif(sys.platform == "win32", reason="Не работает на Windows")
def test_unix_specific():
pass
# Кастомные маркеры (регистрируются в pytest.ini)
@pytest.mark.slow
@pytest.mark.integration
def test_full_pipeline():
pass# pytest.ini
[pytest]
markers =
slow: медленные тесты (>1s)
integration: интеграционные тесты
flaky: нестабильные тестыpytest -m "not slow" # пропустить медленные
pytest -m "integration" # только интеграционные
pytest -m "slow and not flaky"unittest.mockfrom unittest.mock import Mock, MagicMock, patch, call
# Простой Mock
mock = Mock()
mock.method(1, 2)
mock.method.assert_called_once_with(1, 2)
mock.method.assert_called()
mock.method.call_count # 1
# MagicMock поддерживает магические методы (__len__, __str__ и т.д.)
magic = MagicMock()
len(magic) # работает!
magic.__len__.return_value = 42
assert len(magic) == 42
# Mock с side_effect: список значений или исключение
mock_func = Mock(side_effect=[1, 2, ValueError("end")])
mock_func() # 1
mock_func() # 2
mock_func() # ValueError!
# Mock с return_value
mock_db = Mock()
mock_db.find_user.return_value = {"id": 1, "name": "Alice"}
result = mock_db.find_user(id=1)
assert result["name"] == "Alice"patch — замена в контекстеfrom unittest.mock import patch
# Как декоратор
@patch("mymodule.requests.get")
def test_fetch_data(mock_get):
mock_get.return_value.json.return_value = {"status": "ok"}
result = fetch_data("http://example.com")
assert result["status"] == "ok"
mock_get.assert_called_once_with("http://example.com")
# Как контекстный менеджер
def test_with_patch():
with patch("mymodule.send_email") as mock_email:
register_user("alice@example.com")
mock_email.assert_called_once()
args = mock_email.call_args
assert "alice@example.com" in args[0][0] # первый позиционный аргумент
# patch.object — патч атрибута объекта
def test_patch_object(db):
with patch.object(db, "find", return_value=[{"id": 1}]) as mock_find:
result = service.get_users(db)
mock_find.assert_called_once()
# patch.dict — патч словаря
import os
def test_env_var():
with patch.dict(os.environ, {"API_KEY": "test-key"}):
assert get_api_key() == "test-key"
# Множественные патчи
@patch("module.func_a")
@patch("module.func_b")
def test_multiple(mock_b, mock_a): # порядок обратный!
passpytest-mock — удобная обёртка# pip install pytest-mock
def test_with_mocker(mocker):
# mocker.patch возвращает MagicMock, автоматически снимается после теста
mock_get = mocker.patch("requests.get")
mock_get.return_value.json.return_value = {"data": [1, 2, 3]}
result = fetch_data()
assert result == [1, 2, 3]
def test_spy(mocker):
# spy: оборачивает реальный метод, следит за вызовами
spy = mocker.spy(MyClass, "method")
obj = MyClass()
obj.method(42)
spy.assert_called_once_with(42)
# Реальный метод выполняется!def test_stdout(capsys):
print("hello world")
captured = capsys.readouterr()
assert captured.out == "hello world\n"
assert captured.err == ""
def test_stderr(capsys):
import sys
print("error msg", file=sys.stderr)
captured = capsys.readouterr()
assert "error" in captured.err
# capfd — работает и с C-уровнем I/O (os.write)
def test_with_capfd(capfd):
os.write(1, b"hello\n") # C-level write
captured = capfd.readouterr()
assert captured.out == "hello\n"hypothesispip install hypothesisfrom hypothesis import given, settings, assume
from hypothesis import strategies as st
# Генерирует случайные входные данные
@given(st.integers())
def test_add_commutative(x):
assert add(x, 0) == x # нейтральный элемент
@given(st.integers(), st.integers())
def test_commutative(a, b):
assert add(a, b) == add(b, a)
@given(st.text(min_size=1))
def test_reverse_twice(s):
assert reverse_string(reverse_string(s)) == s
# assume() — пропустить нерелевантные случаи
@given(st.integers(), st.integers())
def test_division(a, b):
assume(b != 0) # пропустить деление на ноль
result = divide(a, b)
assert result * b == a or abs(result * b - a) < abs(b)
# Стратегии
st.integers(min_value=0, max_value=100)
st.floats(allow_nan=False, allow_infinity=False)
st.text(alphabet=st.characters(whitelist_categories=('Lu', 'Ll')))
st.lists(st.integers(), min_size=1, max_size=10)
st.dictionaries(st.text(), st.integers())
st.one_of(st.integers(), st.text()) # один из типов
st.builds(MyClass, x=st.integers()) # создать экземпляр класса
# Settings
@settings(max_examples=500) # больше попыток (по умолчанию 100)
@given(st.lists(st.integers()))
def test_sort_idempotent(lst):
sorted_once = sorted(lst)
assert sorted(sorted_once) == sorted_onceHypothesis автоматически уменьшает нашедший баг пример до минимального (shrinking).
pip install pytest-asyncioimport pytest
import asyncio
# Метка для async тестов
@pytest.mark.asyncio
async def test_async_function():
result = await async_function()
assert result == "expected"
# asyncio_mode = "auto" в pytest.ini — не нужны метки
# [pytest]
# asyncio_mode = auto
async def test_timeout():
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(slow_coroutine(), timeout=0.1)
@pytest.fixture
async def async_client():
async with aiohttp.ClientSession() as session:
yield session
async def test_http_request(async_client):
# mock-сервер через aioresponses
from aioresponses import aioresponses
with aioresponses() as m:
m.get("http://example.com/api", payload={"status": "ok"})
resp = await async_client.get("http://example.com/api")
data = await resp.json()
assert data["status"] == "ok"pip install pytest-cov
# Запуск с покрытием
pytest --cov=mypackage tests/
pytest --cov=mypackage --cov-report=html tests/
pytest --cov=mypackage --cov-report=term-missing tests/
# Открыть HTML-отчёт
open htmlcov/index.html# .coveragerc
[run]
source = mypackage
omit =
*/tests/*
*/migrations/*
*/__init__.py
[report]
fail_under = 80
show_missing = true
[html]
directory = htmlcovЦель: 80%+ для критичной бизнес-логики. 100% — не всегда оправдано. Фокус на ветвях (branch coverage), не только строках.
project/
├── src/
│ └── myapp/
│ ├── models.py
│ └── services.py
├── tests/
│ ├── conftest.py # общие фикстуры
│ ├── unit/
│ │ ├── test_models.py
│ │ └── test_services.py
│ └── integration/
│ └── test_api.py
└── pytest.ini
# test_{что тестируем}_{при каком условии}_{ожидаемый результат}
def test_divide_by_zero_raises_error():
def test_login_with_valid_credentials_returns_token():
def test_create_user_when_email_exists_raises_conflict():@pytest.fixture
def user_factory(db):
"""Создаёт пользователей с кастомными параметрами."""
created = []
def create_user(name="test", email=None, role="user"):
if email is None:
email = f"{name}@example.com"
user = User(name=name, email=email, role=role)
db.add(user)
db.commit()
created.append(user)
return user
yield create_user
for user in created:
db.delete(user)
db.commit()
def test_admin_permissions(user_factory):
admin = user_factory(role="admin")
regular = user_factory(name="bob")
assert can_delete_user(admin, regular)
assert not can_delete_user(regular, admin)# Mock: объект с отслеживанием вызовов (для unit тестов)
mock_email_service = Mock()
mock_email_service.send.return_value = True
# Fake: реальная реализация для тестов (для интеграционных тестов)
class FakeEmailService:
def __init__(self):
self.sent_emails = []
def send(self, to, subject, body):
self.sent_emails.append({"to": to, "subject": subject})
return True
fake_email = FakeEmailService()
service = UserService(email_service=fake_email)
service.register("alice@example.com")
assert len(fake_email.sent_emails) == 1
assert fake_email.sent_emails[0]["to"] == "alice@example.com"# ❌ Один тест проверяет много вещей
def test_everything():
user = create_user("alice", "alice@example.com")
assert user.name == "alice"
assert user.email == "alice@example.com"
assert user.role == "user"
login(user)
assert current_user() == user
update_profile(user, name="Alice")
assert get_user(user.id).name == "Alice"
# ✅ Каждый тест проверяет одно поведение
def test_create_user_sets_correct_name():
user = create_user("alice", "alice@example.com")
assert user.name == "alice"
def test_login_sets_current_user():
user = create_user("alice", "alice@example.com")
login(user)
assert current_user() == user
# ❌ Тесты зависят друг от друга
def test_step1():
global result
result = step1()
def test_step2():
assert step2(result) == "ok" # зависит от test_step1!
# ✅ Каждый тест независим (использует фикстуры)
def test_step2(some_fixture):
result = step1() # или фикстура
assert step2(result) == "ok"
# ❌ Тест с time.sleep()
def test_async_task():
start_task()
time.sleep(2) # хрупко, медленно
assert task_completed()
# ✅ Используйте моки/фикстуры для контроля времени
def test_async_task(mocker):
mocker.patch("mymodule.time.sleep")
mock_notify = mocker.patch("mymodule.notify")
run_task_synchronously()
mock_notify.assert_called_once()BankAccount с методами deposit, withdraw, balance. Покройте: нормальный кейс, овердрафт, отрицательный депозит.@pytest.mark.parametrize для тестирования функции is_palindrome с 10 разными случаями (включая unicode, пустую строку, числа).mock_database scope=session, которая создаёт SQLite in-memory базу с тестовыми данными.unittest.mock.patch. Убедитесь что запрос не отправляется.hypothesis для нахождения бага в самодельной функции сортировки.json.dumps / json.loads: loads(dumps(x)) == x для разных типов данных.Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.