Модульное тестирование (unit testing) — это автоматическая проверка одной небольшой единицы кода: функции, метода или класса — в изоляции от внешнего мира. Тест вызывает единицу с заданными входными данными и сравнивает результат с ожидаемым. Базы данных, сеть, файлы и текущее время в такой тест не входят: их заменяют тестовыми двойниками.
Содержание
- Мини-словарь
- Чем модульный тест отличается от интеграционного и E2E
- Минимальный пример: функция и ее тесты
- Схема AAA: Arrange, Act, Assert
- Тест тоже может ошибаться
- Моки: как изолировать внешнюю зависимость
- Покрытие: что показывает coverage
- pytest: тот же тест короче
- Если не получилось
- Выводы
- Где применяется / связь с практикой
- FAQ
Ниже — чем unit-тест отличается от других уровней, как его устроить по схеме AAA, что делать с зависимостями (моки), что показывает покрытие и чего оно не показывает. Все примеры — на Python 3.14 и модуле unittest из стандартной библиотеки, вывод взят из реального прогона 24.09.2026.
Мини-словарь
Слово «модуль» здесь легко понять неправильно. В теории тестирования модуль (unit) — это наименьший кусок логики, который имеет смысл проверить отдельно. В Python «модулем» называют файл .py, и в одном таком файле может быть десяток проверяемых единиц.
| Термин | Что это |
|---|---|
| Единица (unit) | Функция, метод или класс, который проверяем |
| Тест-кейс | Один сценарий: входные данные плюс ожидаемый результат |
| Утверждение (assert) | Проверка внутри теста, при нарушении тест падает |
| Тестовый двойник | Подмена зависимости: stub возвращает заготовленный ответ, mock еще и запоминает вызовы |
| Покрытие (coverage) | Доля строк и ветвлений, выполненных во время тестов |
Чем модульный тест отличается от интеграционного и E2E
| Уровень | Что проверяет | Зависимости | Скорость | Когда падает |
|---|---|---|---|---|
| Модульный | Одну единицу логики | Подменены | Миллисекунды | Ошибка в конкретной функции |
| Интеграционный | Связку компонентов: код + БД, два сервиса | Настоящие или близкие к настоящим | Секунды | Ошибка на стыке, в конфигурации, в запросах |
| End-to-end | Сценарий пользователя через интерфейс или API | Вся система | Минуты | Сломан пользовательский путь целиком |
Модульные тесты обычно пишет разработчик вместе с кодом, но в командах QA-инженер их тоже читает, дополняет граничными случаями и следит, чтобы они запускались в CI. Граница уровней в реальных проектах размыта: тест, который поднимает SQLite в памяти, одни называют модульным, другие интеграционным. Практичный критерий — быстрый, изолированный и без внешних сервисов.
Минимальный пример: функция и ее тесты
Проверяем функцию расчета скидки. Деньги храним целым числом в копейках, чтобы не ловить погрешности float.
# cart.py
def apply_discount(price_kop: int, percent: int) -> int:
"""Цена в копейках после скидки, округление вниз до копейки."""
if not 0 <= percent <= 100:
raise ValueError("percent must be in 0..100")
return price_kop * (100 - percent) // 100
def final_price(price_kop: int, promo: str | None = None) -> int:
if promo == "WELCOME":
price_kop = apply_discount(price_kop, 10)
return price_kop
# test_cart.py
import unittest
from cart import apply_discount
class ApplyDiscountTest(unittest.TestCase):
def test_ten_percent(self):
# Arrange
price = 1000_00 # 1000 руб. в копейках
# Act
result = apply_discount(price, 10)
# Assert
self.assertEqual(result, 900_00)
def test_boundaries(self):
cases = [(0, 1000_00), (100, 0), (33, 670_00)]
for percent, expected in cases:
with self.subTest(percent=percent):
self.assertEqual(apply_discount(1000_00, percent), expected)
def test_rounds_down_to_kopeck(self):
self.assertEqual(apply_discount(999, 15), 849) # 849.15 -> 849
def test_rejects_bad_percent(self):
with self.assertRaises(ValueError):
apply_discount(1000_00, 101)
if __name__ == "__main__":
unittest.main()
Запуск из папки, где лежат оба файла: python -m unittest -v. Должны пройти четыре теста:
test_boundaries (test_cart.ApplyDiscountTest.test_boundaries) ... ok
test_rejects_bad_percent (test_cart.ApplyDiscountTest.test_rejects_bad_percent) ... ok
test_rounds_down_to_kopeck (test_cart.ApplyDiscountTest.test_rounds_down_to_kopeck) ... ok
test_ten_percent (test_cart.ApplyDiscountTest.test_ten_percent) ... ok
----------------------------------------------------------------------
Ran 4 tests in 0.000s
OK
Схема AAA: Arrange, Act, Assert
В test_ten_percent три блока подписаны явно:
- Arrange — подготовка: входные данные, объекты, двойники.
- Act — одно действие, которое проверяем: вызов функции.
- Assert — сравнение результата с ожиданием.
Если в тесте несколько Act подряд, это обычно несколько тестов, склеенных в один: при падении непонятно, какой шаг сломался. Комментарии # Arrange писать не обязательно, достаточно разделять блоки пустой строкой.
Что стоит покрыть тестами для одной функции:
- обычный случай (10%);
- границы диапазона (0 и 100%);
- правило округления (849,15 -> 849);
- недопустимый вход, для которого функция обязана выбросить исключение.
subTest позволяет прогнать таблицу случаев внутри одного теста, и при падении видно, на каком значении.
Тест тоже может ошибаться
В первой версии теста для 33% ожидание было записано с ошибкой — 67_00 вместо 670_00:
cases = [(0, 1000_00), (100, 0), (33, 67_00)] # ошибка в ожидании
Результат прогона:
FAIL: test_boundaries (test_cart.ApplyDiscountTest.test_boundaries) (percent=33)
...
AssertionError: 67000 != 6700
Первое число — то, что вернула функция, второе — ожидание из теста. Прежде чем чинить код, пересчитайте ожидание руками: 1000 руб. минус 33% = 670 руб. = 67000 копеек. Функция права, исправляем тест на 670_00. Отсюда правило: новый тест полезно один раз увидеть упавшим, иначе нет уверенности, что он вообще что-то проверяет.
Моки: как изолировать внешнюю зависимость
Функция пересчета цены в доллары берет курс из внешнего API. Модульный тест не должен ходить в сеть: он станет медленным и будет падать, когда API недоступен.
# rates.py
import json
import urllib.request
def fetch_usd_rate() -> int:
"""Курс доллара в копейках (9000 = 90,00 руб.), запрос к внешнему API."""
with urllib.request.urlopen("https://rates.example.com/usd", timeout=3) as r:
return int(json.load(r)["kop"])
# pricing.py
from rates import fetch_usd_rate
def price_in_cents(price_kop: int) -> int:
rate = fetch_usd_rate()
if rate <= 0:
raise ValueError("bad rate")
return price_kop * 100 // rate
# test_pricing.py
import unittest
from unittest.mock import patch
from pricing import price_in_cents
class PriceInCentsTest(unittest.TestCase):
@patch("pricing.fetch_usd_rate", return_value=9000)
def test_converts_by_rate(self, fake_rate):
self.assertEqual(price_in_cents(1800_00), 20_00) # 1800 руб. = 20 $
fake_rate.assert_called_once_with()
@patch("pricing.fetch_usd_rate", return_value=0)
def test_zero_rate_is_error(self, fake_rate):
with self.assertRaises(ValueError):
price_in_cents(100)
if __name__ == "__main__":
unittest.main()
patch на время теста подменяет функцию объектом MagicMock: он возвращает 9000 и запоминает, как его вызвали. assert_called_once_with() проверяет, что курс запрошен ровно один раз. Оба теста проходят без сети.
Типичная ошибка — патчить функцию там, где она определена:
@patch("rates.fetch_usd_rate", return_value=9000) # неверный путь
Результат — тест завершается не FAIL, а ERROR, потому что вызвался настоящий сетевой код:
File "/w/rates.py", line 8, in fetch_usd_rate
with urllib.request.urlopen("https://rates.example.com/usd", timeout=3) as r:
...
urllib.error.URLError: <urlopen error [Errno -2] Name or service not known>
...
FAILED (errors=1)
Причина: from rates import fetch_usd_rate скопировал ссылку на функцию в пространство имен pricing, и price_in_cents ищет ее там. Правило — патчить имя в том модуле, где его используют: pricing.fetch_usd_rate. Текст сетевой ошибки зависит от окружения; хуже, если API ответит, и тест пройдет случайно.
Моки полезны, но их избыток — тоже проблема. Если тест подменяет почти все и проверяет только порядок вызовов, он ломается при любом рефакторинге, хотя поведение не менялось. Подменяйте границы системы (сеть, БД, время, случайность), а свою логику проверяйте по результату.
Покрытие: что показывает coverage
Покрытие считает инструмент coverage.py (ставится через pip install coverage, в стандартную библиотеку не входит). Запустим только один тест для final_price с промокодом:
# test_final.py
import unittest
from cart import final_price
class FinalPriceTest(unittest.TestCase):
def test_welcome_promo(self):
self.assertEqual(final_price(1000_00, "WELCOME"), 900_00)
python -m coverage run --branch -m unittest test_final
python -m coverage report -m --include=cart.py
Name Stmts Miss Branch BrPart Cover Missing
-----------------------------------------------------
cart.py 8 1 4 2 75% 5, 10->12
-----------------------------------------------------
TOTAL 8 1 4 2 75%
Номера строк — для файла cart.py ровно как в блоке выше, вместе с первой строкой-комментарием. Строка 5 (raise ValueError) не выполнялась, а переход 10->12 означает: ни разу не проверен путь «условие ложно, сразу return», то есть покупка без промокода. Если добавить тесты из test_cart.py, все строки будут выполнены, но покрытие останется 92%: ветка без промокода так и не проверена.
Отсюда два вывода. Покрытие по строкам без --branch пропускает непроверенные ветки. И 100% покрытия не доказывает правильность: тест без утверждения выполнит все строки и ничего не проверит. Покрытие показывает, где тестов точно нет, но не то, что существующие тесты хороши.
pytest: тот же тест короче
В проектах часто используют pytest — сторонний фреймворк (не входит в стандартную библиотеку). Он умеет запускать и тесты на unittest, а свои пишутся обычными функциями с assert:
# test_cart_pytest.py
import pytest
from cart import apply_discount
@pytest.mark.parametrize(
("percent", "expected"),
[(0, 1000_00), (10, 900_00), (33, 670_00), (100, 0)],
)
def test_apply_discount(percent, expected):
assert apply_discount(1000_00, percent) == expected
def test_rejects_bad_percent():
with pytest.raises(ValueError, match="0..100"):
apply_discount(1000_00, 101)
Команда python -m pytest -q в папке со всеми четырьмя файлами тестов из статьи (pytest 9.1.1) вывела 12 passed, 3 subtests passed: 4 + 2 + 1 теста на unittest и 5 на pytest (параметризация дает четыре отдельных теста). Выбор между фреймворками — вопрос команды: unittest есть везде, pytest удобнее параметризацией и фикстурами.
Если не получилось
| Симптом | Частая причина | Что сделать |
|---|---|---|
Ran 0 tests, NO TESTS RAN |
Файл не по шаблону test*.py или метод без префикса test |
Переименовать cart_checks.py в test_cart.py, методы начинать с test_ |
ModuleNotFoundError: No module named 'cart' |
Запуск не из корня проекта | Запускать python -m unittest из папки с кодом |
| Мок не сработал, тест лезет в сеть | Патч по месту определения, а не использования | Патчить модуль_где_вызывают.имя |
| Тест то проходит, то падает | Зависимость от времени, случайности, порядка тестов | Передавать время и seed параметром, не делить состояние между тестами |
| Упал старый тест после правки | Изменилось поведение или тест проверял детали реализации | Решить, что верно: новое поведение или старый контракт |
С Python 3.12 прогон без найденных тестов завершается кодом 5, поэтому CI заметит пустой набор, а не покажет зеленый OK.
Выводы
- Модульный тест проверяет одну единицу кода в изоляции: быстро, без сети, БД и файлов.
- Структура AAA — подготовка, одно действие, проверка; граничные значения и ошибочный вход важнее десятка похожих обычных случаев.
- Внешние зависимости подменяют через
unittest.mock.patch, причем по имени в модуле, где функцию вызывают. - Покрытие с
--branchпоказывает непроверенные ветки, но высокий процент не гарантирует качество тестов. - Новый тест стоит один раз увидеть упавшим: ошибиться можно и в ожидании.
Где применяется / связь с практикой
Модульные тесты — первая линия автоматизации: их запускают на каждый коммит в CI, и от их качества зависит, сколько ошибок дойдет до ручного и интеграционного тестирования. Тестировщику важно уметь читать такие тесты, находить непокрытые граничные случаи и понимать отчет покрытия.
Освойте тему на практике
Системно пройти путь от тест-дизайна до автоматизации можно на курсе «Инженер по тестированию». Чтобы оценить формат, загляните на бесплатные открытые уроки Otus.
FAQ
Нужно ли тестировать приватные методы?
Обычно нет: их проверяют через публичный интерфейс. Если приватный метод так сложен, что хочется тестировать его отдельно, это повод вынести его в самостоятельную функцию.
Чем модульное тестирование отличается от TDD?
Модульный тест — вид теста, TDD — порядок работы: сначала падающий тест, потом минимальный код, чтобы он прошел, затем рефакторинг. Unit-тесты можно писать и после кода, без TDD.
Какой процент покрытия считать нормальным?
Универсального числа нет. Команды часто ставят порог в CI, но полезнее смотреть, покрыты ли ветки бизнес-логики и обработки ошибок, чем гнаться за 100%.



