Mocks и monkeypatch — изоляция от внешних зависимостей
В этом уроке — isolation technique для unit tests. Mocks заменяют real objects (DB connections, HTTP clients, time, random) на controllable stand-ins. Это позволяет test’ить логику независимо от external state, делает tests fast + deterministic.
В этом уроке:
- Why mocks — isolate code under test от external dependencies.
unittest.mock.MagicMock— callable mock с auto-generated child attrs.patch()decorator + context-manager — temporary attribute replacement.patch.objectvspatch(Pitfall 13) — when to choose which.monkeypatchfixture (function-scoped) + Pitfall 17 (ScopeMismatch).pytest.MonkeyPatch()— session-scope alternative.- 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()
Что мы получили:
- No real SMTP — tests run в milliseconds.
- Deterministic — нет network dependency.
- Behavior verification — не просто result, но how function called external API.
- 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.py — MagicMock + 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 / method | Description |
|---|---|
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.called | bool — was mock called? |
mock.call_count | int — number of calls |
mock.call_args | call(...) — last call args |
mock.call_args_list | list[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.py — MagicMock 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:
- Explicit lifetime — patch active только в
withblock. - No magic injection — mock binding visible (
as mock_smtp_class). - 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 case | Choose |
|---|---|
Patch attribute by string path ('mymodule.func') | patch('module.attribute') |
| Patch attribute on already-imported instance/class | patch.object(obj, 'attribute') |
| Patch class method для всех instances | patch.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:
| Method | Effect |
|---|---|
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
monkeypatch — function-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.
Mitigation — pytest.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 lifetime — mp.undo() explicitly reverses changes.
Pragmatic rule:
- Function-scoped test/fixture — use
monkeypatchfixture (auto-undo). - Session/module-scoped fixture — instantiate
pytest.MonkeyPatch()+ manualmp.undo().
Cite: pytest 8 docs — monkeypatch; Pitfall 17 — Plan 67-RESEARCH.
Run-on-Your-Machine — monkeypatch demo
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.pyExpected 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’ли:
monkeypatch.setenvsets env var только на duration test.- Auto-undo — следующий test не видит mutation. Это — isolation guarantee monkeypatch.
- 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]
Что мы получили:
monkeypatch— env vars (config source) replaced для этого test.patch('...')— SMTP class mocked для этого test’а via context-manager.MagicMock— controllable instance для verifying interaction.assert_called_with/assert_called_once— behavior verification.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.
Ключевые выводы
- Mocks isolate code under test от external dependencies (DB, network, time, random). Tests становятся fast + deterministic + side-effect-free. Verify interaction contract, не дublicate behavior.
MagicMock— callable + auto-attributes + magic methods. Configurablereturn_value/side_effect. Trackingcalled/call_count/call_args.patch('module.attr')decorator + context-manager (preferred) — temporary attribute replacement. Patch import location, NOT original (patch('mymodule.SMTP'), неpatch('smtplib.SMTP')еслиmymoduledoesfrom smtplib import SMTP).patch.object(Pitfall 13) — patch attribute on already-imported instance/class. Use when target в scope.patchдля cross-module string-path.monkeypatchfixture — function-scoped, auto-undo. Pitfall 17: NOT usable in session-scoped fixtures (ScopeMismatch). Usepytest.MonkeyPatch()instance для session/module-scope alternative + manualmp.undo().- Cite:
Lib/unittest/mock.pyMagicMock +_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.