declarative-mocks 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- declarative_mocks-0.1.0/LICENSE +21 -0
- declarative_mocks-0.1.0/PKG-INFO +278 -0
- declarative_mocks-0.1.0/README.md +251 -0
- declarative_mocks-0.1.0/pyproject.toml +134 -0
- declarative_mocks-0.1.0/src/dmock/__init__.py +45 -0
- declarative_mocks-0.1.0/src/dmock/_exceptions.py +166 -0
- declarative_mocks-0.1.0/src/dmock/_expectation.py +526 -0
- declarative_mocks-0.1.0/src/dmock/_matchers.py +174 -0
- declarative_mocks-0.1.0/src/dmock/_mock.py +289 -0
- declarative_mocks-0.1.0/src/dmock/_types.py +187 -0
- declarative_mocks-0.1.0/src/dmock/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dimitry Churilov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: declarative-mocks
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Declare mock behavior as ordered expectations. Simple, typed, gomock-inspired DSL over unittest.mock
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: mock,mocking,testing,unittest,pytest
|
|
8
|
+
Author: Dimitry Churilov
|
|
9
|
+
Author-email: d.mdx3r@gmail.com
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Project-URL: Homepage, https://github.com/MDx3R/declarative-mocks
|
|
23
|
+
Project-URL: Issues, https://github.com/MDx3R/declarative-mocks/issues
|
|
24
|
+
Project-URL: Repository, https://github.com/MDx3R/declarative-mocks
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
[](https://github.com/MDx3R/declarative-mocks/actions/workflows/ci.yml)
|
|
28
|
+
[](https://www.python.org/downloads/)
|
|
29
|
+
[](https://opensource.org/licenses/MIT)
|
|
30
|
+
|
|
31
|
+
# declarative-mocks
|
|
32
|
+
|
|
33
|
+
**Declare mock behavior as ordered expectations** - readable tests with explicit call patterns, return sequences, and side effects, built on `unittest.mock`.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
- **Fluent expectations** - register calls with arguments (positional vs keyword aware), matchers (`Anything`, `ANY_ARGS` / `ANY_KWARGS`, …), and outcomes in order.
|
|
40
|
+
- **Return sequences** - chain multiple `.returns()` / `.raises()` / `.runs()` for successive matching calls.
|
|
41
|
+
- **Call-count controls** - one quantifier per `expect(...)` (`.once()`, `.times(n)`, `.between(min, max)`, `.never()`, and more).
|
|
42
|
+
- **Spec-backed mocks** - aligned with `Mock(spec=…)` semantics for safer, clearer tests.
|
|
43
|
+
- **Async without ceremony** - `expect()` on `async def` methods is the same DSL; `runs()` accepts both sync and async callables.
|
|
44
|
+
- **No extra runtime dependencies** - only the standard library at runtime.
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
The first PyPI release is being prepared. Until then, install from source:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
poetry install
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The intended published command:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install declarative-mocks
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## The idea: before and after
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
**Before - `unittest.mock`:** behavior is spread across `return_value`, `side_effect`, and ad hoc assertions; ordering and “what happens on the third call?” are easy to lose in a long test.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from unittest.mock import Mock
|
|
70
|
+
|
|
71
|
+
service = Mock(spec=MyService)
|
|
72
|
+
service.fetch_user.side_effect = [
|
|
73
|
+
{"id": 1, "name": "Ada"},
|
|
74
|
+
{"id": 1, "name": "Ada"},
|
|
75
|
+
ConnectionError("retry"),
|
|
76
|
+
]
|
|
77
|
+
# … exercise code …
|
|
78
|
+
# Assertions about call order and counts are often manual and verbose.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**After - `dmock`:** the same story is **declared** next to the mock: ordered expectations, explicit outcomes, and a single verification step.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from dmock import DeclarativeMock
|
|
85
|
+
|
|
86
|
+
service = DeclarativeMock(MyService)
|
|
87
|
+
service.expect("fetch_user").returns({"id": 1, "name": "Ada"}).returns({"id": 1, "name": "Ada"})
|
|
88
|
+
service.expect("fetch_user").raises(ConnectionError("retry"))
|
|
89
|
+
# … exercise code …
|
|
90
|
+
service.verify()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## How it compares
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
The goals are the same as above: a strictly typed expectation DSL, a whitelist mock you construct, call order as a graph, and async wrapping every method in AsyncMock yourself. Against [mockito-python](https://github.com/kaste/mockito-python) and [flexmock](https://github.com/flexmock/flexmock) that looks like this.
|
|
98
|
+
|
|
99
|
+
### Static analysis
|
|
100
|
+
|
|
101
|
+
That only matters if you type-check tests. Tests deserve the same quality bar as production code - maybe more, now that agents write so much of the suite.
|
|
102
|
+
|
|
103
|
+
`mockito-python` is strong on features, but the installed package has **no `py.typed` marker and no `.pyi` stubs**. Then `mypy tests` fails on `import mockito` (`missing library stubs or py.typed marker`) unless you set `ignore_missing_imports = True`. That is [issue #58](https://github.com/kaste/mockito-python/issues/58): the reporter was already ignoring the package; the maintainer called full typing “some significant work.” The ignore makes mypy succeed by treating mockito as `Any`, so `when` / `thenReturn` / `verify` themselves are not checked.
|
|
104
|
+
|
|
105
|
+
`flexmock` **is** typed (`py.typed` since 0.11.3), but it addresses methods **by string** (`should_receive("fetch_user")`), so the name is still not checked statically.
|
|
106
|
+
|
|
107
|
+
`dmock` is the only one of the three that is **strictly typed** (`py.typed`) **and** uses real attribute names on the mock (`mock.fetch_user(...)`), validated against the spec at construction/`expect()` time.
|
|
108
|
+
|
|
109
|
+
### Shorter setup and verify
|
|
110
|
+
|
|
111
|
+
With dmock you write the call once. `verify()` does not repeat it:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
mock.expect("fetch_user", 1).returns(user).once()
|
|
115
|
+
# … exercise code …
|
|
116
|
+
mock.verify()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
mockito splits **configuration and verification** into two expressions that repeat the call:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
when(service).fetch_user(1).thenReturn(user)
|
|
123
|
+
# … exercise code …
|
|
124
|
+
verify(service, times=1).fetch_user(1)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
flexmock uses a four-link chain and a string method name:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
flexmock(service).should_receive("fetch_user").with_args(1).and_return(user).once()
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### A whitelist mock you construct
|
|
134
|
+
|
|
135
|
+
`DeclarativeMock(MyService)` is an object you **hold and pass in**. Unregistered calls fail immediately. Nothing on live modules or instances is rewritten.
|
|
136
|
+
|
|
137
|
+
mockito's usual `when(obj)` **does** rewrite methods on `obj` (module, class, or instance). The docs say you must `unstub()` afterwards, or use `with` / a pytest fixture, or stubs leak into later tests. `mock()` can create an injectable dummy instead, but it still sits in the same global registry.
|
|
138
|
+
|
|
139
|
+
flexmock also replaces attributes on existing objects (`should_receive`). Restore is automatic under pytest and unittest - there is no `unstub()` you call by hand.
|
|
140
|
+
|
|
141
|
+
### Call order as a dependency graph
|
|
142
|
+
|
|
143
|
+
`.not_before(...)` and `in_order(...)` are checked **at the violating call**, not after the fact at `verify()`. Dependencies may cross mock instances. Cycles are rejected at configuration time with `ConfigurationError`.
|
|
144
|
+
|
|
145
|
+
### Async without caveats
|
|
146
|
+
|
|
147
|
+
This argument holds **against flexmock**, not against mockito.
|
|
148
|
+
|
|
149
|
+
flexmock still has **no first-class async API** (no `async` / `await` / `coroutine` in the changelog or API reference). The documented workaround is to return an `AsyncMock` or `asyncio.Future` yourself.
|
|
150
|
+
|
|
151
|
+
mockito added first-class async/await stubbing in **2.0.0**. Their remaining caveat: introspection metadata such as `inspect.iscoroutinefunction` on stub wrappers is implemented **only on Python 3.12+**.
|
|
152
|
+
|
|
153
|
+
`dmock` installs a real nested `async def` dispatcher, so `inspect.iscoroutinefunction(mock.aprocess_order)` is true on **every supported Python** (3.11-3.14).
|
|
154
|
+
|
|
155
|
+
## Usage examples
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### 1. Nested return values (successive `.returns()`)
|
|
160
|
+
|
|
161
|
+
Each `.returns()` applies to the next matching call in order - handy for paginated or multi-step flows without a hand-built `side_effect` list.
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from dmock import DeclarativeMock
|
|
165
|
+
|
|
166
|
+
api = DeclarativeMock(ApiClient)
|
|
167
|
+
api.expect("next_page").returns({"items": [1], "cursor": "a"})
|
|
168
|
+
api.expect("next_page").returns({"items": [2], "cursor": None})
|
|
169
|
+
|
|
170
|
+
# first call → first dict; second call → second dict
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### 2. Outcome sequences with `.runs()` and `.returns()`
|
|
174
|
+
|
|
175
|
+
Chained outcomes apply to **successive matching calls**, not to a single call. `.runs(fn).returns(value)` means: first call runs `fn` (its return value is the result); second call returns `value`. A quantifier (`.once()`, `.at_least(n)`, …) applies to the **whole** `expect(...)` chain, not to the last outcome. A second quantifier on the same chain raises `ConfigurationError`.
|
|
176
|
+
|
|
177
|
+
If you omit a quantifier, the count is exactly the number of chained outcomes (minimum 1). `.runs(fn)` alone is **one** call; a second match is unexpected. The last outcome repeats only when the quantifier allows more calls than outcomes - for example `.runs(fn).at_least(1)` to run the callable on every matching call.
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from dmock import DeclarativeMock
|
|
181
|
+
|
|
182
|
+
calc = DeclarativeMock(Calculator)
|
|
183
|
+
calc.expect("price", 100).runs(lambda x: x - 1).returns(99)
|
|
184
|
+
|
|
185
|
+
assert calc.price(100) == 99 # from the lambda
|
|
186
|
+
assert calc.price(100) == 99 # from .returns(99)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### 3. Strict argument matching (positional vs keyword)
|
|
190
|
+
|
|
191
|
+
`expect("method")` with **no** extra args means “called with no arguments” - not “anything.” Use matchers when you need to accept arbitrary values.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from dmock import Anything, DeclarativeMock
|
|
195
|
+
|
|
196
|
+
m = DeclarativeMock(Worker)
|
|
197
|
+
|
|
198
|
+
# any single positional arg
|
|
199
|
+
m.expect("run", Anything()).returns(0)
|
|
200
|
+
|
|
201
|
+
# keyword-specific expectation
|
|
202
|
+
m.expect("run", job_id=123).returns("queued")
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 4. Variadic arguments (`ANY_ARGS`, `ANY_KWARGS`)
|
|
206
|
+
|
|
207
|
+
To match **any** positional and keyword arguments (including none), use the dedicated wildcards instead of repeating `Anything()`.
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from dmock import ANY_ARGS, ANY_KWARGS, DeclarativeMock
|
|
211
|
+
|
|
212
|
+
m = DeclarativeMock(Worker)
|
|
213
|
+
m.expect("run", ANY_ARGS, ANY_KWARGS).returns(0)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### 5. Async methods
|
|
217
|
+
|
|
218
|
+
`expect()` on an `async def` spec method needs no extra type. Await the call as usual. `runs()` accepts a sync callable or an `async def`; both work.
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from dmock import Anything, DeclarativeMock
|
|
222
|
+
|
|
223
|
+
mock = DeclarativeMock(MyService)
|
|
224
|
+
mock.expect("aprocess_order", Anything()).returns("ok")
|
|
225
|
+
assert await mock.aprocess_order(1) == "ok"
|
|
226
|
+
|
|
227
|
+
mock.expect("aprocess_order", Anything()).runs(lambda order_id: f"sync-{order_id}")
|
|
228
|
+
assert await mock.aprocess_order(7) == "sync-7"
|
|
229
|
+
|
|
230
|
+
async def async_fn(order_id: int) -> str:
|
|
231
|
+
return f"async-{order_id}"
|
|
232
|
+
|
|
233
|
+
mock.expect("aprocess_order", Anything()).runs(async_fn)
|
|
234
|
+
assert await mock.aprocess_order(9) == "async-9"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
For full DSL details and edge cases, see **`SPEC.md`** and **`REFERENCE.md`**.
|
|
238
|
+
|
|
239
|
+
## Limitations & non-goals
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
- **No signature binding.** `expect("method", …)` checks that `method` exists on the spec, not that the argument list matches the real signature.
|
|
244
|
+
- **No automatic `verify()`.** You call `verify()` yourself (a pytest hook is on the roadmap).
|
|
245
|
+
- **No spies and no patching.** The library does not wrap live objects, modules, or `sys.modules`. Construct a `DeclarativeMock` and pass it in.
|
|
246
|
+
- **Not thread-safe.** Concurrent use of one mock from multiple threads is out of scope.
|
|
247
|
+
|
|
248
|
+
## Roadmap
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
After 0.1.0, in no particular order:
|
|
253
|
+
|
|
254
|
+
- **pytest auto-`verify()`** - a plugin or fixture that runs `verify()` at the end of each test (gomock-style `Finish` on teardown), so you do not have to remember the call.
|
|
255
|
+
- **Per-outcome counts (maybe)** - repeat a `.returns()` / `.raises()` / `.runs()` without chaining it n times (today `.returns(1).returns(1).returns(1).returns(2)`).
|
|
256
|
+
- **Rich matchers** - more than `Anything` / `AnythingOfType` / `MatchedBy`: regex, numeric bounds, datetime (and ISO strings), containers, truthy/falsy, plus `and` / `or` / `not`. In the spirit of [anys](https://github.com/jwodder/anys).
|
|
257
|
+
|
|
258
|
+
Other ideas belong in GitHub Issues.
|
|
259
|
+
|
|
260
|
+
## Development
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
poetry install
|
|
266
|
+
ruff check .
|
|
267
|
+
ruff format --check .
|
|
268
|
+
mypy src tests
|
|
269
|
+
pytest
|
|
270
|
+
pytest --cov --cov-report=term-missing
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
See **[CONTRIBUTING.md](CONTRIBUTING.md)** for setup, commits, and tests. Agents follow **[AGENTS.md](AGENTS.md)**.
|
|
274
|
+
|
|
275
|
+
## License
|
|
276
|
+
|
|
277
|
+
MIT
|
|
278
|
+
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
[](https://github.com/MDx3R/declarative-mocks/actions/workflows/ci.yml)
|
|
2
|
+
[](https://www.python.org/downloads/)
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
|
|
5
|
+
# declarative-mocks
|
|
6
|
+
|
|
7
|
+
**Declare mock behavior as ordered expectations** - readable tests with explicit call patterns, return sequences, and side effects, built on `unittest.mock`.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
- **Fluent expectations** - register calls with arguments (positional vs keyword aware), matchers (`Anything`, `ANY_ARGS` / `ANY_KWARGS`, …), and outcomes in order.
|
|
14
|
+
- **Return sequences** - chain multiple `.returns()` / `.raises()` / `.runs()` for successive matching calls.
|
|
15
|
+
- **Call-count controls** - one quantifier per `expect(...)` (`.once()`, `.times(n)`, `.between(min, max)`, `.never()`, and more).
|
|
16
|
+
- **Spec-backed mocks** - aligned with `Mock(spec=…)` semantics for safer, clearer tests.
|
|
17
|
+
- **Async without ceremony** - `expect()` on `async def` methods is the same DSL; `runs()` accepts both sync and async callables.
|
|
18
|
+
- **No extra runtime dependencies** - only the standard library at runtime.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
The first PyPI release is being prepared. Until then, install from source:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
poetry install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The intended published command:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install declarative-mocks
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## The idea: before and after
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
**Before - `unittest.mock`:** behavior is spread across `return_value`, `side_effect`, and ad hoc assertions; ordering and “what happens on the third call?” are easy to lose in a long test.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from unittest.mock import Mock
|
|
44
|
+
|
|
45
|
+
service = Mock(spec=MyService)
|
|
46
|
+
service.fetch_user.side_effect = [
|
|
47
|
+
{"id": 1, "name": "Ada"},
|
|
48
|
+
{"id": 1, "name": "Ada"},
|
|
49
|
+
ConnectionError("retry"),
|
|
50
|
+
]
|
|
51
|
+
# … exercise code …
|
|
52
|
+
# Assertions about call order and counts are often manual and verbose.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**After - `dmock`:** the same story is **declared** next to the mock: ordered expectations, explicit outcomes, and a single verification step.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from dmock import DeclarativeMock
|
|
59
|
+
|
|
60
|
+
service = DeclarativeMock(MyService)
|
|
61
|
+
service.expect("fetch_user").returns({"id": 1, "name": "Ada"}).returns({"id": 1, "name": "Ada"})
|
|
62
|
+
service.expect("fetch_user").raises(ConnectionError("retry"))
|
|
63
|
+
# … exercise code …
|
|
64
|
+
service.verify()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## How it compares
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
The goals are the same as above: a strictly typed expectation DSL, a whitelist mock you construct, call order as a graph, and async wrapping every method in AsyncMock yourself. Against [mockito-python](https://github.com/kaste/mockito-python) and [flexmock](https://github.com/flexmock/flexmock) that looks like this.
|
|
72
|
+
|
|
73
|
+
### Static analysis
|
|
74
|
+
|
|
75
|
+
That only matters if you type-check tests. Tests deserve the same quality bar as production code - maybe more, now that agents write so much of the suite.
|
|
76
|
+
|
|
77
|
+
`mockito-python` is strong on features, but the installed package has **no `py.typed` marker and no `.pyi` stubs**. Then `mypy tests` fails on `import mockito` (`missing library stubs or py.typed marker`) unless you set `ignore_missing_imports = True`. That is [issue #58](https://github.com/kaste/mockito-python/issues/58): the reporter was already ignoring the package; the maintainer called full typing “some significant work.” The ignore makes mypy succeed by treating mockito as `Any`, so `when` / `thenReturn` / `verify` themselves are not checked.
|
|
78
|
+
|
|
79
|
+
`flexmock` **is** typed (`py.typed` since 0.11.3), but it addresses methods **by string** (`should_receive("fetch_user")`), so the name is still not checked statically.
|
|
80
|
+
|
|
81
|
+
`dmock` is the only one of the three that is **strictly typed** (`py.typed`) **and** uses real attribute names on the mock (`mock.fetch_user(...)`), validated against the spec at construction/`expect()` time.
|
|
82
|
+
|
|
83
|
+
### Shorter setup and verify
|
|
84
|
+
|
|
85
|
+
With dmock you write the call once. `verify()` does not repeat it:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
mock.expect("fetch_user", 1).returns(user).once()
|
|
89
|
+
# … exercise code …
|
|
90
|
+
mock.verify()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
mockito splits **configuration and verification** into two expressions that repeat the call:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
when(service).fetch_user(1).thenReturn(user)
|
|
97
|
+
# … exercise code …
|
|
98
|
+
verify(service, times=1).fetch_user(1)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
flexmock uses a four-link chain and a string method name:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
flexmock(service).should_receive("fetch_user").with_args(1).and_return(user).once()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### A whitelist mock you construct
|
|
108
|
+
|
|
109
|
+
`DeclarativeMock(MyService)` is an object you **hold and pass in**. Unregistered calls fail immediately. Nothing on live modules or instances is rewritten.
|
|
110
|
+
|
|
111
|
+
mockito's usual `when(obj)` **does** rewrite methods on `obj` (module, class, or instance). The docs say you must `unstub()` afterwards, or use `with` / a pytest fixture, or stubs leak into later tests. `mock()` can create an injectable dummy instead, but it still sits in the same global registry.
|
|
112
|
+
|
|
113
|
+
flexmock also replaces attributes on existing objects (`should_receive`). Restore is automatic under pytest and unittest - there is no `unstub()` you call by hand.
|
|
114
|
+
|
|
115
|
+
### Call order as a dependency graph
|
|
116
|
+
|
|
117
|
+
`.not_before(...)` and `in_order(...)` are checked **at the violating call**, not after the fact at `verify()`. Dependencies may cross mock instances. Cycles are rejected at configuration time with `ConfigurationError`.
|
|
118
|
+
|
|
119
|
+
### Async without caveats
|
|
120
|
+
|
|
121
|
+
This argument holds **against flexmock**, not against mockito.
|
|
122
|
+
|
|
123
|
+
flexmock still has **no first-class async API** (no `async` / `await` / `coroutine` in the changelog or API reference). The documented workaround is to return an `AsyncMock` or `asyncio.Future` yourself.
|
|
124
|
+
|
|
125
|
+
mockito added first-class async/await stubbing in **2.0.0**. Their remaining caveat: introspection metadata such as `inspect.iscoroutinefunction` on stub wrappers is implemented **only on Python 3.12+**.
|
|
126
|
+
|
|
127
|
+
`dmock` installs a real nested `async def` dispatcher, so `inspect.iscoroutinefunction(mock.aprocess_order)` is true on **every supported Python** (3.11-3.14).
|
|
128
|
+
|
|
129
|
+
## Usage examples
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
### 1. Nested return values (successive `.returns()`)
|
|
134
|
+
|
|
135
|
+
Each `.returns()` applies to the next matching call in order - handy for paginated or multi-step flows without a hand-built `side_effect` list.
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from dmock import DeclarativeMock
|
|
139
|
+
|
|
140
|
+
api = DeclarativeMock(ApiClient)
|
|
141
|
+
api.expect("next_page").returns({"items": [1], "cursor": "a"})
|
|
142
|
+
api.expect("next_page").returns({"items": [2], "cursor": None})
|
|
143
|
+
|
|
144
|
+
# first call → first dict; second call → second dict
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### 2. Outcome sequences with `.runs()` and `.returns()`
|
|
148
|
+
|
|
149
|
+
Chained outcomes apply to **successive matching calls**, not to a single call. `.runs(fn).returns(value)` means: first call runs `fn` (its return value is the result); second call returns `value`. A quantifier (`.once()`, `.at_least(n)`, …) applies to the **whole** `expect(...)` chain, not to the last outcome. A second quantifier on the same chain raises `ConfigurationError`.
|
|
150
|
+
|
|
151
|
+
If you omit a quantifier, the count is exactly the number of chained outcomes (minimum 1). `.runs(fn)` alone is **one** call; a second match is unexpected. The last outcome repeats only when the quantifier allows more calls than outcomes - for example `.runs(fn).at_least(1)` to run the callable on every matching call.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from dmock import DeclarativeMock
|
|
155
|
+
|
|
156
|
+
calc = DeclarativeMock(Calculator)
|
|
157
|
+
calc.expect("price", 100).runs(lambda x: x - 1).returns(99)
|
|
158
|
+
|
|
159
|
+
assert calc.price(100) == 99 # from the lambda
|
|
160
|
+
assert calc.price(100) == 99 # from .returns(99)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### 3. Strict argument matching (positional vs keyword)
|
|
164
|
+
|
|
165
|
+
`expect("method")` with **no** extra args means “called with no arguments” - not “anything.” Use matchers when you need to accept arbitrary values.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from dmock import Anything, DeclarativeMock
|
|
169
|
+
|
|
170
|
+
m = DeclarativeMock(Worker)
|
|
171
|
+
|
|
172
|
+
# any single positional arg
|
|
173
|
+
m.expect("run", Anything()).returns(0)
|
|
174
|
+
|
|
175
|
+
# keyword-specific expectation
|
|
176
|
+
m.expect("run", job_id=123).returns("queued")
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### 4. Variadic arguments (`ANY_ARGS`, `ANY_KWARGS`)
|
|
180
|
+
|
|
181
|
+
To match **any** positional and keyword arguments (including none), use the dedicated wildcards instead of repeating `Anything()`.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from dmock import ANY_ARGS, ANY_KWARGS, DeclarativeMock
|
|
185
|
+
|
|
186
|
+
m = DeclarativeMock(Worker)
|
|
187
|
+
m.expect("run", ANY_ARGS, ANY_KWARGS).returns(0)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 5. Async methods
|
|
191
|
+
|
|
192
|
+
`expect()` on an `async def` spec method needs no extra type. Await the call as usual. `runs()` accepts a sync callable or an `async def`; both work.
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from dmock import Anything, DeclarativeMock
|
|
196
|
+
|
|
197
|
+
mock = DeclarativeMock(MyService)
|
|
198
|
+
mock.expect("aprocess_order", Anything()).returns("ok")
|
|
199
|
+
assert await mock.aprocess_order(1) == "ok"
|
|
200
|
+
|
|
201
|
+
mock.expect("aprocess_order", Anything()).runs(lambda order_id: f"sync-{order_id}")
|
|
202
|
+
assert await mock.aprocess_order(7) == "sync-7"
|
|
203
|
+
|
|
204
|
+
async def async_fn(order_id: int) -> str:
|
|
205
|
+
return f"async-{order_id}"
|
|
206
|
+
|
|
207
|
+
mock.expect("aprocess_order", Anything()).runs(async_fn)
|
|
208
|
+
assert await mock.aprocess_order(9) == "async-9"
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
For full DSL details and edge cases, see **`SPEC.md`** and **`REFERENCE.md`**.
|
|
212
|
+
|
|
213
|
+
## Limitations & non-goals
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
- **No signature binding.** `expect("method", …)` checks that `method` exists on the spec, not that the argument list matches the real signature.
|
|
218
|
+
- **No automatic `verify()`.** You call `verify()` yourself (a pytest hook is on the roadmap).
|
|
219
|
+
- **No spies and no patching.** The library does not wrap live objects, modules, or `sys.modules`. Construct a `DeclarativeMock` and pass it in.
|
|
220
|
+
- **Not thread-safe.** Concurrent use of one mock from multiple threads is out of scope.
|
|
221
|
+
|
|
222
|
+
## Roadmap
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
After 0.1.0, in no particular order:
|
|
227
|
+
|
|
228
|
+
- **pytest auto-`verify()`** - a plugin or fixture that runs `verify()` at the end of each test (gomock-style `Finish` on teardown), so you do not have to remember the call.
|
|
229
|
+
- **Per-outcome counts (maybe)** - repeat a `.returns()` / `.raises()` / `.runs()` without chaining it n times (today `.returns(1).returns(1).returns(1).returns(2)`).
|
|
230
|
+
- **Rich matchers** - more than `Anything` / `AnythingOfType` / `MatchedBy`: regex, numeric bounds, datetime (and ISO strings), containers, truthy/falsy, plus `and` / `or` / `not`. In the spirit of [anys](https://github.com/jwodder/anys).
|
|
231
|
+
|
|
232
|
+
Other ideas belong in GitHub Issues.
|
|
233
|
+
|
|
234
|
+
## Development
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
poetry install
|
|
240
|
+
ruff check .
|
|
241
|
+
ruff format --check .
|
|
242
|
+
mypy src tests
|
|
243
|
+
pytest
|
|
244
|
+
pytest --cov --cov-report=term-missing
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
See **[CONTRIBUTING.md](CONTRIBUTING.md)** for setup, commits, and tests. Agents follow **[AGENTS.md](AGENTS.md)**.
|
|
248
|
+
|
|
249
|
+
## License
|
|
250
|
+
|
|
251
|
+
MIT
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "declarative-mocks"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Declare mock behavior as ordered expectations. Simple, typed, gomock-inspired DSL over unittest.mock"
|
|
5
|
+
authors = [{ name = "Dimitry Churilov", email = "d.mdx3r@gmail.com" }]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
keywords = ["mock", "mocking", "testing", "unittest", "pytest"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
"Topic :: Software Development :: Testing :: Mocking",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/MDx3R/declarative-mocks"
|
|
28
|
+
Repository = "https://github.com/MDx3R/declarative-mocks"
|
|
29
|
+
Issues = "https://github.com/MDx3R/declarative-mocks/issues"
|
|
30
|
+
|
|
31
|
+
[tool.poetry]
|
|
32
|
+
packages = [{ include = "dmock", from = "src" }]
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["poetry-core>=2.2.0,<3.0.0"]
|
|
36
|
+
build-backend = "poetry.core.masonry.api"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest (>=9.0.2,<10.0.0)",
|
|
41
|
+
"pytest-asyncio (>=1.3.0,<2.0.0)",
|
|
42
|
+
"pytest-cov (>=7.1.0,<8.0.0)",
|
|
43
|
+
"pre-commit (>=4.5.1,<5.0.0)",
|
|
44
|
+
"mypy (>=1.20.0,<2.0.0)",
|
|
45
|
+
"ruff (>=0.15.9,<0.16.0)",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 88
|
|
50
|
+
target-version = "py311"
|
|
51
|
+
src = ["src/dmock"]
|
|
52
|
+
include = ["*.py", "*.pyi"]
|
|
53
|
+
exclude = ["build", "dist", ".venv", ".git", "__pycache__", "examples"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = [
|
|
57
|
+
"E",
|
|
58
|
+
"W",
|
|
59
|
+
"F",
|
|
60
|
+
"I",
|
|
61
|
+
"B",
|
|
62
|
+
"C4",
|
|
63
|
+
"D",
|
|
64
|
+
"N",
|
|
65
|
+
"UP",
|
|
66
|
+
"C90",
|
|
67
|
+
"PL",
|
|
68
|
+
"PT",
|
|
69
|
+
"RUF",
|
|
70
|
+
"SLF",
|
|
71
|
+
"SIM",
|
|
72
|
+
"TC",
|
|
73
|
+
"A",
|
|
74
|
+
"T20",
|
|
75
|
+
"PERF",
|
|
76
|
+
"PIE",
|
|
77
|
+
"FLY",
|
|
78
|
+
]
|
|
79
|
+
ignore = [
|
|
80
|
+
"RUF001",
|
|
81
|
+
"D203",
|
|
82
|
+
"D213",
|
|
83
|
+
"E501",
|
|
84
|
+
"D102",
|
|
85
|
+
"D103",
|
|
86
|
+
"D104",
|
|
87
|
+
"D105",
|
|
88
|
+
"D106",
|
|
89
|
+
"D107",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
[tool.ruff.lint.per-file-ignores]
|
|
93
|
+
"tests/**/*.py" = ["SLF001", "PLR2004", "D101"]
|
|
94
|
+
|
|
95
|
+
[tool.ruff.lint.pydocstyle]
|
|
96
|
+
convention = "google"
|
|
97
|
+
|
|
98
|
+
[tool.ruff.lint.flake8-type-checking]
|
|
99
|
+
strict = true
|
|
100
|
+
|
|
101
|
+
[tool.ruff.lint.isort]
|
|
102
|
+
combine-as-imports = true
|
|
103
|
+
known-first-party = ["dmock"]
|
|
104
|
+
lines-after-imports = 2
|
|
105
|
+
|
|
106
|
+
[tool.ruff.format]
|
|
107
|
+
quote-style = "double"
|
|
108
|
+
docstring-code-format = true
|
|
109
|
+
|
|
110
|
+
[tool.mypy]
|
|
111
|
+
mypy_path = "src"
|
|
112
|
+
explicit_package_bases = true
|
|
113
|
+
python_version = "3.11"
|
|
114
|
+
strict = true
|
|
115
|
+
disallow_any_unimported = true
|
|
116
|
+
show_error_codes = true
|
|
117
|
+
warn_unreachable = true
|
|
118
|
+
enable_error_code = ["ignore-without-code", "redundant-cast", "truthy-bool"]
|
|
119
|
+
|
|
120
|
+
[tool.pytest.ini_options]
|
|
121
|
+
pythonpath = ["src"]
|
|
122
|
+
testpaths = ["tests", "src"]
|
|
123
|
+
addopts = "-ra -q --strict-markers --strict-config --doctest-modules"
|
|
124
|
+
doctest_optionflags = "ELLIPSIS NORMALIZE_WHITESPACE"
|
|
125
|
+
asyncio_mode = "auto"
|
|
126
|
+
|
|
127
|
+
[tool.coverage.run]
|
|
128
|
+
source = ["dmock"]
|
|
129
|
+
branch = true
|
|
130
|
+
|
|
131
|
+
[tool.coverage.report]
|
|
132
|
+
show_missing = true
|
|
133
|
+
fail_under = 90
|
|
134
|
+
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
|