# Модульний тест

## Що це таке

Модульний тест — це невелика автоматична перевірка одного фрагмента коду окремо
від решти: він викликає функцію, клас або невеликий модуль із підібраними
вхідними даними й перевіряє, що результат саме такий, яким має бути. Він
виконується в тому самому процесі, що й код, який перевіряє, завершується майже
миттєво й не потребує ні мережі, ні бази даних, ні браузера. У розмові
розробники часто кажуть юніт-тест, а саму практику називають модульним
тестуванням.

Що саме вважати модулем, не так однозначно, як підказує назва. У розділі про
модульне тестування книжки _Software Engineering at Google_ Erik Kuefler
називає так тести відносно вузького обсягу, наприклад одного класу чи методу, і
додає, що модуль може бути завбільшки з одну функцію, а може охоплювати кілька
пов'язаних пакетів. На практиці корисна межа проходить там, де код викликають
інші: модуль — це те, що надає інтерфейс, на який хтось зовні покладається, а
його тести користуються цим інтерфейсом так само, як ті, хто його викликає.

Сучасні фреймворки мають спільний родовід. Стаття Кента Бека _Simple Smalltalk
Testing: With Patterns_ описувала невеликий фреймворк із фікстур, тестових
випадків, перевірок і наборів тестів та радила розробникам самим писати модульні
тести, по одному на клас. Документація модуля `unittest` зі стандартної
бібліотеки Python прямо каже, що його спочатку надихнув JUnit, і в ньому ті самі
чотири поняття: тестова фікстура (підготовка, якої потребує тест, і прибирання
після нього), тестовий випадок (окрема одиниця тестування), набір тестів і
запускач тестів. Обидва проводять межу, яку варто пам'ятати: збій (failure) —
це перевірка, що отримала неправильну відповідь, а помилка (error) — те, чого
тест узагалі не передбачав, наприклад виняток, викинутий глибоко всередині коду.

Модульні тести лежать на вузькому краї шкали. У розділі про тестування в тій
самій книжці Adam Bender упорядковує тести за обсягом: вузькі зазвичай
називають модульними, середні, що охоплюють кілька компонентів разом, —
інтеграційними, а великі, що проганяють усю систему, — наскрізними тестами.

## Чому це важливо

Перша причина — швидкість. Модульний тест виконується в тому самому процесі, що
й код, тож розробник може запустити тисячі таких тестів за звичайний робочий
день, і помилка виявляється, поки зміна, що її спричинила, ще відкрита на
екрані. Настанови Google тут однозначні: малий тест не може засинати, виконувати
введення-виведення чи звертатися до мережі або диска. Те, що лишається, працює
швидко й детерміновано, і команда вчиться довіряти набору, який щоразу дає ту
саму відповідь.

Друга — діагностика. Коли падає вузький тест, вада сидить у невеликому
фрагменті коду, найчастіше в щойно зміненому. Коли падає тест, що проганяє всю
систему, причина може бути будь-де між браузером і базою даних. Тому _Software
Engineering at Google_ подає піраміду як приблизну настанову: близько 80%
вузьких тестів, 15% інтеграційних і 5% наскрізних.

Третя — зміни. Набір, що перевіряє поведінку через публічний інтерфейс, дає
змогу без страху перебудовувати код за цим інтерфейсом: якщо тести досі
проходять, ті, хто викликає код, і далі отримують те, що їм потрібно. Ідеал,
який описує Kuefler, — тест, який не доводиться змінювати, доки не зміняться
вимоги, тож чистий рефакторинг не зачіпає жодного тесту.

Модульні тести ще й документують. Тест, названий за поведінкою, з простими
вхідними даними й повністю виписаним очікуваним результатом показує наступному
читачеві, як цей код викликати й що він обіцяє, а на відміну від коментаря тест
падає, щойно перестає відповідати дійсності.

## Як це виглядає на практиці

Візьмімо функцію, яка читає тривалості на кшталт `1h30m` із конфігураційного
файлу й переводить їх у секунди:

```python
# durations.py
UNITS = {"h": 3600, "m": 60, "s": 1}


def to_seconds(text: str) -> int:
    """Переводить "1h30m" або "45s" у ціле число секунд."""
    total, digits = 0, ""
    for char in text:
        if char.isdigit():
            digits += char
        elif char in UNITS and digits:
            total += int(digits) * UNITS[char]
            digits = ""
        else:
            raise ValueError(f"not a duration: {text!r}")
    if digits or not text:
        raise ValueError(f"not a duration: {text!r}")
    return total
```

Її модульні тести, написані для pytest, перевіряють дві поведінки: що функція
приймає і що відкидає.

```python
# test_durations.py
import pytest

from durations import to_seconds


@pytest.mark.parametrize(
    "text,expected",
    [("45s", 45), ("2m", 120), ("1h30m", 5400), ("0s", 0)],
)
def test_counts_each_unit(text, expected):
    assert to_seconds(text) == expected


@pytest.mark.parametrize("text", ["", "90", "h", "1x", "1.5h"])
def test_refuses_what_it_cannot_read(text):
    with pytest.raises(ValueError):
        to_seconds(text)
```

Команда `pytest`, запущена в каталозі, збирає всі файли з назвами `test_*.py`
або `*_test.py`, і дві функції розгортаються в дев'ять випадків, по одному на
кожен рядок параметрів. Документація pytest описує кожен тест як arrange, act,
assert і cleanup, тобто підготовку, дію, перевірку й прибирання; тут підготовка
— це рядок параметрів, дія — єдиний виклик, а прибирати нічого, бо поза функцією
нічого не зачеплено. Якщо згодом хтось помилково задасть для хвилини `3600`
секунд, два випадки не пройдуть, і звіт назве кожен за його параметрами:
`test_counts_each_unit[2m-120]` покаже `7200` проти очікуваних `120`, а
`test_counts_each_unit[1h30m-5400]` — `111600` проти `5400`. Зверніть увагу:
очікувані значення пораховано вручну, а не скопійовано з того, що код повернув
під час першого запуску.

Ті самі перевірки в `unittest` зі стандартної бібліотеки мали б вигляд підкласу
`unittest.TestCase` з методами, назви яких починаються з `test`, і з викликами
`assertEqual` та `assertRaises`; `python -m unittest` за замовчуванням шукає
файли, що відповідають шаблону `test*.py`. Go вбудовує цю домовленість у сам
інструментарій: функцію `func TestXxx(t *testing.T)` у файлі, назва якого
закінчується на `_test.go`, запускає `go test`.

Масштаб змінює облік, а не ідею.
[Генератор бази даних, покритий 981 тестовим випадком](/uk/portfolio/held-the-generator-to-981-tests-and-92-coverage/)
— робота, виконана в липні 2026 року, — налічує 383 тестові функції, які з усіма
наборами параметрів дають 981 тестовий випадок, у 45 файлах загальним обсягом
7 944 рядки. Збірка вимагає щонайменше 92% покриття гілок, виміряне покриття
становить близько 95%, а попередження вважаються помилками. Сторінка чесно каже,
де межа цього числа: два дефекти, знайдені в проєкті згодом, містилися в коді,
який звіт про покриття показував як покритий.

## Де виникають проблеми

**Тести віддзеркалюють реалізацію.** Тест, який лізе у приватні допоміжні
функції або перевіряє точну послідовність внутрішніх викликів, ламається
щоразу, коли код перебудовують, хоча для того, хто його викликає, нічого не
змінилося. Kuefler називає такий тест крихким і вважає надмірне покладання на
фреймворки для моків найпоширенішою причиною того, що тести взаємодій
псуються. Тестуйте через публічний інтерфейс, перевіряйте стан, який вийшов у
результаті, а не виклики, що до нього привели, і беріть справжні об'єкти там, де
вони дешеві.

**Тести залежать один від одного.** Один тест створює запис, наступний його
читає, і набір проходить лише в такому порядку. Запустіть один тест окремо або
ті самі тести в іншому порядку — і він упаде з причини, якої читач не бачить.
Фреймворк Бека створював і викидав тестові дані для кожного тесту окремо, а
документація `unittest` вимагає, щоб тестовий випадок був цілком самодостатнім.
Дайте кожному тесту власну фікстуру й прибирайте після нього.

**Покриття стає метою.** Покриття показує, що рядок виконався, а не що хтось
перевірив, що він зробив. Тест без жодної перевірки піднімає число й нічого не
доводить, а _Software Engineering at Google_ застерігає, що поріг у 80% зазвичай
перетворюється на стелю і зміни зупиняються якраз на мінімумі. Вважайте число
підлогою, а перевірки читайте під час рев'ю коду.

**Один тест на метод.** Пара «метод — тестовий метод» виглядає охайно, але тест
розростається щоразу, коли метод отримує нову гілку, аж поки ніхто не може
сказати, що він перевіряє. Kuefler радить натомість тестувати поведінку: одна
поведінка на тест, а назва — за тим, що він доводить. Тоді звіт про падіння
читається як речення про те, що саме зламалося.

**Тест чекає, поки мине час.** Виклик `sleep()`, щоб щось устигло завершитися,
сповільнює набір на кожному запуску і все одно падає на завантаженій машині. У
модульному тесті час — це вхідні дані: передайте кодові годинник або позначку
часу, щоб тест сам задав потрібний момент, а не чекав на нього.

## Що почитати далі

- [unittest: Unit testing framework](https://docs.python.org/3/library/unittest.html)
  — фреймворк зі стандартної бібліотеки Python: фікстури, тестові випадки,
  набори, запускачі, пошук тестів і різниця між збоєм та помилкою.
- [Anatomy of a test](https://docs.pytest.org/en/stable/explanation/anatomy.html)
  — пояснення pytest про arrange, act, assert і cleanup.
- [Get Started](https://docs.pytest.org/en/stable/getting-started.html)
  — вступ від самого pytest: звичайний `assert`, пошук тестів і
  `pytest.raises`.
- [Unit Testing](https://abseil.io/resources/swe-book/html/ch12.html)
  — розділ Erik Kuefler у _Software Engineering at Google_ про крихкі тести,
  публічні API, стан замість взаємодій і тестування поведінки.
- [Simple Smalltalk Testing: With Patterns](https://web.archive.org/web/20150315073817/http://www.xprogramming.com/testfram.htm)
  — стаття Кента Бека про фреймворк, про модульні тести по одному на клас і про
  різницю між збоями та помилками, в архівній копії.
- [testing](https://pkg.go.dev/testing)
  — стандартний пакет Go для тестування: функції `TestXxx`, файли `_test.go` і
  `go test`.

<https://engineer.company/uk/glossary/unit-test/>
