Как проверить функцию, которая зависит от сегодняшней даты, сети и диска
wednesday_bonus, send_sms и save_receipt.Модульный тест проверяет одну функцию, и его результат должен зависеть только от её кода и входных данных. А функции зависят от окружения:
| Зависимость | Чем мешает тесту | Чем заменяют |
|---|---|---|
| Текущая дата и время | Тест проходит в среду и падает в четверг | Подменённая функция «сегодня» — monkeypatch |
| Сеть, внешний сервис | Медленно, платно, падает без интернета, шлёт настоящие SMS | Заглушка — Mock, patch |
| Файлы на диске | Мусор в проекте, тесты мешают друг другу | Временная папка — tmp_path |
| База данных | Данные меняются, нужен сервер | Временная база или заглушка; часто это уже интеграционный тест |
Заглушку называют по-разному: stub (возвращает заготовленный ответ), mock (ещё и запоминает, как его вызывали), fake (упрощённая рабочая замена, например база в памяти). В Python всё это делает один класс unittest.mock.Mock, поэтому в разговоре обычно говорят просто «мок».
Чтобы функцию было удобно тестировать, её зависимость должна быть доступна для подмены. В shop.py дата берётся не прямо из dt.date.today(), а через отдельную функцию today() — её и подменяют. Так же устроен «Задачник»: у него тоже есть функция today().
Встроенная фикстура 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.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 не ловит исключение, а пропускает его наверх. Хорошо это или плохо — вопрос к требованиям, а тест делает это поведение явным: если кто-то изменит его, тест скажет.
Встроенная фикстура 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 покажет именно её. Сравнивать файл целиком одной строкой хуже — отчёт будет длинным и неточным.
В «Задачнике» вся логика — в функциях, которые можно вызвать напрямую: проверки полей (validate_title, validate_priority, validate_login, validate_password), разбор срока parse_due, переходы статусов can_move, просрочка is_overdue и счётчики count_stats. Им не нужен сервер, браузер и сеть — тест выполняется за миллисекунды.
Две особенности, которые нужно учесть:
tasker.today(), а она — из переменной tasker.FIXED_TODAY, если та задана. Подменить можно любую из двух: monkeypatch.setattr(tasker, "FIXED_TODAY", dt.date(2026, 11, 10)). Функции is_overdue и count_stats к тому же принимают дату параметром day.tasker.VERSION ("1.0" или "1.1"). Модульный тест версии 1.1 подменяет её так же: monkeypatch.setattr(tasker, "VERSION", "1.1").Чем модульный тест лучше проверки через интерфейс, видно на счётчиках. Чтобы проверить счётчик «Просрочено» в браузере, нужны задачи с прошедшим сроком — а создать их через интерфейс нельзя, срок в прошлом не принимается. Модульному тесту достаточно передать в count_stats список словарей с нужными сроками и статусами — любые сочетания, включая те, которых нет в учебных данных. Поля словаря — status и due (строка ГГГГ-ММ-ДД или None). Ожидаемый результат — из требований Т6 «Задачника».
Если ваш итоговый проект не на Python, модульные тесты пишут на его языке. Понятия те же, названия другие:
| Понятие | Python: pytest | Java: JUnit 5 | JavaScript: Jest | C#: xUnit |
|---|---|---|---|---|
| Тест | def test_…(): | @Test void …() | test("…", () => …) | [Fact] public void …() |
| Проверка | assert a == b | assertEquals(b, a) | expect(a).toBe(b) | Assert.Equal(b, a) |
| Исключение | pytest.raises | assertThrows | expect(fn).toThrow() | Assert.Throws<T> |
| Параметризация | @pytest.mark.parametrize | @ParameterizedTest | test.each | [Theory] [InlineData] |
| Подготовка | фикстура | @BeforeEach | beforeEach | конструктор класса |
| Заглушка | unittest.mock | Mockito | jest.fn(), jest.mock | Moq |
| Запуск | pytest | mvn test, gradle test | npx jest | dotnet test |
Для PHP — PHPUnit, для C++ — GoogleTest. В каждом есть отчёт JUnit XML, поэтому в CI (раздел 10) результаты любых языков показываются одинаково.
monkeypatch.setattr подменяет на время теста и сам возвращает исходное; подменяют там, где имя ищутMock возвращает заготовленное и запоминает вызовы: проверяют и ответ функции, и то, что она отправилаtmp_path — своя временная папка у каждого теста