Skip to main content

Lightweight interface mocking library for Python for expectation-driven testing

Project description

Mockamorph

Lightweight interface mocking library for Python for expectation-driven testing.

[!NOTE] I hate monkey-patching using string-literals in tests. I created mockamorph library to simply testing code, inspired by uber-go/mock.

[!WARNING] Code is written with AI assistance. List of tools used:

  • Zed Editor with Claude Opus 4.5.

Quick Example

from typing import Protocol
from mockamorph import Mockamorph


# 1. Define an interface (Protocol or ABC)
class UserRepository(Protocol):
    def get_user(self, user_id: int) -> str: ...
    def save_user(self, name: str) -> bool: ...


# 2. Your code that depends on the interface
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo

    def greet_user(self, user_id: int) -> str:
        name = self.repo.get_user(user_id)
        return f"Hello, {name}!"

    def create_user(self, name: str) -> str:
        if self.repo.save_user(name):
            return "User created"
        raise RuntimeError("Failed to save user")


# 3. Test with Mockamorph
def test_user_service():
    with Mockamorph(UserRepository) as mock:
        # Set expectations BEFORE calling code
        mock.expect().get_user().called_with(42).returns("Alice")
        mock.expect().save_user().called_with("Bob").returns(True)
        mock.expect().save_user().called_with("").raises(RuntimeError("Invalid name"))

        # Use the mock
        service = UserService(mock.get_mock())
        
        assert service.greet_user(42) == "Hello, Alice!"
        assert service.create_user("Bob") == "User created"
        
        with pytest.raises(RuntimeError, match="Invalid name"):
            service.create_user("")
        # Mockamorph auto-verifies all expectations were satisfied on exit

Motivation

When we write code with SOLID principles in mind, there are many interfaces and usecases in our code that depend on interfaces. In production, we use adapters as concrete implementations for those interfaces, but in tests we need to rely on mocks in order to test business logic of usecases.

Typical code looks like this:

class UserRepository(Protocol):
    def get_user(self, user_id: UserID) -> User | None: ...
    def save_user(self, user: User) -> User: ...
    
@final
class CreateNewUserUsecase:
    def __init__(self, repo: UserRepository):
        self.repo = repo
        
    def create_user(self, email: str) -> User:
        ... # some business logic
        
        user = User(email=email, token=10, ...) 
        user = self.repo.save_user(user)
        
        ... # some business logic
        
        return user

In order to test such code, we need to write the following code:

class TestCreateNewUserUsecase(unittest.TestCase):
    def test_create_user(self):
        mock_repo = Mock()
        
        usecase = CreateNewUserUsecase(repo=mock_repo)
        email = "test@example.com"
        expected = User(email=email, token=10, id=1)

        mock_repo.save_user.return_value = expected
        result = usecase.create_user(email=email)
        self.assertEqual(result, expected)

        mock_repo.save_user.assert_called_once_with(User(email=email, token=10))

Note, that we need to:

  • Setup mock before initalizing the CreateNewUserUsecase class
  • Setup mocked return value right before actual call
  • Assert that the method was called with the correct arguments after the execution
  • Assert that the return value is correct

This way, we need to interact with mock object multiple times, increasing the complexity of the test and possibly of human error. To simplify this process, the Mockamorph library was created.

The same test could be written using Mockamorph:

def test_create_user():
    email = "test@example.com"
    
    with Mockamorph(UserRepository) as ctrl:
        ctrl.expect().save_user().called_with(
            User(email=email, token=10)
        ).returns(
            User(email=email, token=10, id=1)
        )
        
        usecase = CreateNewUserUsecase(ctrl.get_mock())
        usecase.create_user(email)
        # Mockamorph automatically verifies all expectations were satisfied

Additionally, this approach simplifies TDT (table driven tests) by allowing to create mocks before actual test execution.

Some toy example:

from collections.abc import Callable
from typing import Protocol, TypedDict, final

from mockamorph import Mockamorph


class Greeter(Protocol):
    def greet(self, name: str) -> str: ...


@final
class GreetUsecase:
    def __init__(self, greeter: Greeter) -> None:
        self._greeter = greeter

    def execute(self, name: str | None) -> str:
        if name is None:
            return "Hello, anon!"

        return self._greeter.greet(name) + "!"


def test_greet_table_driven() -> None:
    class Test(TypedDict):
        name: str
        mock: Callable[[Mockamorph[Greeter]], None]
        input: str | None
        expected: str

    tests: list[Test] = [
        {
            "name": "greets alice",
            "mock": lambda m: m.expect().greet().called_with("Alice").returns("Hello, Alice"),
            "input": "Alice",
            "expected": "Hello, Alice!",
        },
        {
            "name": "greets bob",
            "mock": lambda m: m.expect().greet().called_with("Bob").returns("Hi, Bob"),
            "input": "Bob",
            "expected": "Hi, Bob!",
        },
        {
            "name": "greets empty",
            "mock": lambda m: m.expect().greet().called_with("").returns("Hello, stranger"),
            "input": "",
            "expected": "Hello, stranger!",
        },
        {
            "name": "name is missing",
            "mock": lambda m: None,  # no calls expected
            "input": None,
            "expected": "Hello, anon!",
        },
    ]

    for tt in tests:
        with Mockamorph(Greeter) as ctrl:
            tt["mock"](ctrl)
            result = GreetUsecase(ctrl.get_mock()).execute(tt["input"])
            assert result == tt["expected"], f"Failed: {tt['name']}"

Examples

Basic Mocking

from mockamorph import Mockamorph

class Calculator(Protocol):
    def add(self, a: int, b: int) -> int: ...

with Mockamorph(Calculator) as mock:
    mock.expect().add().called_with(2, 3).returns(5)
    
    calc = mock.get_mock()
    assert calc.add(2, 3) == 5

Multiple Return Values (FIFO)

with Mockamorph(Calculator) as mock:
    mock.expect().add().called_with(1, 1).returns(2)
    mock.expect().add().called_with(1, 1).returns(3)  # Different return for same args
    
    calc = mock.get_mock()
    assert calc.add(1, 1) == 2  # First call
    assert calc.add(1, 1) == 3  # Second call

Raising Exceptions

class FileReader(Protocol):
    def read(self, path: str) -> str: ...

with Mockamorph(FileReader) as mock:
    mock.expect().read().called_with("/missing").raises(FileNotFoundError("Not found"))
    
    reader = mock.get_mock()
    with pytest.raises(FileNotFoundError):
        reader.read("/missing")

Returning Tuples

class DataSource(Protocol):
    def fetch(self) -> tuple[int, str, bool]: ...

with Mockamorph(DataSource) as mock:
    # Use multiple arguments to returns() for tuple unpacking
    mock.expect().fetch().called_with().returns(42, "hello", True)
    
    source = mock.get_mock()
    x, y, z = source.fetch()
    assert (x, y, z) == (42, "hello", True)

Manual Verification

mock = Mockamorph(Calculator)
mock.expect().add().called_with(1, 2).returns(3)

calc = mock.get_mock()
calc.add(1, 2)

mock.verify()  # Manually verify all expectations were satisfied

Resetting Expectations

mock = Mockamorph(Calculator)
mock.expect().add().called_with(1, 2).returns(3)
mock.reset()  # Clear all expectations
mock.verify()  # Passes - no expectations to satisfy

Async Support

class RemoveServer(Protocol):
    async def fetch(self, resource: str) -> bytes: ...

async with Mockamorph(RemoveServer) as mock:
    mock.expect().fetch().awaited_with(resource="resA").returns(b"ok")
    
    source = mock.get_mock()
    assert await source.fetch() == b"ok"

Development

Setup

# Clone the repository
git clone https://github.com/mockamorph/mockamorph.git
cd mockamorph

# Install dependencies with uv
uv sync --all-groups

Running Tests

uv run pytest .

Type Checking

uv run mypy src
# or
uv run basedpyright

Building

uv run hatch build

Publishing

uv run hatch publish

License

MIT License - see LICENSE file for details.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mockamorph-0.2.2.tar.gz (6.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mockamorph-0.2.2-py3-none-any.whl (7.4 kB view details)

Uploaded Python 3

File details

Details for the file mockamorph-0.2.2.tar.gz.

File metadata

  • Download URL: mockamorph-0.2.2.tar.gz
  • Upload date:
  • Size: 6.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: Hatch/1.16.2 cpython/3.12.8 HTTPX/0.28.1

File hashes

Hashes for mockamorph-0.2.2.tar.gz
Algorithm Hash digest
SHA256 5a3bb7f39d38e53a9f6ccc37de619204d0d65ddc6631a0f8cb4e85349bd6adaf
MD5 079d0edd84e6ce2b2dc9f9299f9c06bd
BLAKE2b-256 58e6cab3fac9305d6df05b7b53c32c00c7389213cf6f068379927c55a61d369c

See more details on using hashes here.

File details

Details for the file mockamorph-0.2.2-py3-none-any.whl.

File metadata

  • Download URL: mockamorph-0.2.2-py3-none-any.whl
  • Upload date:
  • Size: 7.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: Hatch/1.16.2 cpython/3.12.8 HTTPX/0.28.1

File hashes

Hashes for mockamorph-0.2.2-py3-none-any.whl
Algorithm Hash digest
SHA256 ca0c76cc17d8b16c45e093768081742086b208d1fd7e0399211df02e2faeec54
MD5 1c292399ccd4220240a693cb2ee13cf2
BLAKE2b-256 c80cc0318037959dd44b345ee3e3faee295ca37231f2b94d62e40868006c8692

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page