Skip to content

Commit 604d516

Browse files
committed
Mocking, patching and README with tests.
1 parent e488e56 commit 604d516

File tree

8 files changed

+539
-122
lines changed

8 files changed

+539
-122
lines changed

README.md

Lines changed: 241 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,243 @@
1-
# uMock (MicroMock)
1+
# uMock (MicroMock) 🔬🥸
22

33
MicroMock is a very simple and small module for mocking objects in MicroPython
4-
with PyScript.
4+
with PyScript.
5+
6+
It currently only implements relatively naive versions of the following
7+
features inspired by the `unittest.mock` module in the CPython standard
8+
library.
9+
10+
* A simplified `Mock` class to replace synchronous objects in Python.
11+
* A simplified `AsyncMock` class to replace `await`-able objects in Python.
12+
* A `patch` decorator / context manager to replace a target for the lifetime
13+
of the decorated function / context manager.
14+
15+
## Usage
16+
17+
**This module is for use with MicroPython within PyScript.**
18+
19+
### Setup
20+
21+
1. Ensure the `umock.py` file is in your Python path. You may need to copy this
22+
over using the
23+
[files settings](https://docs.pyscript.net/2024.7.1/user-guide/configuration/#files).
24+
You should probably ensure use of [uPyTest](https://github.com/ntoll/upytest)
25+
by copying over the `upytest.py` file into your Python path.
26+
(See the `config.json` file in this repository for an example of this in
27+
action.)
28+
2. Create your tests that use mocks / patching, as described below.
29+
3. Ensure you have your tests setup properly as per the instruction in the
30+
[upytest README](https://github.com/ntoll/upytest?tab=readme-ov-file#setup).
31+
4. In your `index.html` make sure you use the `async` and `terminal` attributes
32+
referencing your MicroPython script (as in the `index.html` file in this
33+
repository):
34+
35+
```
36+
<script type="mpy" src="./main.py" config="./config.json" terminal async></script>
37+
```
38+
39+
Now point your browser at the `index.html` and you should see the test suite,
40+
including your mocks and patches, run.
41+
42+
### Mocking
43+
44+
In code, a mock is simply something that imitates something else. Furthermore,
45+
mock objects often record their interactions with other aspects of the code so
46+
you're able to observe and "spy" on the behaviour of your code, and perhaps
47+
even check expected behaviours are occuring.
48+
49+
Such objects are used in test situations when necessary objects are perhaps
50+
very complicated to set up, or you only wish to test isolated code in a highly
51+
constrained context without having to configure a complicated test setting.
52+
53+
For example, you may wish to mock away a database connection so the mock
54+
emulates a real database connection without the need for an expensive or
55+
complicated to configure database. All other aspects of the code under test
56+
remain the same.
57+
58+
However, when using mocks, there is a danger you may mock away the universe and
59+
the resulting context in which your test code is run doesn't accurately bear
60+
any resemblance to the real world.
61+
62+
With this context in mind, the `Mock` class provided by uMock is inspired by
63+
(but not the same as) Python's own unittest.mock.Mock class.
64+
65+
The main differences between this `Mock` class and Python's `unittest.mock.Mock`
66+
class include:
67+
68+
* Instantiation of the object only allows use of the `spec`, `side_effect` and
69+
`return_value` keyword arguments (no `name`, `spec_set`, `wraps` or `unsafe`
70+
arguments). However, arbitrary keyword arguments can be passed to become
71+
attributes on the resulting mock object.
72+
* Calls are recorded in a list of tuples in the form `(args, kwargs)` rather
73+
than a list of special `Call` instance objects.
74+
* Mock objects do NOT record nor reveal call information relating to thier
75+
child mock objects (i.e. calls are not propagated to parent mocks).
76+
* None of the following methods exist in this implementation:
77+
`mock_add_spec`, `attach_mock`, `configure_mock`, `_get_child_mock`,
78+
`method_calls`, `mock_calls`.
79+
80+
The `Mock` class takes several optional arguments that specify the behaviour of
81+
the Mock object:
82+
83+
* `spec`: This can be either a list of strings or an existing object (a
84+
class or instance) that acts as the specification for the mock
85+
object. If you pass in an object then a list of strings is formed by
86+
calling dir on the object (excluding unsupported magic attributes and
87+
methods). Accessing any attribute not in this list will raise an
88+
`AttributeError`.
89+
90+
If `spec` is an object (rather than a list of strings) then `__class__`
91+
returns the class of the `spec` object. This allows mocks to pass
92+
`isinstance()` tests.
93+
* `side_effect`: A function to be called whenever the Mock is called.
94+
Useful for raising exceptions or dynamically changing return values.
95+
The function is called with the same arguments as the mock, and the
96+
return value of this function is used as the mock's return value.
97+
98+
Alternatively `side_effect` can be an exception class or instance. In
99+
this case the exception will be raised when the mock is called.
100+
101+
If `side_effect` is an iterable then each call to the mock will return
102+
the next value from the iterable.
103+
104+
A `side_effect` can be cleared by setting it to `None`.
105+
* `return_value`: The value returned when the mock is called. By default
106+
this is a new Mock (created on first access).
107+
108+
The resulting mock object has the following properties:
109+
110+
* `call_count`: the number of calls made to the mock object.
111+
* `called`: `True` if the mock object was called at least once.
112+
* `call_args`: the arguments of the last call to the mock object.
113+
* `call_args_list`: a list of the arguments of each call to the mock object.
114+
115+
The mock object also has the following methods:
116+
117+
* `reset_mock()`: reset the mock object to a clean state. This is useful for when
118+
you want to reuse a mock object.
119+
* `assert_called()`: assert that the mock object was called at least once.
120+
* `assert_called_once()`: assert that the mock object was called once.
121+
* `assert_called_with(*args, **kwargs)`: assert that the mock object was last
122+
called in a particular way.
123+
* `assert_called_once_with(*args, **kwargs)`: assert that the mock object was
124+
called once with the given arguments.
125+
* `assert_any_call(*args, **kwargs)`: assert that the mock object was called at
126+
least once with the given arguments.
127+
* `assert_has_calls(calls, any_order=False)`: assert the mock has been called
128+
with the specified `calls`. If any_order is `False` then the calls must be
129+
sequential. If any_order is `False`` then the calls can be in any order, but
130+
they must all appear in mock_calls.
131+
* `assert_never_called()`: assert that the mock object was never called.
132+
133+
As a result, given a mock object it is possible to call it, have it behave in
134+
a specified manner, and interrogate it about how it has been used:
135+
136+
```python
137+
from umock import Mock
138+
139+
140+
m = Mock(return_value=42)
141+
142+
meaning_of_life = m()
143+
144+
assert meaning_of_life == 42, "Meaning of life is not H2G2 compliant."
145+
m.assert_called_once()
146+
```
147+
148+
### Patching
149+
150+
The `patch` class acts as a function decorator or a context manager. Inside the
151+
body of the function or `with` statement, the target is patched with a new
152+
object. When the function/with statement exits the patch is undone.
153+
154+
The `patch` must always have a target argument that identifies the Python
155+
object to replace. This string much be of the form:
156+
157+
`"module.submodule:object_name.method_name"`
158+
159+
(Note the colon ":"!)
160+
161+
If no `new` object is provided as the optional second argument, then a new Mock
162+
object is created with the supplied `kwargs`.
163+
164+
If the `patch` class is being used as a decorator for a function, it will pass
165+
in the resulting Mock object as the function's argument.
166+
167+
```python
168+
from umock import patch
169+
170+
171+
@patch("tests.a_package.a_module:a_function", return_value=42)
172+
def test(mock_object):
173+
from tests.a_package.a_module import a_function
174+
175+
assert mock_object is a_function, "Wrong object patched."
176+
assert (
177+
a_function(1, 2) == 42
178+
), "Wrong return value from patched object."
179+
```
180+
181+
Alternatively, if the `patch` class can used as a context manager.
182+
183+
```python
184+
from umock import patch, Mock
185+
186+
187+
mock_function = Mock(return_value=42)
188+
189+
with patch("tests.a_package.a_module:a_function", mock_function) as mock_object:
190+
assert mock_object is mock_function, "Wrong replacement object."
191+
from tests.a_package.a_module import a_function
192+
193+
assert (
194+
a_function(1, 2) == 42
195+
), "Wrong return value from patched object."
196+
197+
mock_function.assert_called_once_with(1, 2)
198+
```
199+
200+
## Developer setup
201+
202+
This is easy:
203+
204+
1. Clone the project.
205+
2. Start a local web server: `python -m http.server`
206+
3. Point your browser at http://localhost:8000/
207+
4. Change code and refresh your browser to check your changes.
208+
5. **DO NOT CREATE A NEW FEATURE WITHOUT FIRST CREATING AN ISSUE FOR IT IN WHICH
209+
YOU PROPOSE YOUR CHANGE**. (We want to avoid a situation where you work hard
210+
on something that is ultimately rejected by the maintainers.)
211+
6. Given all the above, pull requests are welcome and greatly appreciated.
212+
213+
We expect all contributors to abide by the spirit of our
214+
[code of conduct](./CODE_OF_CONDUCT.md).
215+
216+
## Testing uMock
217+
218+
See the content of the `tests` directory in this repository. To run the test
219+
suite, just follow steps 1, 2 and 3 in the developer setup section.
220+
221+
We use the [uPyTest](https://github.com/ntoll/upytest) to run our test suite.
222+
223+
## License
224+
225+
Copyright (c) 2024 Nicholas H.Tollervey
226+
227+
Permission is hereby granted, free of charge, to any person obtaining a copy of
228+
this software and associated documentation files (the "Software"), to deal in
229+
the Software without restriction, including without limitation the rights to
230+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
231+
of the Software, and to permit persons to whom the Software is furnished to do
232+
so, subject to the following conditions:
233+
234+
The above copyright notice and this permission notice shall be included in all
235+
copies or substantial portions of the Software.
236+
237+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
238+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
239+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
240+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
241+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
242+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
243+
SOFTWARE.

config.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
"./tests/test_asyncmock.py": "tests/test_asyncmock.py",
77
"./tests/test_mock.py": "tests/test_mock.py",
88
"./tests/test_patch.py": "tests/test_patch.py",
9-
"./tests/test_resolve_target.py": "tests/test_resolve_target.py",
9+
"./tests/test_patch_target.py": "tests/test_patch_target.py",
1010
"./tests/a_package/__init__.py": "tests/a_package/__init__.py",
11-
"./tests/a_package/a_module.py": "tests/a_package/a_module.py"
11+
"./tests/a_package/a_module.py": "tests/a_package/a_module.py",
12+
"./tests/a_package/another_module.py": "tests/a_package/another_module.py"
1213
}
1314
}

tests/a_package/a_module.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,7 @@ def a_function(a, b):
2525

2626

2727
def another_function(a, b):
28-
return random.randint(a, b)
28+
return a_function(a, b)
29+
30+
31+
an_object = AClass(1)

tests/a_package/another_module.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""
2+
Another dummy module for testing purposes.
3+
"""
4+
5+
6+
def do_something():
7+
"""
8+
Does something.
9+
"""
10+
return "Done"

0 commit comments

Comments
 (0)