Тестирование на Python
12-02-2026
Эта статья-интенсив по тестированию на Python подробно объясняет, зачем нужны автотесты, какие виды тестов существуют, и как строится пирамида тестирования. Вы познакомитесь с подходом TDD и циклом Red -> Green -> Refactor, узнаете, когда использовать мок-объекты, а когда нет, и получите практические навыки работы с pytest — от юнит-тестов до интеграционных и E2E-тестов. Примеры кода, рекомендации по структуре тестов и советы по качественной архитектуре тестового покрытия помогут вам начать писать надёжный и поддерживаемый Python-код с тестами.
# Почему тесты это не «дополнение», а базовая инженерная практика
Вокруг тестов в Python много лишней драматургии. Обычно есть два крайних мнения. Первое звучит как “без стопроцентного покрытия вы не разработчик”. Второе как “мы стартап, нам не до тестов”. Оба подхода одинаково вредны, потому что они заменяют инженерный разговор идеологией.
Зрелый взгляд на тестирование куда проще. У нас есть система, которая меняется. У этой системы есть цена ошибки. У этой же системы есть стоимость проверки. Мы хотим снизить цену ошибки при вменяемой стоимости проверки. Автотесты ровно про это.
Важно понимать, что тесты не делают код безошибочным. Они делают ошибки быстрыми, локализуемыми и дешевыми в исправлении. Когда ошибка ловится на ноутбуке разработчика через двадцать секунд после изменения, это одна экономика. Когда ошибка ловится через два дня в продакшене, это совсем другая экономика, и она почти всегда дороже.
# Зачем автотесты на практике, а не в теории
Чаще всего называют три причины. Защита от регрессий, документация поведения и уверенность при рефакторинге. Это правильные слова, но чтобы они не были абстрактными, нужно приземлить их на реальные сценарии.
Представим сервис заказов. В него добавляют поддержку промокодов. Разработчик правит логику расчета итоговой цены и случайно ломает правило бесплатной доставки для VIP-клиентов. Если тестов нет, баг уезжает в прод, поддержка получает жалобы, менеджер поднимает инцидент, команда срочно откатывает релиз. Если тесты есть, падает конкретный тест расчета доставки, и проблема решается до релиза.
Документация поведения в тестах особенно заметна в долгоживущих проектах. Через полгода после запуска фичи никто уже не помнит, почему именно в случае отмененного заказа и частичного возврата комиссия пересчитывается определенным образом. Тест с понятным названием и явным assertion отвечает на этот вопрос быстрее, чем любой коммит в истории.
Уверенность при рефакторинге вообще стоит считать отдельной инженерной валютой. Код без рефакторинга стареет и тянет проект вниз. Код с рефакторингом без тестов превращается в поле экспериментов с высоким риском. Код с тестами позволяет улучшать архитектуру постепенно и безопасно.
# Катастрофы без тестов выглядят скучно, но бьют больно
Самые дорогие сбои редко выглядят как эффектный “все упало”. Чаще это серия тихих ошибок, которые долго живут незамеченными.
Одна из типовых историй связана с деньгами. В коде расчетов меняют округление с банковского на арифметическое. На единичной операции разница минимальная, но на миллионах операций она превращается в существенное расхождение в отчетности. Проблему замечают поздно, потому что автоматических проверок бизнес-инвариантов не было.
Другая история связана с датами и временем. Команда добавляет поддержку нового часового пояса. На локальных тестах разработчика все хорошо. На сервере с другим timezone задача по отправке уведомлений начинает уходить на сутки раньше. Если нет детерминированных тестов со “замороженным” временем, такие ошибки живут долго.
Третья история связана с интеграцией. Внешний API меняет поле
status с ok на success. Клиентский код продолжает успешно
парсить JSON, но ветка бизнес-логики начинает вести себя не так,
как ожидалось. Юниты, которые мокали старый формат ответа,
проходят. Интеграционного теста контракта нет. Баг замечают в
бою.
Общий вывод в этих историях очень прагматичный. Тесты защищают не только код. Они защищают инженерное время и предсказуемость поставки изменений.
# Что именно мы тестируем: уровни и назначение
Слово “тест” без указания уровня мало что означает. Один тест может проверять чистую функцию за миллисекунды, другой гонять браузерный сценарий несколько минут. И это нормально, если мы понимаем, зачем каждый из них нужен.
Юнит-тест проверяет маленький фрагмент поведения в изоляции. Обычно это функция или небольшой класс без обращения к сети, диску и базе данных. Главная ценность юнита в скорости и точности сигнала. Если он упал, область поиска проблемы очень узкая.
Интеграционный тест проверяет стык нескольких модулей или слоев. Обычно здесь уже участвуют реальные адаптеры, БД, очередь, внешний HTTP-клиент через тестовый стенд. Такой тест медленнее, но он ловит ошибки контрактов и конфигураций, которые юнитами не поймать.
E2E тест проверяет сквозной пользовательский путь. Это не тест метода. Это тест бизнес-сценария целиком. Например, “регистрация -> подтверждение почты -> создание заказа -> оплата”. Такие тесты самые дорогие в поддержке, но они дают сигнал, который важен бизнесу напрямую.
Smoke и sanity проверки это короткие контрольные прогоны, которые отвечают на вопрос “система вообще жива после деплоя”. Они не заменяют полный пакет, но отлично подходят как быстрый барьер на критических функциях.
Регрессионные тесты не отдельный технический уровень, а назначение. Любой тест становится регрессионным, когда он защищает уже согласованное поведение от повторной поломки.
# Пирамида тестирования как модель затрат
Пирамида тестирования нужна не для красоты презентаций. Это рабочая модель стоимости проверки. Внизу много дешевых и быстрых проверок, выше меньше и дороже, на вершине совсем немного самых дорогих проверок на уровне бизнеса.
Если команда пишет почти одни E2E, она быстро сталкивается с медленным feedback loop. Каждый запуск долгий, окружение хрупкое, тесты нестабильны. Разработчики начинают реже запускать их локально, а CI превращается в место сюрпризов.
Если команда пишет только юниты, она теряет защиту на стыках. Модули по отдельности могут быть безупречны, но в интеграции сломаться из-за формата данных, конфигурации или миграций.
Здоровая стратегия почти всегда выглядит как широкий фундамент из юнитов, умеренный слой интеграционных проверок и ограниченный, но тщательно подобранный набор E2E на критический путь продукта.
# Архитектура и тестируемость связаны напрямую
Тестируемость редко появляется случайно. Она почти всегда является следствием архитектурных решений. Чем лучше разделены ответственности в коде, тем проще писать тесты и тем дешевле их поддерживать.
Плохой пример обычно выглядит как склеивание бизнес-логики, инфраструктуры и побочных эффектов в одном методе.
class PaymentService:
def pay(self, user_id: int, amount: int) -> None:
db = PostgresClient("postgres://prod")
gateway = StripeClient("secret")
metrics = MetricsClient("statsd://metrics")
user = db.fetch_user(user_id)
if not user.active:
raise ValueError("inactive user")
gateway.charge(user.card_token, amount)
db.save_payment(user_id, amount)
metrics.increment("payments.success")Здесь тесту сложно выбрать уровень. Для юнит-теста слишком много внешнего мира. Для интеграционного слишком много разных ответственностей сразу.
Лучший пример отделяет бизнес-решение от инфраструктуры и внедряет зависимости извне.
class PaymentService:
def __init__(self, users_repo, payments_repo, gateway, metrics):
self.users_repo = users_repo
self.payments_repo = payments_repo
self.gateway = gateway
self.metrics = metrics
def pay(self, user_id: int, amount: int) -> None:
user = self.users_repo.get(user_id)
if not user.active:
raise ValueError("inactive user")
self.gateway.charge(user.card_token, amount)
self.payments_repo.save(user_id, amount)
self.metrics.increment("payments.success")Теперь юнит-тест может проверять решения домена через фейковые зависимости, а интеграционные тесты отдельно проверят репозиторий, шлюз и метрики. Такой код проще развивать и проще покрывать.
# TDD: Red -> Green -> Refactor без романтизации
TDD полезен, когда нужен короткий цикл проверки гипотез и ясный контракт до реализации. Но важно понимать, что это не религия, а дисциплина.
Red означает, что мы сначала формулируем ожидаемое поведение через падающий тест. Green означает, что мы пишем минимальный код, достаточный для прохождения. Refactor означает, что мы улучшаем структуру кода, не меняя поведение.
Рассмотрим короткий пример. Пусть нужна функция, которая разбивает сумму по месяцам и последнюю копейку отдает в последний месяц.
На фазе Red можно начать так:
def test_allocate_even_months_with_remainder():
# 1000 cents over 3 months -> 333, 333, 334
assert allocate_cents(1000, 3) == [333, 333, 334]На фазе Green пишем минимальную реализацию:
def allocate_cents(total: int, months: int) -> list[int]:
base = total // months
remainder = total % months
result = [base] * months
result[-1] += remainder
return resultНа фазе Refactor добавляем защиту от некорректного ввода, делаем имя переменных яснее, добавляем тесты на крайние случаи.
import pytest
def allocate_cents(total: int, months: int) -> list[int]:
if months <= 0:
raise ValueError("months must be positive")
if total < 0:
raise ValueError("total must be non-negative")
base = total // months
remainder = total % months
schedule = [base] * months
schedule[-1] += remainder
return schedule
def test_allocate_one_month():
assert allocate_cents(500, 1) == [500]
def test_allocate_zero_total():
assert allocate_cents(0, 4) == [0, 0, 0, 0]
def test_allocate_invalid_months():
with pytest.raises(ValueError):
allocate_cents(100, 0)Именно так TDD удерживает фокус на контракте и предотвращает переусложнение раньше времени.
# Скорость обратной связи: главный скрытый ускоритель
Команда ускоряется не тогда, когда быстрее печатает код. Команда ускоряется тогда, когда быстрее получает надежный ответ на вопрос “изменение корректно или нет”.
Маленькие изолированные тесты дают сигнал за секунды. Тяжелые интеграционные проверки дают сигнал за минуты. E2E иногда дают сигнал за десятки минут. Все уровни нужны, но последовательность их использования критична.
В рабочем режиме разработчик обычно запускает локально быстрый пакет, потом нужные интеграционные тесты по задаче, и уже после этого отдает изменения в CI, где гоняется полный набор.
Когда эта дисциплина есть, количество “красных” сюрпризов в CI существенно падает. А значит, падает и время ожидания, и количество контекстных переключений.
# London school и classicist school в реальном проекте
Спор между школами тестирования существует давно и часто подается как выбор одного лагеря. На практике полезнее понимать, в чем сила каждой школы и где ее границы.
London school чаще использует моки и проверяет взаимодействия. Если ваш код это оркестратор нескольких внешних вызовов, этот подход дает очень точный контроль над тем, кто и как был вызван.
Classicist school чаще проверяет состояние и поведение через реальные объекты без агрессивного мокинга. Если у вас много чистой доменной логики, такой стиль обычно делает тесты более устойчивыми к рефакторингу.
Давайте посмотрим на один и тот же кейс двумя стилями.
В mock-heavy подходе:
from unittest.mock import Mock
def register_user(repo, email_sender, email: str) -> None:
user_id = repo.create(email=email)
email_sender.send_welcome(user_id=user_id, email=email)
def test_register_user_calls_dependencies():
repo = Mock()
repo.create.return_value = 101
email_sender = Mock()
register_user(repo, email_sender, "alice@example.com")
repo.create.assert_called_once_with(email="alice@example.com")
email_sender.send_welcome.assert_called_once_with(
user_id=101,
email="alice@example.com",
)В state-oriented подходе:
class InMemoryRepo:
def __init__(self):
self.items = []
def create(self, email: str) -> int:
user_id = len(self.items) + 1
self.items.append({"id": user_id, "email": email})
return user_id
class InMemoryEmailSender:
def __init__(self):
self.sent = []
def send_welcome(self, user_id: int, email: str) -> None:
self.sent.append({"user_id": user_id, "email": email})
def test_register_user_changes_state():
repo = InMemoryRepo()
sender = InMemoryEmailSender()
register_user(repo, sender, "alice@example.com")
assert repo.items == [{"id": 1, "email": "alice@example.com"}]
assert sender.sent == [{"user_id": 1, "email": "alice@example.com"}]Оба теста полезны. Первый лучше контролирует контракт вызовов. Второй лучше переживает рефакторинг внутренней реализации.
# Моки и стабы: когда помогают, а когда ломают картину
Мок нужен там, где без него тест становится нестабильным, дорогим или слишком широким. Хороший признак полезного мокинга это изоляция внешнего мира, а не изоляция собственной логики.
Разберем пример с внешним API курса валют. В production-коде мы делаем HTTP-запрос, но в юнит-тесте хотим проверить реакцию на ответы API без реальной сети.
import requests
def fetch_rate(base: str, quote: str) -> float:
resp = requests.get(
"https://rates.example.com/latest",
params={"base": base, "quote": quote},
timeout=3,
)
data = resp.json()
return float(data["rate"])Юнит-тест с mock:
from unittest.mock import Mock, patch
def test_fetch_rate_parses_response():
fake_response = Mock()
fake_response.json.return_value = {"rate": "95.4"}
with patch("requests.get", return_value=fake_response) as get_mock:
rate = fetch_rate("USD", "RUB")
assert rate == 95.4
get_mock.assert_called_once()Тут мок оправдан. Мы не тестируем requests, мы тестируем,
как наша функция обрабатывает ответ.
Но если мы начнем мокать каждый метод собственного репозитория,
каждую внутреннюю функцию и каждый if, тесты станут хрупкими.
Они будут падать от технического рефакторинга, даже если
поведение не изменилось.
# unittest, pytest, nose2: что выбирать в 2026 году
Встроенный unittest остается рабочим вариантом, особенно там,
где важна минимизация зависимостей и строгая стандартизация.
pytest в повседневной разработке обычно выигрывает за счет
читаемости, параметризации, фикстур и удобной экосистемы.
Для большинства новых Python-проектов это практический выбор по
умолчанию.
nose стоит рассматривать как исторический этап экосистемы.
nose2 жив, но в новых кодовых базах он встречается редко,
потому что pytest решает типичные задачи проще и с большей
поддержкой сообщества.
# AAA и структура теста, которую удобно читать через год
Одна из самых простых техник, которая сильно повышает поддерживаемость тестов, это явная структура Arrange, Act, Assert. Даже без комментариев она удерживает тест коротким и линейным.
def calculate_total(price: int, quantity: int, discount: float) -> int:
subtotal = price * quantity
return int(subtotal * (1 - discount))
def test_calculate_total_with_discount():
price = 500
quantity = 2
discount = 0.1
result = calculate_total(price, quantity, discount)
assert result == 900Здесь тест легко сканируется глазами. Подготовка данных, действие, проверка. Ничего лишнего.
Если тест содержит слишком много действий и проверок, это часто сигнал, что он пытается проверить слишком много разных сценариев сразу. Лучше разделить такой тест на несколько маленьких.
# Фикстуры, setup/teardown и контроль над состоянием
Фикстуры в pytest решают ключевую проблему дублирования
подготовки данных и окружения. Но их легко превратить в
непрозрачную магию, если прятать внутри слишком много логики.
Сравним плохой и хороший подход.
Плохо, когда фикстура делает слишком много:
import pytest
@pytest.fixture
def app_everything():
# создает БД, поднимает сервис, загружает данные,
# настраивает очередь, запускает фоновые воркеры
return {"warning": "fixture is too broad"}Хорошо, когда фикстуры узкие и ясные по назначению:
import pytest
@pytest.fixture
def user_email():
return "alice@example.com"
@pytest.fixture
def in_memory_repo():
class Repo:
def __init__(self):
self.items = []
def save(self, email: str):
self.items.append(email)
return Repo()
def test_repo_save(in_memory_repo, user_email):
in_memory_repo.save(user_email)
assert in_memory_repo.items == ["alice@example.com"]Чем яснее фикстуры, тем проще понимать, откуда в тесте взялись данные и почему они именно такие.
# Рекомендации по юнит-тестам через конкретные примеры
Сильный юнит-тест обычно проверяет один смысловой сценарий. Рассмотрим функцию нормализации телефона.
import re
def normalize_phone(value: str) -> str:
digits = re.sub(r"\D", "", value)
if len(digits) == 11 and digits.startswith("8"):
digits = "7" + digits[1:]
if len(digits) != 11:
raise ValueError("invalid phone")
return "+" + digitsПлохой тест на такую функцию часто пытается проверить сразу пять форматов и две ошибки в одном кейсе. Хороший набор тестов разделяет сценарии.
import pytest
def test_normalize_phone_ru_local_prefix():
assert normalize_phone("8 (999) 000-00-00") == "+79990000000"
def test_normalize_phone_already_international():
assert normalize_phone("+7 999 000 00 00") == "+79990000000"
def test_normalize_phone_invalid_length():
with pytest.raises(ValueError):
normalize_phone("123")Теперь при падении точно видно, какой именно сценарий сломан.
# Как организовать test suite проекта, чтобы им реально пользовались
Рабочий test suite начинается с понятной структуры каталогов и режимов запуска. Когда уровни тестов разделены, разработчику проще выбирать нужный пакет под конкретную задачу.
project/
app/
domain/
application/
infrastructure/
tests/
unit/
integration/
e2e/
pyproject.tomlВ pyproject.toml удобно зафиксировать маркеры и общие настройки
pytest, чтобы команда запускала тесты единообразно.
[tool.pytest.ini_options]
addopts = "-ra -q"
testpaths = ["tests"]
markers = [
"integration: tests with real infrastructure",
"e2e: end-to-end scenarios",
]Тогда локально можно запускать быстрые тесты отдельно от тяжелых.
pytest tests/unit
pytest -m integration
pytest -m e2eКлючевой момент в том, что suite должен помогать разработчику, а не наказывать его долгими непредсказуемыми прогонами.
# Интеграционные тесты на примере API и базы данных
Разберем практический пример с FastAPI и SQLite, где мы хотим проверить полный путь “HTTP-запрос -> запись в БД -> HTTP-ответ”.
from fastapi import FastAPI
from pydantic import BaseModel
import sqlite3
app = FastAPI()
class ItemIn(BaseModel):
name: str
def get_conn():
conn = sqlite3.connect("test.db")
conn.execute("CREATE TABLE IF NOT EXISTS items(name TEXT)")
return conn
@app.post("/items")
def create_item(payload: ItemIn):
conn = get_conn()
conn.execute("INSERT INTO items(name) VALUES (?)", (payload.name,))
conn.commit()
conn.close()
return {"ok": True}Интеграционный тест:
from fastapi.testclient import TestClient
import sqlite3
client = TestClient(app)
def test_create_item_persists_to_db(tmp_path, monkeypatch):
db_path = tmp_path / "items.db"
def get_test_conn():
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE IF NOT EXISTS items(name TEXT)")
return conn
monkeypatch.setattr("main.get_conn", get_test_conn)
response = client.post("/items", json={"name": "book"})
assert response.status_code == 200
assert response.json() == {"ok": True}
conn = sqlite3.connect(db_path)
rows = conn.execute("SELECT name FROM items").fetchall()
conn.close()
assert rows == [("book",)]Этот тест уже не юнит. Он проверяет интеграцию HTTP-слоя, валидации и хранилища.
# E2E на примере Playwright
E2E полезно показывать на коротких, но бизнес-значимых сценариях. Например, логин пользователя.
from playwright.sync_api import sync_playwright
def test_login_e2e():
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("http://localhost:8000/login")
page.fill("input[name='email']", "alice@example.com")
page.fill("input[name='password']", "qwerty")
page.click("button[type='submit']")
page.wait_for_url("**/dashboard")
assert page.locator("text=Welcome").is_visible()
browser.close()В этом тесте мы не проверяем внутреннюю реализацию логина. Мы проверяем, что пользователь действительно может пройти критический путь.
# База данных в тестах: миграции, фикстуры, rollback
С БД в тестах есть одно правило, которое нельзя нарушать. Каждый тест должен быть независим по состоянию.
Один из рабочих шаблонов для SQLAlchemy это транзакция на тест, которая откатывается после выполнения.
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture
def db_session():
engine = create_engine("sqlite:///:memory:")
Session = sessionmaker(bind=engine)
session = Session()
tx = session.begin()
try:
yield session
finally:
tx.rollback()
session.close()Такой подход дает чистое состояние на каждый тест и сохраняет высокую скорость прогонов.
Если в проекте используются миграции, их нужно включать в процесс подготовки тестовой БД, иначе тесты начнут жить в схеме, которая отличается от production.
# Приватные методы: когда тестировать напрямую, а когда нет
По умолчанию лучше тестировать поведение через публичный API. Это делает тесты устойчивее к рефакторингу.
Рассмотрим пример.
class TaxCalculator:
def calculate(self, amount: int) -> int:
return amount + self._tax(amount)
def _tax(self, amount: int) -> int:
return int(amount * 0.2)Стабильный тест проверяет calculate, а не _tax.
def test_calculate_adds_tax():
calc = TaxCalculator()
assert calc.calculate(100) == 120Если завтра _tax будет заменен на сложную стратегию
с льготами, тест публичного поведения останется валидным,
если контракт не изменился.
Прямое тестирование приватного метода может быть временно оправдано в legacy-коде, но как постоянная практика оно обычно повышает хрупкость тестов.
# Время в тестах и детерминизм
Время это одна из самых частых причин flaky-тестов.
Если тест зависит от datetime.now(), он может вести себя
по-разному в разные дни, часы и timezone.
freezegun решает это элегантно.
from datetime import datetime
from freezegun import freeze_time
def current_period_start() -> str:
now = datetime.now()
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
@freeze_time("2026-02-12 10:00:00")
def test_current_period_start():
assert current_period_start() == "2026-02-01T00:00:00"Такой тест детерминирован и не зависит от машины запуска.
# Плотный блок про pytest: от базы до полезных встроенных фикстур
Начнем с базового примера с assert.
def multiply(a: int, b: int) -> int:
return a * b
def test_multiply_basic():
assert multiply(3, 4) == 12Параметризация позволяет расширять покрытие без копипаста.
import pytest
@pytest.mark.parametrize(
"a,b,expected",
[
(2, 3, 6),
(0, 10, 0),
(-2, 3, -6),
],
)
def test_multiply_parametrized(a, b, expected):
assert multiply(a, b) == expectedПроверка исключений:
import pytest
def divide(a: int, b: int) -> float:
if b == 0:
raise ZeroDivisionError("division by zero")
return a / b
def test_divide_zero_raises():
with pytest.raises(ZeroDivisionError, match="division by zero"):
divide(10, 0)tmp_path полезен для файловых сценариев.
def save_report(path, text: str) -> None:
path.write_text(text, encoding="utf-8")
def test_save_report(tmp_path):
report = tmp_path / "report.txt"
save_report(report, "ok")
assert report.read_text(encoding="utf-8") == "ok"monkeypatch помогает временно менять окружение и атрибуты.
import os
def service_url() -> str:
return os.getenv("SERVICE_URL", "https://prod.example.com")
def test_service_url_from_env(monkeypatch):
monkeypatch.setenv("SERVICE_URL", "https://test.example.com")
assert service_url() == "https://test.example.com"В реальных проектах такие встроенные инструменты снимают много болезненного ручного кода в тестах.
# Интеграция типизации и тестов: две сетки безопасности лучше одной
Тесты и статический анализ закрывают разные области риска. Type checker ловит типовые несоответствия до запуска. Тесты ловят поведенческие и интеграционные ошибки при выполнении.
Например, type checker может заранее подсветить ситуацию,
когда функция обещает вернуть int, а фактически возвращает
float или None. Тесты в этот момент еще даже не запускались.
В CI зрелого проекта обычно запускают и type checker, и pytest. Это дает плотный и быстрый контур качества. Сначала отсекаются структурные ошибки, затем проверяется поведение.
# CI/CD: как сделать тесты частью процесса, а не пожеланием
Если тесты живут только локально, их ценность ограничена. Кто-то запускает их, кто-то нет, а качество становится вопросом дисциплины отдельных людей. CI снимает этот риск, потому что проверки становятся обязательным этапом потока.
Для GitHub Actions минимальный рабочий пайплайн может выглядеть так:
name: tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pip install pytest pytest-cov pyright
- run: pyright
- run: pytest tests/unit tests/integration --cov=app --cov-fail-under=80Для GitLab CI логика та же:
stages:
- test
test:
stage: test
image: python:3.12
script:
- pip install -r requirements.txt
- pip install pytest pytest-cov pyright
- pyright
- pytest tests/unit tests/integration --cov=app --cov-fail-under=80Ключевая инженерная мысль здесь простая. Неважно, какая платформа CI выбрана. Важно, что правила качества автоматизированы и единообразны для всех.
# Частые анти-паттерны, из-за которых тесты теряют ценность
Один из самых неприятных анти-паттернов это тестирование внутренней реализации вместо внешнего поведения. Такой тест привязывается к тому, как код написан сейчас, и ломается при любом рефакторинге, даже если контракт не менялся.
Второй анти-паттерн это смешивание уровней в одном тесте. Тест называется юнитом, но внутри идет в сеть, использует реальную БД, зависит от текущего времени и случайности. Результат предсказуем: долгий и нестабильный прогон.
Третий анти-паттерн это flaky-тесты, которые иногда красные, иногда зеленые без изменений в коде. Как только команда привыкает к мысли “этот тест иногда сам по себе падает”, доверие к тестовому пакету рушится.
Четвертый анти-паттерн это попытка покрыть абсолютно все подряд, включая чужие библиотеки и тривиальные прокси-методы без бизнес-смысла. Покрытие растет, но полезный сигнал не растет.
# Реальный сценарий Red -> Green -> Refactor на короткой функции
Сделаем еще один компактный TDD-сценарий, на этот раз про валидацию пароля. Требование такое: пароль должен быть длиной не меньше восьми символов, содержать хотя бы одну цифру и хотя бы одну букву.
Сначала Red:
def test_validate_password_ok():
assert validate_password("abc12345") is True
def test_validate_password_too_short():
assert validate_password("a1") is FalseGreen, минимальная реализация:
def validate_password(value: str) -> bool:
if len(value) < 8:
return False
has_digit = any(ch.isdigit() for ch in value)
has_alpha = any(ch.isalpha() for ch in value)
return has_digit and has_alphaRefactor и расширение тестов:
import pytest
@pytest.mark.parametrize(
"value,expected",
[
("abc12345", True),
("abcdefgh", False),
("12345678", False),
("ab12", False),
],
)
def test_validate_password_parametrized(value, expected):
assert validate_password(value) is expectedЭто простой пример, но он хорошо показывает идею. Тесты сначала формулируют контракт, потом поддерживают безопасные изменения.
# Что делать уже завтра в реальном проекте
Если в проекте тестов мало или они хаотичны, не нужно пытаться переписать все за одну итерацию. Практичнее начать с маленького, но системного шага. Выделить критический бизнес-путь и закрыть его базовым набором проверок на трех уровнях: быстрые юниты, несколько интеграционных тестов на ключевые контракты и один-два E2E сценария на пользовательскую ценность.
Параллельно стоит зафиксировать минимальные правила качества в CI. Запуск юнитов, запуск интеграционных тестов, запуск type checker, порог покрытия, блокировка merge при красном пайплайне. Это уже создает каркас процесса, который можно постепенно усиливать.
По мере роста кода нужно следить не только за числом тестов, но и за их полезностью. Каждый тест должен отвечать на вопрос, какой риск он закрывает. Когда тесты начинают жить своей жизнью и проверять не риск, а случайные детали, их ценность быстро снижается.
# Вместо финала
Тестирование в Python не является бюрократией, если к нему подходить как к инженерному инструменту, а не как к формальному требованию. Тесты помогают быстрее двигаться, потому что дают предсказуемый сигнал, удерживают систему в рабочем состоянии и позволяют безопасно менять код, который уже приносит бизнесу ценность.
Команда, которая умеет писать понятные юниты, осмысленные интеграционные тесты и ограниченный набор E2E на критический путь, обычно выпускает изменения чаще и спокойнее. Не потому, что у нее “больше тестов”, а потому что у нее лучше управляется риск.
И в этом главный смысл всей темы. Тесты нужны не ради тестов. Они нужны ради управляемой разработки.