Легаси-код и CI

Тестирование легаси-кода, запуск тестов в CI и финальный чек-лист курса.

Легаси-код — это, по выражению Майкла Фезерса, просто код без тестов. Главная цель — окружить его тестами, прежде чем менять.

Как подступиться к коду без тестов

Страшно менять код, который не покрыт тестами: непонятно, что сломаешь. Стратегия безопасна и пошагова:

  1. Характеризующие тесты. Сначала пишут тесты, фиксирующие текущее поведение — даже если оно странное. Цель не «как должно быть», а «как есть сейчас», чтобы заметить любое изменение.
  2. Находим «швы». Ищем места, куда можно подставить мок (параметры, зависимости). Если функция жёстко вызывает БД — выносим зависимость в аргумент.
  3. Меняем под защитой тестов. Теперь рефакторинг безопасен: тесты покраснеют при любой регрессии.

Пример: характеризующий тест

Допустим, есть старая функция, и мы не уверены в её поведении на краях. Пишем тест, который документирует то, что она делает прямо сейчас:

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.
Проверьте себя
1. Что такое легаси-код по определению из урока?
AСтарый код на Python 2
BКод с багами
CЧужой код
DКод без тестов
2. Что такое характеризующий тест?
AТест, фиксирующий текущее (как есть) поведение, чтобы заметить любое его изменение
BТест идеального поведения
CТест производительности
DТест UI
3. Что делает CI с тестами?
AПишет тесты за вас
BАвтоматически прогоняет тесты на каждый push и блокирует красные изменения
CУдаляет упавшие тесты
DЗаменяет unittest на pytest