Python · Тестирование с pytest · Экспертный

Test-Driven Development

5 задач

Управляйте проектированием через тесты: сначала пишите падающие тесты, затем реализуйте код.

Разработка через тестирование: Красный-Зелёный-Рефакторинг

#
Разработка через тестирование (TDD) инвертирует обычный порядок написания кода: сначала пишется тест до какой-либо реализации, затем пишется ровно столько кода, чтобы тест прошёл, затем код улучшается. Дисциплина выражена в трёхфазном цикле под названием Красный-Зелёный-Рефакторинг. ## Цикл Красный-Зелёный-Рефакторинг **Красный** -- напишите тест для поведения, которого ещё не существует. Запустите его. Он падает -- вывод красный. Это намеренно и правильно. Падающий тест до любой реализации доказывает, что тест действительно проверяет что-то реальное. Если совершенно новый тест проходит без каких-либо изменений кода, либо поведение уже реализовано в другом месте, либо тест неверен. **Зелёный** -- напишите минимальный код, который делает падающий тест проходящим. Ничего лишнего. Никакого кода "на будущее". Никаких излишеств. Единственная цель -- зелёный вывод. Напишите простейшее, что может работать. **Рефакторинг** -- теперь, когда код работает, улучшите его. Устраните дублирование, переименуйте для ясности, выделите вспомогательные функции, реорганизуйте. Тесты защищают вас: если рефакторинг что-то сломает, вы узнаете немедленно. Это фаза, в которой пишется хороший код -- не в фазе Зелёный. ``` [Красный] Написать падающий тест для следующего поведения | v [Зелёный] Написать минимальный код для прохождения теста | v [Рефакторинг] Улучшить код, пока тесты остаются зелёными | v [Красный] Написать следующий падающий тест ... ``` Каждый цикл короткий -- несколько минут максимум. Цикл намеренно мал. Вы никогда не накапливаете большие объёмы непроверенного кода. ## Зачем писать тест первым? Аргументы в пользу test-first -- не просто дисциплина, они меняют то, что вы строите и как вы это строите. **Тесты, написанные после кода, описывают что код делает, а не что он должен делать.** Когда вы пишете тесты ретроспективно, вы уже знаете реализацию, поэтому неосознанно подтверждаете существующее, а не указываете задуманное. Граничные случаи, которые не реализованы, не тестируются, потому что вы о них не подумали. **Трудно тестируемый код сигнализирует о проблеме дизайна.** Функция, требующая 8 шагов настройки перед вызовом, трудна для написания теста -- и трудна для использования в продакшне. С TDD вы обнаруживаете это до того, как код написан. Боль от сложного теста ведёт к лучшему дизайну. **Тест -- первый клиент вашего API.** Написание теста -- это первый раз, когда вы "вызываете" свой код. Если вызов в тесте ощущается неудобным, API неудобен. TDD выявляет проблемы API, пока их ещё можно исправить с нулевыми затратами. **Покрытие спецификации автоматическое.** Каждая строка реализации существует потому, что её потребовал падающий тест. Если ни один тест не потребовал строку, она не пишется. ## Когда TDD оправдывает себя TDD приносит наибольшую пользу когда: - **Логика нетривиальна.** Правила валидации, бизнес-ограничения, автоматы состояний, разбор, вычисления с множеством ветвящихся условий -- любой код, где корректность зависит от тонких комбинаций входных данных. - **Вы проектируете API.** TDD -- инструмент проектирования. Тест, который легко написать, предполагает хороший API; болезненный тест предполагает плохой. Трение появляется до того, как код зафиксирован. - **Вы будете рефакторить.** Рефакторинг без тестов -- это угадывание. С TDD каждое поведение имеет тест до начала любого рефакторинга -- вы можете свободно реструктурировать и доверять тестам обнаружение регрессий. - **Корректность важнее скорости.** Финансовые вычисления, чувствительный к безопасности код, преобразования данных с граничными случаями -- места, где тихий неверный ответ хуже очевидного падения. ## Когда TDD менее ценен TDD подходит не для каждой ситуации: - **Исследовательские спайки.** Когда вы ещё не знаете, что строите -- прототипируете подход, изучаете незнакомую библиотеку, пробуете алгоритм -- сначала напишите спайк для обучения, затем выбросьте его и начните TDD с реальной реализацией. Тесты на одноразовый код -- это потери. - **Тонкий клей-код.** Определения маршрутов, файлы миграций, декларативная конфигурация -- код без логики для тестирования. Тест на то, что Django сопоставил `/users/` с `UserView`, не добавляет уверенности. - **UI-рендеринг и вёрстка.** Утверждения о пиксельных позициях или поведении анимаций хрупки и требуют много обслуживания. Тестируйте логику, питающую UI; используйте более лёгкие инструменты для UI-интеграции. - **Тонкие обёртки внешних API.** `return requests.get(url).json()` не имеет ничего значимого для тестирования кроме "вызвали ли мы requests". Такие тесты хрупки и не добавляют уверенности. ## Как TDD формирует структуру кода Код, выращенный test-first, склонен приобретать определённые структурные свойства -- не из-за дисциплины, а потому что тесты требуют этого во время разработки: **Меньшие функции.** Функция, требующая 8 аргументов или глубокой настройки для вызова, болезненна для тестирования. Боль появляется до того, как код существует, поэтому вы делаете функцию меньше. **Явные зависимости.** Если функции нужно соединение с базой данных, тест должен его предоставить. Путь наименьшего сопротивления -- сделать зависимость параметром (внедрение зависимостей), а не скрытым импортом на уровне модуля. TDD немедленно делает скрытые глобалы и импорты с побочными эффектами болезненными. **Больше чистых функций.** Функции, принимающие аргументы и возвращающие значения -- без общего состояния, без файлового ввода-вывода, без сети -- проще всего тестировать. TDD последовательно подталкивает вас писать их больше. **Минимальные, связные классы.** Класс, делающий слишком много, требует слишком много настройки в каждом тесте. Трение теста сигнализирует о проблеме дизайна до того, как она закрепляется. ## Распространённые возражения **"TDD занимает вдвое больше времени."** Тест нужно написать в какой-то момент -- вопрос в том, когда. Написание первым направляет реализацию и часто делает её быстрее. Настоящий пожиратель времени -- отладка непроверенного кода постфактум. TDD переносит затраты с отладки на проектирование, что является лучшим компромиссом. **"Я ещё не знаю, что строю."** Не нужно знать весь дизайн заранее. Нужно знать только следующее поведение. Напишите один тест для наименьшей следующей вещи. TDD итеративен -- вы обнаруживаете дизайн через тесты, а не до них. **"Мой код невозможно тестировать."** Нетестируемый код -- симптом проблем дизайна: тесная связанность, скрытые зависимости, глобальное состояние. TDD, применённый к новому коду, предотвращает их возникновение. Применённый к существующему коду, TDD действует как предохранительный клапан -- он делает проблемы дизайна видимыми как боль при тестировании, мотивируя вас их устранять.

TDD на практике: пошаговое создание BankAccount

#
## Приложение, которое мы построим Класс `BankAccount` с методами `deposit`, `withdraw` и `balance`, выращенный с помощью строгого TDD -- по одному тесту за раз, минимум кода для прохождения, затем рефакторинг. Два файла: ``` bank.py # продакшн-код -- начинается пустым test_bank.py # тесты -- растёт по одному тесту за раз ``` В начале оба файла пусты. --- ## Цикл 1: у нового счёта нулевой баланс **Красный** -- напишем первый тест: ```python # test_bank.py from bank import BankAccount def test_new_account_has_zero_balance(): account = BankAccount() assert account.balance == 0 ``` Запускаем: ``` $ pytest test_bank.py -v FAILED - ModuleNotFoundError: No module named 'bank' ``` Хорошо -- падает. У нас есть разрешение писать код. **Зелёный** -- абсолютный минимум: ```python # bank.py class BankAccount: def __init__(self): self.balance = 0 ``` ``` 1 passed ``` **Рефакторинг** -- пока нечего чистить. --- ## Цикл 2: депозит увеличивает баланс **Красный:** ```python def test_deposit_increases_balance(): account = BankAccount() account.deposit(100) assert account.balance == 100 ``` ``` FAILED - AttributeError: 'BankAccount' object has no attribute 'deposit' ``` **Зелёный:** ```python def deposit(self, amount): self.balance += amount ``` ``` 2 passed ``` **Рефакторинг** -- всё ещё нечего дедублировать. --- ## Цикл 3: снятие уменьшает баланс **Красный:** ```python def test_withdraw_decreases_balance(): account = BankAccount() account.deposit(200) account.withdraw(50) assert account.balance == 150 ``` **Зелёный:** ```python def withdraw(self, amount): self.balance -= amount ``` ``` 3 passed ``` --- ## Цикл 4: овердрафт вызывает ValueError Здесь тест вынуждает нас указать контракт -- тип ошибки, сообщение -- до того, как существует какой-либо код: **Красный:** ```python import pytest def test_withdraw_raises_when_insufficient_funds(): account = BankAccount() account.deposit(100) with pytest.raises(ValueError, match="Insufficient funds"): account.withdraw(150) ``` ``` FAILED - DID NOT RAISE <class 'ValueError'> ``` **Зелёный:** ```python def withdraw(self, amount): if amount > self.balance: raise ValueError("Insufficient funds") self.balance -= amount ``` ``` 4 passed ``` Сообщение об ошибке `"Insufficient funds"` было решено в тесте -- спецификация управляет деталью реализации. --- ## Цикл 5: сумма депозита должна быть положительной **Красный:** ```python def test_deposit_rejects_non_positive_amount(): account = BankAccount() with pytest.raises(ValueError, match="Amount must be positive"): account.deposit(-50) ``` **Зелёный:** ```python def deposit(self, amount): if amount <= 0: raise ValueError("Amount must be positive") self.balance += amount ``` ``` 5 passed ``` --- ## Цикл 6: сумма снятия тоже должна быть положительной Снятие нуля или отрицательного числа молча испортит баланс. Тест поймает это до того, как это может случиться: **Красный:** ```python def test_withdraw_rejects_non_positive_amount(): account = BankAccount() account.deposit(100) with pytest.raises(ValueError, match="Amount must be positive"): account.withdraw(0) ``` **Зелёный:** ```python def withdraw(self, amount): if amount <= 0: raise ValueError("Amount must be positive") if amount > self.balance: raise ValueError("Insufficient funds") self.balance -= amount ``` ``` 6 passed ``` --- ## Рефакторинг: выделить `_validate_positive` Оба `deposit` и `withdraw` теперь имеют идентичную валидацию. До того, как это разрослось, выделим её: **Шаг 1** -- добавить вспомогательную функцию (все 6 тестов по-прежнему проходят): ```python def _validate_positive(self, amount): if amount <= 0: raise ValueError("Amount must be positive") ``` **Шаг 2** -- заменить дублирующиеся проверки (все 6 тестов по-прежнему проходят): ```python def deposit(self, amount): self._validate_positive(amount) self.balance += amount def withdraw(self, amount): self._validate_positive(amount) if amount > self.balance: raise ValueError("Insufficient funds") self.balance -= amount ``` ``` 6 passed ``` Рефакторинг безопасен, потому что каждое поведение имеет тест. --- ## Итоговое состояние обоих файлов ```python # test_bank.py import pytest from bank import BankAccount def test_new_account_has_zero_balance(): account = BankAccount() assert account.balance == 0 def test_deposit_increases_balance(): account = BankAccount() account.deposit(100) assert account.balance == 100 def test_withdraw_decreases_balance(): account = BankAccount() account.deposit(200) account.withdraw(50) assert account.balance == 150 def test_withdraw_raises_when_insufficient_funds(): account = BankAccount() account.deposit(100) with pytest.raises(ValueError, match="Insufficient funds"): account.withdraw(150) def test_deposit_rejects_non_positive_amount(): account = BankAccount() with pytest.raises(ValueError, match="Amount must be positive"): account.deposit(-50) def test_withdraw_rejects_non_positive_amount(): account = BankAccount() account.deposit(100) with pytest.raises(ValueError, match="Amount must be positive"): account.withdraw(0) ``` ```python # bank.py class BankAccount: def __init__(self): self.balance = 0 def deposit(self, amount): self._validate_positive(amount) self.balance += amount def withdraw(self, amount): self._validate_positive(amount) if amount > self.balance: raise ValueError("Insufficient funds") self.balance -= amount def _validate_positive(self, amount): if amount <= 0: raise ValueError("Amount must be positive") ``` --- ## Что дал нам TDD - 6 поведений, каждое указанное тестом до того, как существовал код. - Сообщения об ошибках были решены в тестах -- спецификация управляет деталями реализации, а не наоборот. - Вспомогательная функция `_validate_positive` появилась из рефакторинга, а не из предварительного проектирования. Тест-сьют сделал её безопасной для выделения. - В `bank.py` нет кода, не покрытого тестом -- каждая строка там потому, что её потребовал падающий тест. - В любой точке цикла запуск `pytest -v` говорит вам точно, где вы находитесь.

Справочник TDD: цикл, именование и паттерны

#
## Цикл TDD ``` 1. [Красный] Написать падающий тест для следующего поведения 2. [Зелёный] Написать минимальный код для его прохождения -- ничего лишнего 3. [Рефакторинг] Навести порядок в коде, пока все тесты зелёные 4. Повторить для следующего поведения ``` ## Команды pytest для TDD ```bash # Остановиться на первом падении -- самая быстрая обратная связь при реализации pytest -x # Запустить только тесты, соответствующие шаблону имени pytest -k "withdraw" # Запустить один конкретный тест pytest test_bank.py::test_withdraw_raises_when_insufficient_funds -v # Подробный вывод: видеть имя каждого теста и результат pytest -v # Режим наблюдения: перезапускать при сохранении файла (требует pytest-watch) pip install pytest-watch ptw -- -x ``` ## Именование тестов для TDD Хорошие имена читаются как спецификация. Паттерн: `test_<субъект>_<поведение>_<условие>`. | Имя | Читается как | |---|---| | `test_new_account_has_zero_balance` | "У нового счёта нулевой баланс" | | `test_withdraw_raises_when_insufficient_funds` | "Снятие вызывает ошибку при недостаточных средствах" | | `test_deposit_rejects_non_positive_amount` | "Депозит отвергает неположительные суммы" | | `test_email_validator_returns_false_for_empty_string` | Условие явное | Избегайте: `test_bank_1`, `test_edge_case`, `test_stuff`. Когда тест падает, его имя должно говорить что сломалось без чтения тела. ## Проверка фазы Красный Новый тест, немедленно проходящий (без изменений кода), означает одно из двух: 1. Поведение уже реализовано -- оставьте тест как защиту от регрессий. 2. Тест неверен -- он не упражняет поведение, которое вы думаете. Для проверки: временно сломайте реализацию и убедитесь что тест краснеет: ```python def deposit(self, amount): pass # намеренно сломано # Если test_deposit_increases_balance теперь ПАДАЕТ -> тест верен # Если он по-прежнему ПРОХОДИТ -> тест не тестирует deposit ``` Восстановите реализацию после проверки. ## Безопасный рефакторинг Правило: **все тесты должны проходить до и после каждого шага рефакторинга**. Никогда не рефакторьте, пока любой тест красный. ```python # До: дублирование в двух методах def deposit(self, amount): if amount <= 0: raise ValueError("Amount must be positive") self.balance += amount def withdraw(self, amount): if amount <= 0: raise ValueError("Amount must be positive") ... # Шаг рефакторинга 1: добавить вспомогательную функцию (тесты по-прежнему проходят) def _validate_positive(self, amount): if amount <= 0: raise ValueError("Amount must be positive") # Шаг рефакторинга 2: заменить дубликаты (тесты по-прежнему проходят) def deposit(self, amount): self._validate_positive(amount) self.balance += amount ``` Делайте одно маленькое изменение, запускайте тесты, убеждайтесь в зелёном, затем делайте следующее изменение. ## TDD с parametrize `@pytest.mark.parametrize` органично вписывается в TDD: напишите полную спецификацию (все тест-кейсы) до любой реализации. Все кейсы падают одновременно (Красный); одна реализация делает их все проходящими (Зелёный): ```python import pytest @pytest.mark.parametrize("amount,is_valid", [ (100, True), (0.01, True), (0, False), (-50, False), ]) def test_deposit_validates_amount(amount, is_valid): account = BankAccount() if is_valid: account.deposit(amount) assert account.balance == pytest.approx(amount) else: with pytest.raises(ValueError): account.deposit(amount) ``` Вывод parametrize показывает каждый кейс отдельно -- вы можете отслеживать прогресс через фазу Красный, когда кейсы зеленеют один за другим. ## Характеризующие тесты для легаси-кода При добавлении тестов к существующему коду без тестов пишите "характеризующие тесты" -- тесты, документирующие то, что код делает сейчас: ```python # Шаг 1: написать тесты для фиксации текущего поведения (они проходят немедленно) def test_word_count_splits_on_spaces(): stats = TextStats("hello world foo") assert stats.word_count() == 3 # Шаг 2: рефакторить реализацию, держа тесты зелёными # Шаг 3: добавить новые тесты для нового поведения ``` Тесты не указывают что код должен делать -- они фиксируют что он делает. После фиксации можно рефакторить безопасно. ## Делаем время-зависимый код тестируемым Внедряйте время как зависимость вместо прямого вызова `time.time()`: ```python import time class RateLimiter: def __init__(self, calls_per_second, time_fn=None): self.limit = calls_per_second self._time = time_fn if time_fn is not None else time.time # None = реальные часы self._window_start = self._time() self._call_count = 0 def allow(self): now = self._time() if now - self._window_start >= 1.0: self._window_start = now self._call_count = 0 if self._call_count < self.limit: self._call_count += 1 return True return False ``` В тестах передавайте фиктивные часы -- `time.sleep` не нужен: ```python fake_time = [0.0] limiter = RateLimiter(2, time_fn=lambda: fake_time[0]) fake_time[0] = 1.5 # мгновенно сдвинуть время ``` ## Распространённые ошибки TDD | Ошибка | Последствие | Исправление | |---|---|---| | Написать несколько тестов до первого Зелёного | Каскад падений, сложно изолировать | По одному тесту за раз | | Фаза Зелёный добавляет больше кода, чем нужно | Неспецифицированное поведение без тестов | Сделать только падающий тест проходящим | | Пропуск фазы Рефакторинг | Технический долг накапливается каждый цикл | Всегда рефакторить, пусть и кратко | | Не подтверждать Красный перед написанием кода | Тест может ничего не проверять | Всегда смотреть как тест падает | | Тестировать внутренности реализации | Тесты ломаются при рефакторинге, не при изменении поведения | Тестировать поведение (вход/выход), не внутренности | | Рефакторинг крупными шагами | Сложно определить какой шаг сломал тест | Маленькие шаги, запускать тесты между каждым |
01

Реализовать класс Stack с помощью TDD

#

Используя TDD, реализуйте класс `Stack` с четырьмя методами: `push(item)`, `pop()`, `peek()` и `is_empty()`. **Шаг 1 -- Красный:** Сначала заполните все 7 тел тестов. Запустите pytest -- все 7 должны упасть с `AttributeError` или `AssertionError`. Не трогайте `stack.py` пока. Тесты для написания: 1. `test_new_stack_is_empty` -- `Stack().is_empty()` возвращает `True` 2. `test_push_makes_stack_non_empty` -- после `push(1)`, `is_empty()` возвращает `False` 3. `test_pop_returns_last_pushed_item` -- добавить 1, 2, 3; `pop()` возвращает `3` 4. `test_pop_removes_the_item` -- после push и pop, `is_empty()` возвращает `True` 5. `test_peek_returns_top_without_removing` -- `peek()` возвращает верхний элемент; `is_empty()` по-прежнему `False` после 6. `test_pop_raises_on_empty_stack` -- `pop()` на пустом стеке вызывает `IndexError` 7. `test_peek_raises_on_empty_stack` -- `peek()` на пустом стеке вызывает `IndexError` **Шаг 2 -- Зелёный/Рефакторинг:** Реализуйте `Stack` по одному методу за раз, запуская pytest после каждого добавления.

# stack.py
class Stack:
    pass


# test_stack.py
import pytest
from stack import Stack


def test_new_stack_is_empty():
    pass


def test_push_makes_stack_non_empty():
    pass


def test_pop_returns_last_pushed_item():
    pass


def test_pop_removes_the_item():
    pass


def test_peek_returns_top_without_removing():
    pass


def test_pop_raises_on_empty_stack():
    pass


def test_peek_raises_on_empty_stack():
    pass
Решение
# stack.py
class Stack:
    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        if self.is_empty():
            raise IndexError("pop from empty stack")
        return self._items.pop()

    def peek(self):
        if self.is_empty():
            raise IndexError("peek at empty stack")
        return self._items[-1]

    def is_empty(self):
        return len(self._items) == 0


# test_stack.py
import pytest
from stack import Stack


def test_new_stack_is_empty():
    stack = Stack()
    assert stack.is_empty() is True


def test_push_makes_stack_non_empty():
    stack = Stack()
    stack.push(1)
    assert stack.is_empty() is False


def test_pop_returns_last_pushed_item():
    stack = Stack()
    stack.push(1)
    stack.push(2)
    stack.push(3)
    assert stack.pop() == 3


def test_pop_removes_the_item():
    stack = Stack()
    stack.push(42)
    stack.pop()
    assert stack.is_empty() is True


def test_peek_returns_top_without_removing():
    stack = Stack()
    stack.push(10)
    stack.push(20)
    assert stack.peek() == 20
    assert stack.is_empty() is False


def test_pop_raises_on_empty_stack():
    stack = Stack()
    with pytest.raises(IndexError):
        stack.pop()


def test_peek_raises_on_empty_stack():
    stack = Stack()
    with pytest.raises(IndexError):
        stack.peek()
02

Сначала написать тесты, затем реализовать validate_email

#

Используя TDD, реализуйте `validate_email(s: str) -> bool`. **Шаг 1 -- Красный:** Сначала напишите все 5 тел тестов. Запустите pytest -- все 5 падают потому что функция всегда возвращает `None` (что является ложным значением). Ничего не реализуйте пока. Необходимые тесты: 1. `test_valid_email_returns_true` -- `"[email protected]"` -> `True` 2. `test_missing_at_sign_returns_false` -- `"userexample.com"` -> `False` 3. `test_missing_local_part_returns_false` -- `"@example.com"` -> `False` 4. `test_missing_domain_returns_false` -- `"user@"` -> `False` 5. `test_empty_string_returns_false` -- `""` -> `False` **Шаг 2 -- Зелёный:** Реализуйте одно правило валидации за раз. После каждого правила запускайте pytest. Добавляйте только код, необходимый для прохождения текущего падающего теста.

# email_validator.py
def validate_email(s: str) -> bool:
    pass


# test_email.py
from email_validator import validate_email


def test_valid_email_returns_true():
    pass


def test_missing_at_sign_returns_false():
    pass


def test_missing_local_part_returns_false():
    pass


def test_missing_domain_returns_false():
    pass


def test_empty_string_returns_false():
    pass
Решение
# email_validator.py
def validate_email(s: str) -> bool:
    if not s:
        return False
    if '@' not in s:
        return False
    local, _, domain = s.partition('@')
    if not local:
        return False
    if not domain:
        return False
    return True


# test_email.py
from email_validator import validate_email


def test_valid_email_returns_true():
    assert validate_email("[email protected]") is True


def test_missing_at_sign_returns_false():
    assert validate_email("userexample.com") is False


def test_missing_local_part_returns_false():
    assert validate_email("@example.com") is False


def test_missing_domain_returns_false():
    assert validate_email("user@") is False


def test_empty_string_returns_false():
    assert validate_email("") is False
03

Написать характеризующие тесты для легаси-кода, затем безопасно рефакторить

#

Вам дан класс `TextStats` с существующим поведением, но без тестов. Ваша задача состоит из двух частей: **Часть 1 -- Характеризация:** Напишите 4 теста, документирующих текущее поведение. Эти тесты должны проходить немедленно после заполнения (код уже работает). Они не задают новое поведение -- они фиксируют существующее, чтобы рефакторинг был безопасным. Тесты для написания: 1. `test_word_count` -- `TextStats("hello world foo").word_count()` возвращает `3` 2. `test_char_count` -- `TextStats("hello").char_count()` возвращает `5` 3. `test_most_common_word` -- `TextStats("cat dog cat").most_common_word()` возвращает `"cat"` 4. `test_most_common_word_is_case_insensitive` -- `TextStats("Cat cat Dog").most_common_word()` возвращает `"cat"` **Часть 2 -- Рефакторинг:** Когда все 4 теста проходят, перепишите `most_common_word` с использованием `collections.Counter`. Запустите тесты после -- они должны по-прежнему проходить. ```python # text_stats.py -- дано, не изменять в Части 1 class TextStats: def __init__(self, text: str): self.text = text def word_count(self): return len(self.text.split()) def char_count(self): return len(self.text) def most_common_word(self): words = self.text.lower().split() counts = {} for word in words: if word in counts: counts[word] += 1 else: counts[word] = 1 best, best_count = None, 0 for word, count in counts.items(): if count > best_count: best, best_count = word, count return best ```

# text_stats.py -- дано, не изменять в Части 1
class TextStats:
    def __init__(self, text: str):
        self.text = text

    def word_count(self):
        return len(self.text.split())

    def char_count(self):
        return len(self.text)

    def most_common_word(self):
        words = self.text.lower().split()
        counts = {}
        for word in words:
            if word in counts:
                counts[word] += 1
            else:
                counts[word] = 1
        best, best_count = None, 0
        for word, count in counts.items():
            if count > best_count:
                best, best_count = word, count
        return best


# test_text_stats.py
from text_stats import TextStats


def test_word_count():
    pass


def test_char_count():
    pass


def test_most_common_word():
    pass


def test_most_common_word_is_case_insensitive():
    pass
Решение
# test_text_stats.py
from text_stats import TextStats


def test_word_count():
    stats = TextStats("hello world foo")
    assert stats.word_count() == 3


def test_char_count():
    stats = TextStats("hello")
    assert stats.char_count() == 5


def test_most_common_word():
    stats = TextStats("cat dog cat")
    assert stats.most_common_word() == "cat"


def test_most_common_word_is_case_insensitive():
    stats = TextStats("Cat cat Dog")
    assert stats.most_common_word() == "cat"


# text_stats.py -- после рефакторинга
from collections import Counter

class TextStats:
    def __init__(self, text: str):
        self.text = text

    def word_count(self):
        return len(self.text.split())

    def char_count(self):
        return len(self.text)

    def most_common_word(self):
        words = self.text.lower().split()
        if not words:
            return None
        return Counter(words).most_common(1)[0][0]
04

TDD с время-зависимым RateLimiter -- внедряем часы

#

Используя TDD, реализуйте класс `RateLimiter(calls_per_second, time_fn=None)` с единственным методом `allow() -> bool`. Параметр `time_fn` принимает любой вызываемый объект, возвращающий текущее время как float. По умолчанию `time.time`. Это делает часы внедряемыми -- тесты передают фиктивные часы вместо ожидания. **Шаг 1 -- Красный:** Напишите все 5 тестов с фиктивными часами (без `time.sleep`). Все падают, потому что `RateLimiter` ещё не существует. Тесты для написания: 1. `test_first_call_is_always_allowed` 2. `test_calls_within_limit_are_allowed` -- при limit=2 два вызова при t=0 оба `True` 3. `test_calls_exceeding_limit_are_denied` -- при limit=2 третий вызов при t=0 равен `False` 4. `test_calls_reset_after_one_second` -- после продвижения фиктивного времени до 1.0 вызовы снова разрешены 5. `test_two_limiters_are_independent` -- исчерпание одного ограничителя не влияет на другой **Шаг 2 -- Зелёный/Рефакторинг:** Реализуйте `RateLimiter` используя фиксированное окно в 1 секунду. Окно сбрасывается когда `now - window_start >= 1.0`.

# rate_limiter.py
class RateLimiter:
    pass


# test_rate_limiter.py
from rate_limiter import RateLimiter


def test_first_call_is_always_allowed():
    pass


def test_calls_within_limit_are_allowed():
    pass


def test_calls_exceeding_limit_are_denied():
    pass


def test_calls_reset_after_one_second():
    pass


def test_two_limiters_are_independent():
    pass
Решение
# rate_limiter.py
import time

class RateLimiter:
    def __init__(self, calls_per_second, time_fn=None):
        self.limit = calls_per_second
        self._time = time_fn if time_fn is not None else time.time
        self._window_start = self._time()
        self._call_count = 0

    def allow(self):
        now = self._time()
        if now - self._window_start >= 1.0:
            self._window_start = now
            self._call_count = 0
        if self._call_count < self.limit:
            self._call_count += 1
            return True
        return False


# test_rate_limiter.py
from rate_limiter import RateLimiter


def test_first_call_is_always_allowed():
    fake_time = [0.0]
    limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
    assert limiter.allow() is True


def test_calls_within_limit_are_allowed():
    fake_time = [0.0]
    limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
    assert limiter.allow() is True
    assert limiter.allow() is True


def test_calls_exceeding_limit_are_denied():
    fake_time = [0.0]
    limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
    limiter.allow()
    limiter.allow()
    assert limiter.allow() is False


def test_calls_reset_after_one_second():
    fake_time = [0.0]
    limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
    limiter.allow()
    limiter.allow()            # лимит достигнут при t=0
    fake_time[0] = 1.0        # сдвинуть фиктивные часы
    assert limiter.allow() is True   # новое окно, снова разрешено


def test_two_limiters_are_independent():
    fake_time = [0.0]
    a = RateLimiter(1, time_fn=lambda: fake_time[0])
    b = RateLimiter(1, time_fn=lambda: fake_time[0])
    a.allow()                  # исчерпать лимит a
    assert b.allow() is True   # b не затронут
05

TDD с parametrize: сначала спецификация, затем реализация

#

Используя TDD с `@pytest.mark.parametrize`, реализуйте `to_roman(n: int) -> str`, конвертирующий положительное целое число в строку римских цифр. **Шаг 1 -- Красный:** Параметризованный тест уже написан ниже. Запустите его -- все 10 кейсов падают, потому что `to_roman` возвращает `None`. Ничего не реализуйте пока. Прочитайте все 10 кейсов, чтобы понять полный контракт. **Шаг 2 -- Зелёный:** Реализуйте `to_roman`. Чистый подход: список пар `(value, numeral)` в убывающем порядке, включая субтрактивные случаи (`900 -> "CM"`, `400 -> "CD"`, `90 -> "XC"` и т.д.). Цикл: пока `n >= value`, добавлять цифру и вычитать значение. **Шаг 3 -- Рефакторинг:** Когда все 10 кейсов проходят, проверьте реализацию. Таблица поиска в правильном порядке? Все субтрактивные пары присутствуют? ```python # test_roman.py -- спецификация уже написана; запустите как есть import pytest from roman import to_roman @pytest.mark.parametrize("n,expected", [ (1, "I"), (4, "IV"), (9, "IX"), (14, "XIV"), (40, "XL"), (90, "XC"), (400, "CD"), (900, "CM"), (1994, "MCMXCIV"), (2024, "MMXXIV"), ]) def test_to_roman(n, expected): assert to_roman(n) == expected ```

# roman.py
def to_roman(n: int) -> str:
    pass


# test_roman.py -- не изменять; запустить pytest и наблюдать как все 10 падают
import pytest
from roman import to_roman


@pytest.mark.parametrize("n,expected", [
    (1,    "I"),
    (4,    "IV"),
    (9,    "IX"),
    (14,   "XIV"),
    (40,   "XL"),
    (90,   "XC"),
    (400,  "CD"),
    (900,  "CM"),
    (1994, "MCMXCIV"),
    (2024, "MMXXIV"),
])
def test_to_roman(n, expected):
    assert to_roman(n) == expected
Решение
# roman.py
def to_roman(n: int) -> str:
    values = [
        (1000, "M"),  (900, "CM"), (500, "D"),  (400, "CD"),
        (100,  "C"),  (90,  "XC"), (50,  "L"),  (40,  "XL"),
        (10,   "X"),  (9,   "IX"), (5,   "V"),  (4,   "IV"),
        (1,    "I"),
    ]
    result = ""
    for value, numeral in values:
        while n >= value:
            result += numeral
            n -= value
    return result


# test_roman.py -- без изменений
import pytest
from roman import to_roman


@pytest.mark.parametrize("n,expected", [
    (1,    "I"),
    (4,    "IV"),
    (9,    "IX"),
    (14,   "XIV"),
    (40,   "XL"),
    (90,   "XC"),
    (400,  "CD"),
    (900,  "CM"),
    (1994, "MCMXCIV"),
    (2024, "MMXXIV"),
])
def test_to_roman(n, expected):
    assert to_roman(n) == expected