|
6 | 6 | [](https://img.shields.io/github/commit-activity/m/claudiubelu/mockey) |
7 | 7 | [](https://img.shields.io/github/license/claudiubelu/mockey) |
8 | 8 |
|
9 | | -A fixture that enforces correct mock.patch autospec behaviour, surfacing signature violations that the standard mock library silently ignores. |
| 9 | +A fixture that enforces correct `mock.patch` autospec behaviour, surfacing signature violations that the standard mock library silently ignores. |
10 | 10 |
|
11 | 11 | - **Github repository**: <https://github.com/claudiubelu/mockey/> |
12 | | -- **Documentation** <https://claudiubelu.github.io/mockey/> |
| 12 | +- **Documentation**: <https://claudiubelu.github.io/mockey/> |
13 | 13 |
|
14 | 14 | --- |
15 | 15 |
|
16 | | -Repository initiated with [osprey-oss/cookiecutter-uv](https://github.com/osprey-oss/cookiecutter-uv). |
| 16 | +## Background and motivation |
| 17 | + |
| 18 | +This library is based on |
| 19 | +[oslotest's `mock_fixture.py`](https://github.com/openstack/oslotest/blob/0b348bccd3639b6d2a8acd19e62b1dd19ad903d6/oslotest/mock_fixture.py), |
| 20 | +extracted and extended as a standalone package. |
| 21 | + |
| 22 | +The standard `unittest.mock` library has long-standing bugs that let mocked methods be called with the |
| 23 | +wrong number or names of arguments without raising a `TypeError`. Tests pass, but they are not testing |
| 24 | +anything meaningful, the real code would raise immediately if called the same way. |
| 25 | + |
| 26 | +There are multiple root causes, some of which have been reported in upstream issues: |
| 27 | + |
| 28 | +- [mock#393](https://github.com/testing-cabal/mock/issues/393): `mock.Mock` and `mock.MagicMock` |
| 29 | + have no `autospec=` parameter; using `spec=` only checks attribute *existence*, not call signatures. |
| 30 | +- [mock#396](https://github.com/testing-cabal/mock/issues/396): `mock.patch` with `autospec=True` |
| 31 | + does not consume the implicit `self` argument on instance methods, causing every patched-method |
| 32 | + call to fail the signature check (so people turn `autospec` off). |
| 33 | + |
| 34 | +## What this library fixes |
| 35 | + |
| 36 | +| Issue | Without mockey | With mockey | |
| 37 | +|---|---|---| |
| 38 | +| `mock.Mock(autospec=MyClass)` - calls with wrong args | silently accepted | `TypeError` raised | |
| 39 | +| `mock.Mock(autospec=MyClass)` - non-existent attribute | silently created | `AttributeError` raised | |
| 40 | +| `mock.patch.*` - calls with wrong args | silently accepted | `TypeError` raised | |
| 41 | +| `mock.patch.*` - no explicit `autospec=True` needed | must opt in per-patch | enforced globally | |
| 42 | +| Return value of `def get_foo(self) -> Foo` (via `mock.Mock(autospec=…)`) | plain `MagicMock` | autospecced as `Foo` | |
| 43 | +| Return value of `def get_foo(self) -> Foo` (via `mock.patch.object(…)`) | plain `MagicMock` | autospecced as `Foo` | |
| 44 | +| Return value of `def get_none(self) -> None` | `MagicMock` object | `None` | |
| 45 | +| Constructor: `mock.Mock(autospec=MyClass)(wrong_args)` | silently accepted | `TypeError` raised | |
| 46 | +| Patching an already-mocked attribute | silently double-patches | `InvalidSpecError` raised | |
| 47 | + |
| 48 | +## What this library adds compared to oslotest |
| 49 | + |
| 50 | +In addition to what `oslotest`'s `mock_fixture` fixes, this library adds on top of that: |
| 51 | + |
| 52 | +- **Return-value autospeccing from type hints**: if a method declares `-> SomeClass`, its mock |
| 53 | + return value is automatically autospecced as `SomeClass`, so chained calls are also checked. |
| 54 | +- **`-> None` enforcement**: methods annotated `-> None` return actual `None`, matching runtime |
| 55 | + behaviour and preventing tests from accidentally asserting on a `MagicMock` return value. |
| 56 | +- **Constructor signature enforcement**: calling `mock.Mock(autospec=MyClass)(wrong_args)` raises |
| 57 | + `TypeError`, just as calling the real class' constructor would. |
| 58 | + |
| 59 | +--- |
| 60 | + |
| 61 | +## Installation |
| 62 | + |
| 63 | +```bash |
| 64 | +pip install mockey |
| 65 | +``` |
| 66 | + |
| 67 | +## Usage |
| 68 | + |
| 69 | +### Critical: import order |
| 70 | + |
| 71 | +`patch_mock_module()` must be called **before any test module is imported**. The reason is that |
| 72 | +`@mock.patch` decorators (including `mock.patch.object` and `mock.patch.multiple`) capture |
| 73 | +`mock._patch` at *class definition time*, not at call time. If `patch_mock_module()` is called |
| 74 | +after the test class is imported, those decorators will use the original, unfixed `mock._patch` |
| 75 | +and signature enforcement will silently not apply. |
| 76 | + |
| 77 | +The canonical place is your test package's `__init__.py`: |
| 78 | + |
| 79 | +```python |
| 80 | +# tests/__init__.py |
| 81 | +from mockey.fixture import patch_mock_module |
| 82 | + |
| 83 | +patch_mock_module() |
| 84 | +``` |
| 85 | + |
| 86 | +This file is imported by Python before any test module in the `tests/` package, so all |
| 87 | +`@mock.patch` decorators in all test files pick up the patched version automatically. |
| 88 | + |
| 89 | +### MockAutospecFixture |
| 90 | + |
| 91 | +Activate `MockAutospecFixture` in your test's `setUp`. With `testtools`: |
| 92 | + |
| 93 | +```python |
| 94 | +from mockey import MockAutospecFixture |
| 95 | +import testtools |
| 96 | + |
| 97 | +class MyTestCase(testtools.TestCase): |
| 98 | + def setUp(self): |
| 99 | + super().setUp() |
| 100 | + self.useFixture(MockAutospecFixture()) |
| 101 | +``` |
| 102 | + |
| 103 | +With plain `unittest`: |
| 104 | + |
| 105 | +```python |
| 106 | +from mockey import MockAutospecFixture |
| 107 | +import unittest |
| 108 | + |
| 109 | +class MyTestCase(unittest.TestCase): |
| 110 | + def setUp(self): |
| 111 | + super().setUp() |
| 112 | + self._fixture = MockAutospecFixture() |
| 113 | + self._fixture.setUp() |
| 114 | + self.addCleanup(self._fixture.cleanUp) |
| 115 | +``` |
| 116 | + |
| 117 | +### Using `mock.Mock(autospec=...)` |
| 118 | + |
| 119 | +Once the fixture is active, pass `autospec=` directly to `mock.Mock` or `mock.MagicMock`: |
| 120 | + |
| 121 | +```python |
| 122 | +from unittest import mock |
| 123 | +from mymodule import MyService, MyModel |
| 124 | + |
| 125 | +# Autospec from a class - attribute access and call signatures are enforced. |
| 126 | +m = mock.Mock(autospec=MyService) |
| 127 | + |
| 128 | +# Correct call - passes. |
| 129 | +m.do_something(user_id=42) |
| 130 | + |
| 131 | +# Wrong signature - raises TypeError, just like the real class would. |
| 132 | +m.do_something(unknown_kwarg="oops") # TypeError |
| 133 | + |
| 134 | +# Non-existent attribute - raises AttributeError. |
| 135 | +m.typo_metod # AttributeError |
| 136 | + |
| 137 | +# Autospec from an instance works the same way. |
| 138 | +service = MyService() |
| 139 | +m2 = mock.Mock(autospec=service) |
| 140 | +``` |
| 141 | + |
| 142 | +### Return-value autospeccing |
| 143 | + |
| 144 | +If a method declares a concrete return type, calling it on an autospecced mock returns an |
| 145 | +autospecced instance of that type - no extra setup required: |
| 146 | + |
| 147 | +```python |
| 148 | +class Repository: |
| 149 | + def get_user(self, user_id: int) -> User: |
| 150 | + ... |
| 151 | + |
| 152 | +m = mock.Mock(autospec=Repository) |
| 153 | +user_mock = m().get_user(1) |
| 154 | + |
| 155 | +# user_mock is autospecced as User - wrong attribute access raises AttributeError. |
| 156 | +user_mock.nonexistent_field # AttributeError |
| 157 | + |
| 158 | +# Methods on user_mock also enforce signatures. |
| 159 | +user_mock.update(name="Alice") # passes if that matches User.update's signature |
| 160 | +``` |
| 161 | + |
| 162 | +Methods returning `None` behave correctly too: |
| 163 | + |
| 164 | +```python |
| 165 | +class Writer: |
| 166 | + def flush(self) -> None: |
| 167 | + ... |
| 168 | + |
| 169 | +m = mock.Mock(autospec=Writer) |
| 170 | +result = m().flush() |
| 171 | +assert result is None |
| 172 | +``` |
| 173 | + |
| 174 | +### Using `mock.patch` (decorator and context manager) |
| 175 | + |
| 176 | +With `patch_mock_module()` active, `autospec=True` is the default for all patches - you do not |
| 177 | +need to write it yourself, or update your existing unit tests: |
| 178 | + |
| 179 | +```python |
| 180 | +# Both of these enforce signature checking on Foo.bar. |
| 181 | +with mock.patch.object(Foo, "bar"): |
| 182 | + ... |
| 183 | + |
| 184 | +@mock.patch.object(Foo, "bar") |
| 185 | +def test_something(self, mock_bar): |
| 186 | + ... |
| 187 | +``` |
| 188 | + |
| 189 | +To opt out of autospeccing for a specific patch, pass `autospec=False` explicitly: |
| 190 | + |
| 191 | +```python |
| 192 | +with mock.patch.object(Foo, "bar", autospec=False): |
| 193 | + Foo().bar() # no signature checking |
| 194 | +``` |
| 195 | + |
| 196 | +Passing `new=`, `new_callable=`, `create=`, or `spec=` also disables auto-injection, matching |
| 197 | +the standard library's semantics. |
| 198 | + |
| 199 | +--- |
| 200 | + |
| 201 | +## Contributing |
| 202 | + |
| 203 | +See [CONTRIBUTING.md](CONTRIBUTING.md) for how to set up the development environment, run the |
| 204 | +linter (`make check`), and run the test suite (`make test`). |
0 commit comments