Learning Platform
Глоссарий Troubleshooting
Урок 09.05 · 25 мин
Средний
pytestMocksMagicMockpatchpatch.objectmonkeypatchPitfall 13Pitfall 17ScopeMismatchRun-on-Your-Machine

Mocks и monkeypatch — изоляция от внешних зависимостей

В этом уроке — isolation technique для unit tests. Mocks заменяют real objects (DB connections, HTTP clients, time, random) на controllable stand-ins. Это позволяет test’ить логику независимо от external state, делает tests fast + deterministic.

В этом уроке:

  1. Why mocks — isolate code under test от external dependencies.
  2. unittest.mock.MagicMock — callable mock с auto-generated child attrs.
  3. patch() decorator + context-manager — temporary attribute replacement.
  4. patch.object vs patch (Pitfall 13) — when to choose which.
  5. monkeypatch fixture (function-scoped) + Pitfall 17 (ScopeMismatch).
  6. pytest.MonkeyPatch() — session-scope alternative.
  7. Run-on-Your-Machine — monkeypatch demo.

Why mocks — изоляция от внешних зависимостей

Function send_welcome_email, который вызывает SMTP server:

import smtplib

def send_welcome_email(user_email: str) -> bool:
    """Send welcome email via SMTP."""
    smtp = smtplib.SMTP('smtp.example.com', 587)
    smtp.starttls()
    smtp.login('user', 'pass')
    msg = f'To: {user_email}\nSubject: Welcome\n\nHello!'
    smtp.sendmail('[email protected]', user_email, msg)
    smtp.quit()
    return True

Без mocks — test потребует real SMTP server. Tests становятся:

  • Slow — network round-trip (~500ms per test).
  • Flaky — server downtime → false failures.
  • Side-effects — sends real emails (production пользователям!).

С mocks — replace smtplib.SMTP на controllable stand-in:

from unittest.mock import patch, MagicMock

def test_send_welcome_email_calls_smtp_correctly():
    with patch('smtplib.SMTP') as mock_smtp_class:
        mock_smtp = MagicMock()
        mock_smtp_class.return_value = mock_smtp

        result = send_welcome_email('[email protected]')

        assert result is True
        mock_smtp_class.assert_called_with('smtp.example.com', 587)
        mock_smtp.starttls.assert_called_once()
        mock_smtp.login.assert_called_with('user', 'pass')
        mock_smtp.sendmail.assert_called_once()
        mock_smtp.quit.assert_called_once()

Что мы получили:

  1. No real SMTP — tests run в milliseconds.
  2. Deterministic — нет network dependency.
  3. Behavior verification — не просто result, но how function called external API.
  4. Safe — нет real emails sent.

Mocks — не dishonest tests. Они verify interaction contract между вашим code и dependency. Production-grade unit tests always mock external dependencies.

Cite: pytest 8 docs — monkeypatch; Lib/unittest/mock.pyMagicMock + patch reference.


unittest.mock.MagicMock — callable mock с auto-generated child attrs

MagicMock — основной mock class. Has all magic methods auto-implemented (__call__, __getattr__, __iter__, etc.) для drop-in replacement любого object:

from unittest.mock import MagicMock

mock = MagicMock()

# Auto-callable
result = mock()                       # returns another MagicMock (default)
print(result)                         # <MagicMock id='...'>

# Auto-attribute access
print(mock.foo)                       # <MagicMock name='mock.foo' id='...'>
print(mock.foo.bar.baz)               # works arbitrarily deep

# call_count tracking
mock(1)
mock(2)
mock(3)
print(mock.call_count)                # 3
print(mock.called)                    # True
print(mock.call_args)                 # call(3) — last call
print(mock.call_args_list)            # [call(1), call(2), call(3)]

# Configurable return value
mock_with_return = MagicMock(return_value=42)
print(mock_with_return())             # 42
print(mock_with_return('any', 'args'))  # 42 (always returns 42)

# Configurable raise
mock_with_error = MagicMock(side_effect=ValueError('fail'))
try:
    mock_with_error()
except ValueError as e:
    print(f'caught: {e}')             # caught: fail

# side_effect as function
mock_with_fn = MagicMock(side_effect=lambda x: x * 2)
print(mock_with_fn(5))                # 10
print(mock_with_fn(10))               # 20

Key API:

Attribute / methodDescription
MagicMock(return_value=X)mock() returns X
MagicMock(side_effect=fn)mock(args) calls fn(args), returns its result
MagicMock(side_effect=Exc)mock(...) raises Exc
mock.calledbool — was mock called?
mock.call_countint — number of calls
mock.call_argscall(...) — last call args
mock.call_args_listlist[call] — all calls
mock.assert_called_with(...)raises if last call differs
mock.assert_called_once()raises if not called exactly once
mock.assert_not_called()raises if called

Mock vs MagicMock:

  • Mock — basic mock без magic methods. Use rare.
  • MagicMock — Mock + magic methods (__call__, __iter__, __enter__, etc.). Default choice.

Cite: Lib/unittest/mock.pyMagicMock class definition; _patch.__enter__/__exit__ для context-manager-style patching.


patch() decorator + context-manager

patch('module.attribute') temporarily replaces attribute с MagicMock. Two usage patterns:

Decorator style

from unittest.mock import patch, MagicMock

@patch('mymodule.smtplib.SMTP')
def test_email_decorator(mock_smtp_class):
    mock_smtp = MagicMock()
    mock_smtp_class.return_value = mock_smtp

    send_welcome_email('[email protected]')

    mock_smtp.sendmail.assert_called_once()

Note: decorator injects mock как last test parameter. Pytest fixtures (если есть) — before the mock.

Context-manager style (preferred)

def test_email_context_manager():
    with patch('mymodule.smtplib.SMTP') as mock_smtp_class:
        mock_smtp = MagicMock()
        mock_smtp_class.return_value = mock_smtp

        send_welcome_email('[email protected]')

        mock_smtp.sendmail.assert_called_once()

Pragmatic rule: context-manager — preferred. Reasons:

  1. Explicit lifetime — patch active только в with block.
  2. No magic injection — mock binding visible (as mock_smtp_class).
  3. Multiple patches — easy nest: with patch(...) as a, patch(...) as b:.

Где патчить — important

patch('mymodule.smtplib.SMTP') patches smtplib.SMTP в namespace mymodule, NOT smtplib module’s own attribute. Pitfall — patch the import location, not original:

# mymodule.py
from smtplib import SMTP   # ← imports SMTP into mymodule namespace

def send_email():
    smtp = SMTP(...)        # ← uses mymodule.SMTP
    ...

# test_mymodule.py
@patch('mymodule.SMTP')     # ← CORRECT: patch mymodule's SMTP attribute
def test(...): ...

@patch('smtplib.SMTP')      # ← WRONG: patches smtplib's SMTP, but mymodule already имеет own reference
def test(...): ...

Cite: pytest 8 docs — где патчить; unittest.mock — Where to patch.


patch.object (Pitfall 13) — already-imported objects

patch.object(target, 'attribute', ...) — patches attribute on already-imported object (vs string-path):

from unittest.mock import patch
from mymodule import EmailService

def test_email_service_directly():
    service = EmailService()

    with patch.object(service, 'send', return_value=True) as mock_send:
        result = service.send('[email protected]')

        mock_send.assert_called_once_with('[email protected]')
        assert result is True

Pitfall 13 — when использовать patch vs patch.object:

Use caseChoose
Patch attribute by string path ('mymodule.func')patch('module.attribute')
Patch attribute on already-imported instance/classpatch.object(obj, 'attribute')
Patch class method для всех instancespatch.object(MyClass, 'method')

patch.object more explicit — gets actual object, not magic string. Preferred when target object already в scope. patch preferred for cross-module patching (e.g., 3rd-party library functions).

Cite: unittest.mock — patch.object; Pitfall 13 — Plan 67-RESEARCH lines 591-617.


monkeypatch fixture — function-scoped + Pitfall 17 ScopeMismatch

monkeypatch — pytest’s built-in fixture для temporarily mutating attributes/env vars. Function-scoped — auto-undo после test.

import os
import pytest

def get_db_url() -> str:
    return os.environ['DATABASE_URL']


def test_get_db_url(monkeypatch):
    monkeypatch.setenv('DATABASE_URL', 'sqlite:///:memory:')
    assert get_db_url() == 'sqlite:///:memory:'
    # After test: env var undo'd automatically

monkeypatch API:

MethodEffect
monkeypatch.setattr('module.attr', value)Set attribute (string path)
monkeypatch.setattr(obj, 'attr', value)Set attribute on instance
monkeypatch.delattr('module.attr')Delete attribute
monkeypatch.setenv('NAME', 'value')Set environment variable
monkeypatch.delenv('NAME')Delete environment variable
monkeypatch.syspath_prepend('/path')Prepend sys.path
monkeypatch.chdir('/path')Change CWD

Undo stack — pytest tracks all changes made via monkeypatch.X calls. After test exits (success или fail), pytest automatically reverses all changes в LIFO order. Это — defensive mechanism — guarantees test isolation.

Pitfall 17 — ScopeMismatch

monkeypatchfunction-scoped. Cannot use в session-scoped fixture:

import pytest

@pytest.fixture(scope='session')
def configured_db(monkeypatch):       # ← ERROR: ScopeMismatch
    monkeypatch.setenv('DATABASE_URL', 'sqlite:///:memory:')
    return Database()

**pytest.fixture(scope='session') requesting function-scoped fixture** — pytest raises ScopeMismatch`. Recall lesson 02 — fixture scope must be ≥ scope of fixtures it requests.

Mitigationpytest.MonkeyPatch() (uppercase, class — not the fixture):

import pytest

@pytest.fixture(scope='session')
def configured_db():
    mp = pytest.MonkeyPatch()                      # ← session-scope alternative
    mp.setenv('DATABASE_URL', 'sqlite:///:memory:')
    yield Database()
    mp.undo()                                      # ← manual undo

pytest.MonkeyPatch() instance имеет same API (setattr/setenv/etc), но manually controlled lifetimemp.undo() explicitly reverses changes.

Pragmatic rule:

  • Function-scoped test/fixture — use monkeypatch fixture (auto-undo).
  • Session/module-scoped fixture — instantiate pytest.MonkeyPatch() + manual mp.undo().

Cite: pytest 8 docs — monkeypatch; Pitfall 17 — Plan 67-RESEARCH.


Run-on-Your-Machine — monkeypatch demo

TIP

Run-on-Your-Machine: monkeypatch fixture для env vars + assert undo

Установите pytest (если ещё не из урока 02):

pip install pytest

Создайте test_monkeypatch_demo.py:

# test_monkeypatch_demo.py
import os
import pytest


def get_db_url() -> str:
    """Read DB URL from env."""
    return os.environ.get('DATABASE_URL', 'NOT_SET')


def test_set_env_via_monkeypatch(monkeypatch):
    # Arrange — set env var
    monkeypatch.setenv('DATABASE_URL', 'sqlite:///test.db')

    # Act
    result = get_db_url()

    # Assert
    assert result == 'sqlite:///test.db'


def test_env_not_persisted_to_next_test():
    """After previous test, monkeypatch auto-undid env var."""
    # Если monkeypatch не undo'd, этот test fails:
    # DATABASE_URL would still be 'sqlite:///test.db'
    result = get_db_url()
    assert result != 'sqlite:///test.db'   # auto-undo confirmed


def test_set_env_via_monkeypatch_again(monkeypatch):
    """Same env var, fresh setup для этого test."""
    monkeypatch.setenv('DATABASE_URL', 'postgresql://localhost/db')
    assert get_db_url() == 'postgresql://localhost/db'

Запустите:

pytest -v test_monkeypatch_demo.py

Expected output:

test_monkeypatch_demo.py::test_set_env_via_monkeypatch PASSED            [ 33%]
test_monkeypatch_demo.py::test_env_not_persisted_to_next_test PASSED     [ 66%]
test_monkeypatch_demo.py::test_set_env_via_monkeypatch_again PASSED      [100%]

============================== 3 passed in 0.02s ==============================

Что мы verify’ли:

  1. monkeypatch.setenv sets env var только на duration test.
  2. Auto-undo — следующий test не видит mutation. Это — isolation guarantee monkeypatch.
  3. Re-set в следующем test — fresh setup, no carry-over.

Bonus — попробуйте:

  • Заменить monkeypatch.setenv(...) на os.environ['DATABASE_URL'] = ... (raw mutation) → 2nd test fails (mutation persists).
  • Demonstrates why monkeypatch — defensive choice for test isolation.

Recipe — production mock + monkeypatch combination

Real-world test combining patch + monkeypatch:

# tests/test_user_service.py
import pytest
from unittest.mock import patch, MagicMock
from mypkg.user_service import UserService


@pytest.fixture
def user_service(monkeypatch):
    """Create UserService с mocked SMTP + env-driven config."""
    monkeypatch.setenv('USER_DB_URL', 'sqlite:///:memory:')
    monkeypatch.setenv('SMTP_HOST', 'mock.smtp.test')
    return UserService()


def test_register_user_sends_welcome_email(user_service):
    with patch('mypkg.user_service.smtplib.SMTP') as mock_smtp_class:
        mock_smtp = MagicMock()
        mock_smtp_class.return_value = mock_smtp

        user = user_service.register('[email protected]', 'password')

        # Assert: user created
        assert user.email == '[email protected]'

        # Assert: SMTP called correctly
        mock_smtp_class.assert_called_with('mock.smtp.test', 587)
        mock_smtp.starttls.assert_called_once()
        mock_smtp.sendmail.assert_called_once()

        # Assert: email contained welcome content
        sendmail_call = mock_smtp.sendmail.call_args
        assert 'Welcome' in sendmail_call.args[2]

Что мы получили:

  1. monkeypatch — env vars (config source) replaced для этого test.
  2. patch('...') — SMTP class mocked для этого test’а via context-manager.
  3. MagicMock — controllable instance для verifying interaction.
  4. assert_called_with / assert_called_once — behavior verification.
  5. call_args.args[N] — inspect actual arguments passed.

Test runs в milliseconds, no network, no side-effects. Это — production-grade isolated unit test.


Brief — patch alternatives

patch.dict('os.environ', {'KEY': 'value'}) — temporary dict mutation:

def test_with_env():
    with patch.dict(os.environ, {'DATABASE_URL': 'test'}):
        assert get_db_url() == 'test'

Equivalent to monkeypatch.setenv but без fixture dependency.

patch.multiple(target, **values) — patch multiple attributes simultaneously:

with patch.multiple('mymodule', SMTP=MagicMock(), HTTP=MagicMock()):
    ...

unittest.mock.AsyncMock — для async/await targets (Python 3.8+):

from unittest.mock import AsyncMock

mock = AsyncMock(return_value=42)

async def test_async():
    result = await mock()
    assert result == 42

AsyncMock.assert_awaited_once() — async-specific assertion.

Cite: unittest.mock — patch variants.


Ключевые выводы

  1. Mocks isolate code under test от external dependencies (DB, network, time, random). Tests становятся fast + deterministic + side-effect-free. Verify interaction contract, не дublicate behavior.
  2. MagicMock — callable + auto-attributes + magic methods. Configurable return_value / side_effect. Tracking called / call_count / call_args.
  3. patch('module.attr') decorator + context-manager (preferred) — temporary attribute replacement. Patch import location, NOT original (patch('mymodule.SMTP'), не patch('smtplib.SMTP') если mymodule does from smtplib import SMTP).
  4. patch.object (Pitfall 13) — patch attribute on already-imported instance/class. Use when target в scope. patch для cross-module string-path.
  5. monkeypatch fixture — function-scoped, auto-undo. Pitfall 17: NOT usable in session-scoped fixtures (ScopeMismatch). Use pytest.MonkeyPatch() instance для session/module-scope alternative + manual mp.undo().
  6. Cite: Lib/unittest/mock.py MagicMock + _patch.__enter__/__exit__; pytest 8 monkeypatch docs.

Дальше — conftest.py (урок 06): shared fixtures across test directory subtree, project layouts (src vs flat), pyproject.toml [tool.pytest.ini_options] config. Browser challenge py-m08-06-code-1 использует Pattern 4 (string-analysis triple-quote) для conftest.py content.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. **Pitfall 17:** Почему pytest **не позволяет** использовать `monkeypatch` fixture внутри `@pytest.fixture(scope='session')` определения? Какое workaround?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7