Легаси-код и 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
Поддержать проект