Skip to content

Mocking context managers#

In Python, with statement context managers provide an extremely useful interface to execute code inside a given "runtime context." This context can define consistent, failsafe setup and teardown behavior. For example, Python's built-in file objects provide a context manager interface to ensure the underlying file resource is opened and closed cleanly, without the caller having to explicitly deal with it:

with open("hello-world.txt", "r") as f:
    contents = f.read()

You can use Decoy to mock out your dependencies that should provide a context manager interface.

Generator-based context managers#

Using the contextlib module, you can decorate a generator function or method to turn its yielded value into a context manager. This is a great API, and one that Decoy is well-suited to mock. To mock a generator function context manager, use decoy.Stub.then_enter_with.

import contextlib
from my_module.core import Core
from my_module.config import Config, ConfigLoader

def test_loads_config(decoy: Decoy) -> None:
    """It should load config from a ConfigLoader dependency.

    In this example, we know we're going to read/write config
    to/from an external source, like the filesystem. So we want to
    implement this dependency as a context manager to ensure
    resource cleanup.
    """
    config_loader = decoy.mock(ConfigLoader)
    config = decoy.mock(Config)

    subject = Core(config_loader=config_loader)

    decoy.when(config_loader.load()).then_enter_with(config)
    decoy.when(config.read("some_flag")).then_return(True)

    result = subject.get_config("some_flag")

    assert result is True

From this test, we could sketch out the following dependency APIs...

# config.py
import contextlib
from typing import Generator

class Config:
    def read(self, name: str) -> bool:
        ...

class ConfigLoader:
    @contextlib.contextmanager
    def load(self) -> Generator[Config, None, None]:
        ...

...along with our test subject's implementation to pass the test...

# core.py
from .config import Config, ConfigLoader

class Core:
    def __init__(self, config_loader: ConfigLoader) -> None:
        self._config_loader = config_loader

    def get_config(self, name: str) -> bool:
        with self._config_loader.load() as config:
            return config.read(name)

General context managers#

A context manager is simply an object with both __enter__ and __exit__ methods defined. Decoy mocks have both these methods defined, so they are compatible with the with statement. In the author's opinion, tests that mock __enter__ and __exit__ (or any double-underscore method) are harder to read and understand than tests that do not, so generator-based context managers should be preferred where applicable.

Using our earlier example, maybe you'd prefer to use a single Config dependency to both load the configuration resource and read values.

import contextlib
from my_module.core import Core
from my_module.config import Config, ConfigLoader

def test_loads_config(decoy: Decoy) -> None:
    """It should load config from a Config dependency."""
    config = decoy.mock(Config)
    subject = Core(config=config)

    def _handle_enter() -> Config:
        """Ensure `read` only works if context is entered."""
        decoy.when(config.read("some_flag")).then_return(True)
        return config

    def _handle_exit() -> None:
        """Ensure test fails if subject calls `read` after exit."""
        decoy.when(
            config.read("some_flag")
        ).then_raise(AssertionError("Context manager was exited"))

    decoy.when(config.__enter__()).then_do(_handle_enter)
    decoy.when(config.__exit__(None, None, None)).then_do(_handle_exit)

    result = subject.get_config("some_flag")

    assert result is True

From this test, our dependency APIs would be...

# config.py
from __future__ import annotations
from types import TracebackType
from typing import Type, Optional

class Config:
    def __enter__(self) -> Config:
        ...

    def __exit__(
        self,
        exc_type: Optional[Type[BaseException]],
        exc_value: Optional[BaseException],
        traceback: Optional[TracebackType],
    ) -> Optional[bool]:
        ...

    def read(self, name: str) -> bool:
        ...

...along with our test subject's implementation to pass the test...

# core.py
from .config import Config

class Core:
    def __init__(self, config: Config) -> None:
        self._config = config

    def get_config(self, name: str) -> bool:
        with self._config as loaded_config:
            return loaded_config.read(name)

Asynchronous context managers#

Decoy is also compatible with mocking the async __aenter__ and __aexit__ methods of async context managers.

import pytest
import contextlib
from my_module.core import Core
from my_module.config import Config, ConfigLoader

async def test_loads_config(decoy: Decoy) -> None:
    """It should load config from a Config dependency."""
    config = decoy.mock(Config)
    subject = Core(config=config)

    async def _handle_enter() -> Config:
        """Ensure `read` only works if context is entered."""
        decoy.when(config.read("some_flag")).then_return(True)
        return config

    async def _handle_exit() -> None:
        """Ensure test fails if subject calls `read` after exit."""
        decoy.when(
            config.read("some_flag")
        ).then_raise(AssertionError("Context manager was exited"))

    decoy.when(await config.__aenter__()).then_do(_handle_enter)
    decoy.when(await config.__aexit__()).then_do(_handle_exit)

    result = await subject.get_config("some_flag")

    assert result is True

This test spits out the following APIs and implementations...

# config.py
from __future__ import annotations
from types import TracebackType
from typing import Type, Optional

class Config:
    async def __aenter__(self) -> Config:
        ...

    async def __aexit__(
        self,
        exc_type: Optional[Type[BaseException]],
        exc_value: Optional[BaseException],
        traceback: Optional[TracebackType],
    ) -> Optional[bool]:
        ...

    def read(self, name: str) -> bool:
        ...

...along with our test subject's implementation to pass the test...

# core.py
from .config import Config

class Core:
    def __init__(self, config: Config) -> None:
        self._config = config

    async def get_config(self, name: str) -> bool:
        async with self._config as loaded_config:
            return loaded_config.read(name)