Проверка вызовов и side_effect

Проверяем взаимодействия: как и с чем вызвали мок, плюс side_effect для сложного поведения.

Проверка взаимодействия — это проверка не результата, а факта: что код вызвал зависимость нужное число раз и с нужными аргументами.

Два вопроса к моку

После того как тестируемый код отработал, мок умеет ответить на два главных вопроса: «тебя вообще вызывали?» и «с какими аргументами?». Для этого есть assert-методы самого мока:

  • mock.assert_called() — был вызван хотя бы раз.
  • mock.assert_called_once() — ровно один раз.
  • mock.assert_called_with(args) — последний вызов был с такими аргументами.
  • mock.assert_called_once_with(args) — один раз и именно с такими аргументами.
  • mock.assert_not_called() — не вызывался вовсе.

Пример: проверяем, что письмо отправлено

Функция регистрации должна отправить приветственное письмо. Реальную отправку мокаем — нам важно проверить, что её вызвали правильно, а не слать письмо на самом деле.

import unittest
from unittest.mock import Mock

def register(user_email, mailer):
    # ...сохранили пользователя...
    mailer.send(user_email, "Добро пожаловать!")
    return True

class TestRegister(unittest.TestCase):
    def test_sends_welcome_email(self):
        mailer = Mock()
        register("[email protected]", mailer)

        # Проверяем взаимодействие, а не результат
        mailer.send.assert_called_once_with("[email protected]", "Добро пожаловать!")
        self.assertEqual(mailer.send.call_count, 1)

unittest.main(argv=[''], exit=False, verbosity=2)

Вывод:

test_sends_welcome_email (__main__.TestRegister.test_sends_welcome_email) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

Мы убедились: send вызван ровно один раз и с правильными адресом и текстом. Никакой почтовый сервер не задействован.

call_count и call_args_list

Когда мок вызывают несколько раз, полезны call_count (сколько) и call_args_list (с чем каждый раз). Объект call(...) описывает один вызов:

from unittest.mock import Mock, call

logger = Mock()
logger.info("старт")
logger.info("шаг 1")
logger.info("готово")

print("Всего вызовов:", logger.info.call_count)
print("Список вызовов:", logger.info.call_args_list)
# Проверяем, что был именно такой вызов
assert call("шаг 1") in logger.info.call_args_list
print("Вызов 'шаг 1' зафиксирован")

Вывод:

Всего вызовов: 3
Список вызовов: [call('старт'), call('шаг 1'), call('готово')]
Вызов 'шаг 1' зафиксирован

side_effect: разные ответы и исключения

return_value задаёт один и тот же ответ. side_effect — мощнее: им задают последовательность ответов, исключение или функцию, вычисляющую ответ по аргументам.

from unittest.mock import Mock

# 1) Список — мок отдаёт значения по очереди
counter = Mock(side_effect=[10, 20, 30])
print(counter(), counter(), counter())

# 2) Исключение — мок поднимет его при вызове
broken = Mock(side_effect=ValueError("сбой сети"))
try:
    broken()
except ValueError as e:
    print("Поймали:", e)

# 3) Функция — ответ зависит от аргумента
doubler = Mock(side_effect=lambda x: x * 2)
print(doubler(5), doubler(8))

Вывод:

10 20 30
Поймали: сбой сети
10 16

Вариант с исключением особенно ценен: так тестируют, что ваш код корректно обрабатывает падение зависимости (таймаут, ошибку API), не вызывая реальную ошибку.

Мокаем время и случайность через side_effect

С помощью patch и side_effect можно сымитировать «течение времени» — каждый вызов time.time() вернёт следующее значение:

import unittest
from unittest.mock import patch
import time

def measure(work):
    start = time.time()
    work()
    return time.time() - start

class TestMeasure(unittest.TestCase):
    @patch("time.time", side_effect=[100.0, 102.5])  # старт, затем финиш
    def test_duration(self, mock_time):
        elapsed = measure(lambda: None)
        self.assertEqual(elapsed, 2.5)

unittest.main(argv=[''], exit=False, verbosity=2)

Вывод:

test_duration (__main__.TestMeasure.test_duration) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

Первый вызов time.time() вернул 100.0, второй — 102.5, поэтому измеренная длительность ровно 2.5 секунды — без всякого реального ожидания.

Итог

  • assert_called_with и assert_called_once_with проверяют аргументы вызова.
  • call_count и call_args_list — сколько раз и с чем вызывали мок.
  • side_effect задаёт последовательность ответов, исключение или функцию.
  • Через patch + side_effect мокают время и случайность детерминированно.
Проверьте себя
1. Что проверяет mock.assert_called_once_with(args)?
AЧто мок не вызывался
BЧто мок вызван ровно один раз и именно с указанными аргументами
CЧто мок вернул args
DЧто мок — это MagicMock
2. Чем side_effect мощнее return_value?
AНичем
BМожет задать последовательность ответов, исключение или функцию от аргументов
CРаботает быстрее
DТолько для MagicMock
3. Как сымитировать «течение времени» в тесте?
AРеально подождать в тесте
Bpatch("time.time", side_effect=[старт, финиш]) — вызовы вернут заданные значения по очереди
CИспользовать настоящий time.sleep
DЭто невозможно
Поддержать проект