Легаси-код и CI
Тестирование легаси-кода, запуск тестов в CI и финальный чек-лист курса.
Легаси-код — это, по выражению Майкла Фезерса, просто код без тестов. Главная цель — окружить его тестами, прежде чем менять.
Как подступиться к коду без тестов
Страшно менять код, который не покрыт тестами: непонятно, что сломаешь. Стратегия безопасна и пошагова:
- Характеризующие тесты. Сначала пишут тесты, фиксирующие текущее поведение — даже если оно странное. Цель не «как должно быть», а «как есть сейчас», чтобы заметить любое изменение.
- Находим «швы». Ищем места, куда можно подставить мок (параметры, зависимости). Если функция жёстко вызывает БД — выносим зависимость в аргумент.
- Меняем под защитой тестов. Теперь рефакторинг безопасен: тесты покраснеют при любой регрессии.
Пример: характеризующий тест
Допустим, есть старая функция, и мы не уверены в её поведении на краях. Пишем тест, который документирует то, что она делает прямо сейчас:
import unittest
# Легаси-функция: меняем её боимся, поведение неочевидно
def old_round_price(x):
return int(x * 100 + 0.5) / 100
class CharacterizationTest(unittest.TestCase):
# Фиксируем фактическое поведение «как есть»
def test_current_behavior(self):
self.assertEqual(old_round_price(2.345), 2.35)
self.assertEqual(old_round_price(2.344), 2.34)
self.assertEqual(old_round_price(0), 0.0)
unittest.main(argv=[''], exit=False, verbosity=2)
Вывод:
test_current_behavior (__main__.CharacterizationTest.test_current_behavior) ... ok ---------------------------------------------------------------------- Ran 1 test in 0.000s OK
Теперь, если кто-то «улучшит» округление, тест покраснеет и заставит сознательно решить: это намеренное изменение или случайная регрессия.
Делаем код тестируемым: внедрение зависимостей
Главный приём — не вызывать зависимость напрямую внутри функции, а принимать её аргументом. Тогда в тесте легко подставить мок.
import unittest
from unittest.mock import Mock
# Тестируемая версия: clock приходит снаружи, а не time.time() внутри
def is_expired(token_time, ttl, clock):
return clock() - token_time > ttl
class TestExpiry(unittest.TestCase):
def test_not_expired(self):
clock = Mock(return_value=1000)
self.assertFalse(is_expired(token_time=990, ttl=60, clock=clock))
def test_expired(self):
clock = Mock(return_value=2000)
self.assertTrue(is_expired(token_time=990, ttl=60, clock=clock))
unittest.main(argv=[''], exit=False, verbosity=2)
Вывод:
test_expired (__main__.TestExpiry.test_expired) ... ok test_not_expired (__main__.TestExpiry.test_not_expired) ... ok ---------------------------------------------------------------------- Ran 2 tests in 0.001s OK
Тесты в CI
CI (Continuous Integration) — сервис, который при каждом push автоматически прогоняет тесты. Если они красные, изменение не попадёт в основную ветку. Так баги ловятся до релиза. Конфигурация для GitHub Actions проста:
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m unittest discover -v
Ключевое — последняя строка: та же команда, что вы запускаете локально. CI просто делает это автоматически и на каждое изменение. Поскольку unittest встроен, ставить ничего не нужно; для pytest добавился бы шаг pip install pytest.
Финальный чек-лист тестирования
| Проверка | Сделано? |
| Каждый тест проверяет одно поведение | да/нет |
| Покрыты граничные случаи и ошибки | да/нет |
| Имена тестов читаются как утверждения | да/нет |
| Внешние зависимости замоканы | да/нет |
| Тесты быстрые и детерминированные | да/нет |
| Тесты независимы от порядка | да/нет |
| Тесты запускаются в CI на каждый push | да/нет |
Итог курса
- Легаси покрывают характеризующими тестами «как есть», затем безопасно меняют.
- Внедрение зависимостей (аргумент вместо прямого вызова) делает код тестируемым.
- CI прогоняет ту же команду
python -m unittest discoverна каждый push. - Вы освоили unittest, моки и принципы хороших тестов — фундамент переносится и на pytest.