Модульное тестирование: что это и как писать unit-тесты

Модульное тестирование: что это и как писать unit-тесты Полезное

Модульное тестирование (unit testing) — это автоматическая проверка одной небольшой единицы кода: функции, метода или класса — в изоляции от внешнего мира. Тест вызывает единицу с заданными входными данными и сравнивает результат с ожидаемым. Базы данных, сеть, файлы и текущее время в такой тест не входят: их заменяют тестовыми двойниками.

Ниже — чем 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 три блока подписаны явно:

  1. Arrange — подготовка: входные данные, объекты, двойники.
  2. Act — одно действие, которое проверяем: вызов функции.
  3. 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%.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)