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
mockamorphlibrary 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
CreateNewUserUsecaseclass - 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
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mockamorph-0.2.1.tar.gz.
File metadata
- Download URL: mockamorph-0.2.1.tar.gz
- Upload date:
- Size: 6.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: Hatch/1.16.2 cpython/3.12.8 HTTPX/0.28.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
db7a0c189285ba3dfe57735b36698610d0c5636fc50c544056050cba3280ed49
|
|
| MD5 |
452a7fc30222a0357ced48009a455f2d
|
|
| BLAKE2b-256 |
56d325b1171282b72b2f8507705bf66ad1f63c4a103738e11b637c7da51fa884
|
File details
Details for the file mockamorph-0.2.1-py3-none-any.whl.
File metadata
- Download URL: mockamorph-0.2.1-py3-none-any.whl
- Upload date:
- Size: 7.3 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
396d76959883cf57911b3aa60e1b28e6ba57a3a66ee56120229c8780f211b14b
|
|
| MD5 |
8e0f899826e098fd76acf61b37e57111
|
|
| BLAKE2b-256 |
e0ed1ea565a474538ef3757a7edc3d78e1f5e1907b36fd4a4809e7bf9e7aa2d3
|