Раздел 05 — Модульные тесты и заглушки

Как проверить функцию, которая зависит от сегодняшней даты, сети и диска

Прогресс курса Раздел 5 из 12

Что вы освоите в этом разделе

6 академических часов: 2 теории, 4 практики. Примеры — на shop.py: функции wednesday_bonus, send_sms и save_receipt.
01

Что такое изоляция

Модульный тест проверяет одну функцию, и его результат должен зависеть только от её кода и входных данных. А функции зависят от окружения:

ЗависимостьЧем мешает тестуЧем заменяют
Текущая дата и времяТест проходит в среду и падает в четвергПодменённая функция «сегодня» — monkeypatch
Сеть, внешний сервисМедленно, платно, падает без интернета, шлёт настоящие SMSЗаглушка — Mock, patch
Файлы на дискеМусор в проекте, тесты мешают друг другуВременная папка — tmp_path
База данныхДанные меняются, нужен серверВременная база или заглушка; часто это уже интеграционный тест

Заглушку называют по-разному: stub (возвращает заготовленный ответ), mock (ещё и запоминает, как его вызывали), fake (упрощённая рабочая замена, например база в памяти). В Python всё это делает один класс unittest.mock.Mock, поэтому в разговоре обычно говорят просто «мок».

Чтобы функцию было удобно тестировать, её зависимость должна быть доступна для подмены. В shop.py дата берётся не прямо из dt.date.today(), а через отдельную функцию today() — её и подменяют. Так же устроен «Задачник»: у него тоже есть функция today().

02

Подмена даты: monkeypatch

Встроенная фикстура monkeypatch временно заменяет атрибут модуля и сама возвращает исходный после теста. monkeypatch.setattr(shop, "today", …) — «на время теста функция shop.today — вот эта».

import datetime as dt

import pytest

import shop

WEDNESDAY = dt.date(2026, 11, 11)
THURSDAY = dt.date(2026, 11, 12)


def test_bonus_on_wednesday(monkeypatch):
    monkeypatch.setattr(shop, "today", lambda: WEDNESDAY)
    assert shop.wednesday_bonus(1000) == 50


def test_no_bonus_on_thursday(monkeypatch):
    monkeypatch.setattr(shop, "today", lambda: THURSDAY)
    assert shop.wednesday_bonus(1000) == 0


@pytest.mark.parametrize("day, bonus", [(WEDNESDAY, 50), (THURSDAY, 0)], ids=["wed", "thu"])
def test_bonus_by_day(monkeypatch, day, bonus):
    monkeypatch.setattr(shop, "today", lambda: day)
    assert shop.wednesday_bonus(1000) == bonus
$ pytest -v tests/test_wednesday.py
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.2.0, pluggy-1.7.0 -- ~/qa/.venv/bin/python3
cachedir: .pytest_cache
metadata: {'Python': '3.13.5', 'Platform': 'Linux-6.12.95+deb13-amd64-x86_64-with-glibc2.41', 'Packages': {'pytest': '9.2.0', 'pluggy': '1.7.0'}, 'Plugins': {'metadata': '3.1.1', 'playwright': '0.10.0', 'locust': '2.47.0', 'html': '4.2.0'}}
rootdir: ~/qa
configfile: pytest.ini
plugins: metadata-3.1.1, playwright-0.10.0, locust-2.47.0, html-4.2.0
collecting ... collected 4 items

tests/test_wednesday.py::test_bonus_on_wednesday PASSED                  [ 25%]
tests/test_wednesday.py::test_no_bonus_on_thursday PASSED                [ 50%]
tests/test_wednesday.py::test_bonus_by_day[wed] PASSED                   [ 75%]
tests/test_wednesday.py::test_bonus_by_day[thu] PASSED                   [100%]

============================== 4 passed in 0.12s ===============================

11 ноября 2026 года — среда, 12-е — четверг. Тест не зависит от того, в какой день его запустили. Без подмены один из двух тестов падал бы шесть дней в неделю из семи.

Подменять нужно там, где функцию ищут. wednesday_bonus вызывает today() из модуля shop, поэтому подменяем shop.today. Если бы в shop.py было from datetime import date и вызов date.today(), подменять пришлось бы shop.date, а не datetime.date.
03

Заглушка внешнего сервиса: Mock

send_sms отправляет запрос в SMS-сервис через requests.post. В тесте настоящего запроса быть не должно: заменим requests.post объектом Mock, который вернёт заготовленный ответ и запомнит вызов.

from unittest.mock import Mock, patch

import pytest
import requests

import shop


def test_sms_accepted(monkeypatch):
    fake_post = Mock(return_value=Mock(status_code=200))
    monkeypatch.setattr(shop.requests, "post", fake_post)

    assert shop.send_sms("+79110000000", "Заказ 15 собран") is True
    fake_post.assert_called_once_with(
        shop.SMS_URL, json={"phone": "+79110000000", "text": "Заказ 15 собран"}, timeout=5)


def test_sms_rejected(monkeypatch):
    monkeypatch.setattr(shop.requests, "post", Mock(return_value=Mock(status_code=500)))
    assert shop.send_sms("+79110000000", "Заказ 15 собран") is False


def test_sms_network_down():
    with patch("shop.requests.post", side_effect=requests.ConnectionError("нет сети")):
        with pytest.raises(requests.ConnectionError):
            shop.send_sms("+79110000000", "Заказ 15 собран")


def test_mock_records_calls():
    sender = Mock()
    sender("+79110000000", "первое")
    sender("+79110000001", "второе")
    print(sender.call_count, sender.call_args_list)
    assert sender.call_count == 2
ЗаписьЧто значит
Mock(return_value=…)При вызове вернуть это значение
Mock(status_code=200)Объект, у которого есть атрибут status_code — подделка ответа requests
Mock(side_effect=Исключение)При вызове выбросить исключение — так проверяют «сеть недоступна»
.assert_called_once_with(…)Вызван ровно один раз и именно с такими аргументами
.call_count, .call_args_listСколько раз и с чем вызывали
patch("shop.requests.post", …)То же, что monkeypatch.setattr, но блоком with или декоратором
$ pytest -v -s tests/test_sms.py
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.2.0, pluggy-1.7.0 -- ~/qa/.venv/bin/python3
cachedir: .pytest_cache
metadata: {'Python': '3.13.5', 'Platform': 'Linux-6.12.95+deb13-amd64-x86_64-with-glibc2.41', 'Packages': {'pytest': '9.2.0', 'pluggy': '1.7.0'}, 'Plugins': {'metadata': '3.1.1', 'playwright': '0.10.0', 'locust': '2.47.0', 'html': '4.2.0'}}
rootdir: ~/qa
configfile: pytest.ini
plugins: metadata-3.1.1, playwright-0.10.0, locust-2.47.0, html-4.2.0
collecting ... collected 4 items

tests/test_sms.py::test_sms_accepted PASSED
tests/test_sms.py::test_sms_rejected PASSED
tests/test_sms.py::test_sms_network_down PASSED
tests/test_sms.py::test_mock_records_calls 2 [call('+79110000000', 'первое'), call('+79110000001', 'второе')]
PASSED

============================== 4 passed in 0.12s ===============================

Тест проверяет две вещи: что функция правильно поняла ответ сервиса (True/False) и что она правильно сформировала запрос. Второе важно: если перепутать телефон и текст, сервис ответит 200, а SMS уйдёт не туда. Когда вызов не совпадает с ожидаемым, Mock показывает обе версии — здесь в ожидании специально написан другой номер заказа. Ключ --tb=short сокращает трассировку: по строке на шаг вместо полного кода функций — внутренности модуля unittest.mock разбирать незачем, главное в строках E:

$ pytest -q --tb=short tests/test_sms.py::test_sms_accepted
F                                                                        [100%]
=================================== FAILURES ===================================
______________________________ test_sms_accepted _______________________________
tests/test_sms.py:14: in test_sms_accepted
    fake_post.assert_called_once_with(
/usr/lib/python3.13/unittest/mock.py:991: in assert_called_once_with
    return self.assert_called_with(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
/usr/lib/python3.13/unittest/mock.py:979: in assert_called_with
    raise AssertionError(_error_message()) from cause
E   AssertionError: expected call not found.
E   Expected: mock('https://sms.example.ru/api/send', json={'phone': '+79110000000', 'text': 'Заказ 16 собран'}, timeout=5)
E     Actual: mock('https://sms.example.ru/api/send', json={'phone': '+79110000000', 'text': 'Заказ 15 собран'}, timeout=5)
=========================== short test summary info ============================
FAILED tests/test_sms.py::test_sms_accepted - AssertionError: expected call n...
1 failed in 0.24s

Тест test_sms_network_down фиксирует поведение: при обрыве сети send_sms не ловит исключение, а пропускает его наверх. Хорошо это или плохо — вопрос к требованиям, а тест делает это поведение явным: если кто-то изменит его, тест скажет.

Заглушка проверяет ваш код, но не сам сервис. Если SMS-сервис изменит формат запроса, модульные тесты останутся зелёными. Поэтому кроме модульных нужны интеграционные проверки с настоящим или тестовым сервисом — пусть немного и не на каждый коммит.
04

Временные файлы: tmp_path

Встроенная фикстура tmp_path даёт каждому тесту свою пустую папку (объект pathlib.Path). pytest хранит папки трёх последних прогонов, а более старые удаляет сам.

import shop


def test_receipt_lines(tmp_path):
    path = tmp_path / "receipt.txt"
    shop.save_receipt(path, [(120, 3), (45, 10)])
    print(path)
    assert path.read_text(encoding="utf-8").splitlines() == [
        "120.00 x 3",
        "45.00 x 10",
        "Итого: 1110.00",
    ]
$ pytest -q -s tests/test_receipt.py
/tmp/pytest-of-student/pytest-142/test_receipt_lines0/receipt.txt
.
1 passed in 0.10s

Строки чека сравниваются списком: если не совпадёт одна, pytest покажет именно её. Сравнивать файл целиком одной строкой хуже — отчёт будет длинным и неточным.

05

Модульные тесты «Задачника»

В «Задачнике» вся логика — в функциях, которые можно вызвать напрямую: проверки полей (validate_title, validate_priority, validate_login, validate_password), разбор срока parse_due, переходы статусов can_move, просрочка is_overdue и счётчики count_stats. Им не нужен сервер, браузер и сеть — тест выполняется за миллисекунды.

Две особенности, которые нужно учесть:

Чем модульный тест лучше проверки через интерфейс, видно на счётчиках. Чтобы проверить счётчик «Просрочено» в браузере, нужны задачи с прошедшим сроком — а создать их через интерфейс нельзя, срок в прошлом не принимается. Модульному тесту достаточно передать в count_stats список словарей с нужными сроками и статусами — любые сочетания, включая те, которых нет в учебных данных. Поля словаря — status и due (строка ГГГГ-ММ-ДД или None). Ожидаемый результат — из требований Т6 «Задачника».

06

То же в других языках

Если ваш итоговый проект не на Python, модульные тесты пишут на его языке. Понятия те же, названия другие:

ПонятиеPython: pytestJava: JUnit 5JavaScript: JestC#: xUnit
Тестdef test_…():@Test void …()test("…", () => …)[Fact] public void …()
Проверкаassert a == bassertEquals(b, a)expect(a).toBe(b)Assert.Equal(b, a)
Исключениеpytest.raisesassertThrowsexpect(fn).toThrow()Assert.Throws<T>
Параметризация@pytest.mark.parametrize@ParameterizedTesttest.each[Theory] [InlineData]
Подготовкафикстура@BeforeEachbeforeEachконструктор класса
Заглушкаunittest.mockMockitojest.fn(), jest.mockMoq
Запускpytestmvn test, gradle testnpx jestdotnet test

Для PHP — PHPUnit, для C++ — GoogleTest. В каждом есть отчёт JUnit XML, поэтому в CI (раздел 10) результаты любых языков показываются одинаково.

Ключевые выводы раздела

Запомните главное

  • Модульный тест зависит только от кода функции и входных данных; дата, сеть и диск подменяются
  • monkeypatch.setattr подменяет на время теста и сам возвращает исходное; подменяют там, где имя ищут
  • Mock возвращает заготовленное и запоминает вызовы: проверяют и ответ функции, и то, что она отправила
  • tmp_path — своя временная папка у каждого теста
  • Модульный тест позволяет проверить сочетания данных, которые через интерфейс не создать
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 04: Фикстуры, параметризация, маркеры Все разделы Раздел 06: Автотесты API — основы