Python · Тестування з pytest · Експертний

Test-Driven Development

5 завдань

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

Розробка через тестування: Red-Green-Refactor

#
Розробка через тестування (TDD) перевертає звичайний порядок написання коду: ви пишете тест до написання будь-якого коду реалізації, потім пишете рівно стільки коду, щоб тест пройшов, а потім покращуєте код. Дисципліна охоплена трифазним циклом, що називається Red-Green-Refactor. ## Цикл Red-Green-Refactor **Red** -- Напишіть тест для поведінки, якої ще не існує. Запустіть його. Він провалюється -- вивід є червоним. Це навмисно і правильно. Тест, що провалюється, до будь-якої реалізації доводить, що ваш тест справді щось тестує. Якщо абсолютно новий тест проходить без жодних змін коду, то або поведінка вже реалізована десь ще, або тест неправильний. **Green** -- Напишіть мінімальний код, який змушує тест, що провалюється, пройти. Не додавайте нічого зайвого. Ніякого коду "знадобиться пізніше". Ніякого полірування. Єдина мета -- зелений вивід. Пишіть найпростіше, що тільки може спрацювати. **Refactor** -- Тепер, коли код працює, покращуйте його. Видаляйте дублювання, перейменовуйте для ясності, виділяйте допоміжні функції, реорганізовуйте. Тести вас захищають: якщо рефакторинг щось зламав, ви дізнаєтесь про це негайно. Саме в цій фазі ви пишете якісний код -- а не у фазі Green. ``` [Red] Написати тест, що провалюється, для наступної поведінки ↓ [Green] Написати мінімальний код для його проходження ↓ [Refactor] Покращити код, поки тести залишаються зеленими ↓ [Red] Написати наступний тест, що провалюється ... ``` Кожен цикл короткий -- максимум кілька хвилин. Цикл навмисно маленький. Ви ніколи не накопичуєте великі обсяги неперевіреного коду. ## Навіщо писати тест першим? Аргумент на користь підходу "тест спочатку" -- це не просто дисципліна, він змінює те, що ви будуєте і як ви будуєте. **Тести, написані після коду, описують те, що код робить, а не те, що він має робити.** Ви вже знаєте реалізацію, коли пишете тести після факту, тому несвідомо підтверджуєте те, що існує, а не вказуєте те, що було задумано. Граничні випадки, які ви не реалізували, не тестуються, тому що ви не подумали їх протестувати. **Код, який важко тестувати, сигналізує про проблему дизайну.** Функція, що вимагає 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 формує структуру коду Код, що виріс через підхід "тест спочатку", схильний набувати певних структурних властивостей -- не через дисципліну, а тому що тести вимагають цього під час розробки: **Менші функції.** Функція, яка вимагає 8 аргументів або глибокого налаштування для виклику, болісно тестується. Біль з'являється ще до того, як код існує, тому ви робите функцію меншою. **Явні залежності.** Якщо функції потрібне з'єднання з базою даних, тест має його надати. Шлях найменшого опору -- зробити залежність параметром (впровадження залежностей), а не прихованим імпортом на рівні модуля. TDD робить приховані глобалі та побічно-ефектні імпорти негайно болісними. **Більше чистих функцій.** Функції, що приймають аргументи і повертають значення -- без спільного стану, без файлового введення-виведення, без мережі -- найлегше тестувати. TDD послідовно підштовхує вас писати їх більше. **Мінімальні, зв'язні класи.** Клас, що робить забагато, вимагає забагато налаштувань у кожному тесті. Тертя тестів сигналізує про проблему дизайну до того, як вона стала усталеною. ## Поширені заперечення **"TDD займає вдвічі більше часу."** Тест все одно треба написати в якийсь момент -- питання лише в тому, коли. Написання його першим спрямовує реалізацію і часто прискорює її. Справжнє поглинання часу -- це подальше налагодження нетестованого коду. TDD переміщує витрати з налагодження на проектування, що є кращим обміном. **"Я ще не знаю, що будую."** Вам не потрібно знати весь дизайн наперед. Вам потрібно знати лише наступну поведінку. Напишіть один тест для найменшої наступної речі. TDD є інкрементальним -- ви відкриваєте дизайн через тести, а не до них. **"Мій кодова база не тестована."** Нетестований код -- це симптом проблем дизайну: тісне зв'язування, приховані залежності, глобальний стан. TDD, застосований до нового коду, запобігає їх формуванню. Застосований до існуючого коду, TDD діє як запобіжний клапан -- він робить проблеми дизайну видимими як біль тестів, мотивуючи вас їх виправляти.

TDD на практиці: побудова BankAccount крок за кроком

#
## Застосунок, який ми будуємо Клас `BankAccount` з методами `deposit`, `withdraw` і `balance`, вирощений за суворим TDD -- по одному тесту за раз, мінімальний код для проходження, потім рефакторинг. Два файли: ``` bank.py # виробничий код -- починається порожнім test_bank.py # тести -- ростуть по одному тесту за раз ``` На початку обидва файли порожні. --- ## Цикл 1: новий рахунок має нульовий баланс **Red** -- пишемо перший тест: ```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' ``` Добре -- провалюється. У нас є дозвіл писати код. **Green** -- абсолютний мінімум: ```python # bank.py class BankAccount: def __init__(self): self.balance = 0 ``` ``` 1 passed ``` **Refactor** -- поки нічого прибирати. --- ## Цикл 2: поповнення збільшує баланс **Red:** ```python def test_deposit_increases_balance(): account = BankAccount() account.deposit(100) assert account.balance == 100 ``` ``` FAILED - AttributeError: 'BankAccount' object has no attribute 'deposit' ``` **Green:** ```python def deposit(self, amount): self.balance += amount ``` ``` 2 passed ``` **Refactor** -- поки нема що дедублювати. --- ## Цикл 3: зняття зменшує баланс **Red:** ```python def test_withdraw_decreases_balance(): account = BankAccount() account.deposit(200) account.withdraw(50) assert account.balance == 150 ``` **Green:** ```python def withdraw(self, amount): self.balance -= amount ``` ``` 3 passed ``` --- ## Цикл 4: овердрафт кидає ValueError Тут тест змушує нас вказати контракт -- тип помилки, повідомлення -- до того, як існує будь-який код: **Red:** ```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'> ``` **Green:** ```python def withdraw(self, amount): if amount > self.balance: raise ValueError("Insufficient funds") self.balance -= amount ``` ``` 4 passed ``` Повідомлення про помилку `"Insufficient funds"` було визначено в тесті -- специфікація керує деталями реалізації, а не навпаки. --- ## Цикл 5: поповнення має бути позитивною сумою **Red:** ```python def test_deposit_rejects_non_positive_amount(): account = BankAccount() with pytest.raises(ValueError, match="Amount must be positive"): account.deposit(-50) ``` **Green:** ```python def deposit(self, amount): if amount <= 0: raise ValueError("Amount must be positive") self.balance += amount ``` ``` 5 passed ``` --- ## Цикл 6: сума зняття також має бути позитивною Зняття нуля або від'ємного числа непомітно пошкодило б баланс. Тест ловить це ще до того, як це може статися: **Red:** ```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) ``` **Green:** ```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. [Red] Написати тест, що провалюється, для наступної поведінки 2. [Green] Написати мінімальний код для його проходження -- нічого зайвого 3. [Refactor] Прибрати код, поки всі тести залишаються зеленими 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`. Коли тест провалюється, його ім'я має сказати вам, що зламалось, без читання тіла. ## Перевірка фази Red Новий тест, що відразу проходить (без змін коду), означає одне з двох: 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: пишіть повну специфікацію (всі тестові випадки) до будь-якої реалізації. Всі випадки провалюються одночасно (Red); одна реалізація змушує їх всіх пройти (Green): ```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 показує кожен випадок окремо -- ви можете відстежувати прогрес через фазу Red, коли випадки стають зеленими по одному. ## Тести-характеристики для застарілого коду При додаванні тестів до існуючого коду без тестів пишіть "тести-характеристики" -- тести, що документують те, що код зараз робить: ```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 | Помилка | Наслідок | Виправлення | |---|---|---| | Написання кількох тестів до будь-якого Green | Каскад збоїв, важко ізолювати | По одному тесту за раз | | Фаза Green додає більше коду, ніж потрібно | Неспецифікована поведінка без тестів | Зробіть лише тест, що провалюється, зеленим | | Пропуск фази Refactor | Технічний борг накопичується в кожному циклі | Завжди рефакторте, хоча б коротко | | Незахоплення Red перед написанням коду | Тест може нічого не тестувати насправді | Завжди спостерігайте, як тест провалюється спочатку | | Тестування внутрішніх деталей реалізації | Тести ламаються при рефакторингу, а не при зміні поведінки | Тестуйте поведінку (входи/виходи), а не внутрішнє | | Рефакторинг великими кроками | Важко визначити, який крок зламав тест | Малі кроки, запускайте тести між кожним |
01

Реалізуйте клас Stack через TDD

#

Використовуючи TDD, реалізуйте клас `Stack` з чотирма методами: `push(item)`, `pop()`, `peek()` та `is_empty()`. **Крок 1 -- Red:** Спочатку заповніть тіла всіх 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` -- push 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 -- Green/Refactor:** Реалізуйте `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 -- Red:** Спочатку напишіть тіла всіх 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 -- Green:** Реалізуйте по одному правилу валідації за раз. Після кожного правила запускайте 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` приймає будь-який callable, що повертає поточний час як float. За замовчуванням -- `time.time`. Це робить годинник таким, що можна впроваджувати -- тести передають фіктивний годинник замість того, щоб спати. **Крок 1 -- Red:** Напишіть всі 5 тестів з фіктивним годинником (без `time.sleep`). Всі провалюються, тому що `RateLimiter` ще не існує. Тести для написання: 1. `test_first_call_is_always_allowed` 2. `test_calls_within_limit_are_allowed` -- при ліміті=2 два виклики при t=0 обидва `True` 3. `test_calls_exceeding_limit_are_denied` -- при ліміті=2 третій виклик при t=0 є `False` 4. `test_calls_reset_after_one_second` -- після переведення фіктивного часу на 1.0 виклики знову дозволені 5. `test_two_limiters_are_independent` -- вичерпання одного лімітера не впливає на інший **Крок 2 -- Green/Refactor:** Реалізуйте `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 -- Red:** Параметризований тест вже написаний нижче. Запустіть його -- всі 10 випадків провалюються, тому що `to_roman` повертає `None`. Нічого ще не реалізовуйте. Прочитайте всі 10 випадків, щоб зрозуміти повний контракт. **Крок 2 -- Green:** Реалізуйте `to_roman`. Чистий підхід: список пар `(значення, цифра)` у спадному порядку, включаючи субтрактивні випадки (`900 → "CM"`, `400 → "CD"`, `90 → "XC"` тощо). Цикл: поки `n >= значення`, додайте цифру і відніміть значення. **Крок 3 -- Refactor:** Коли всі 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