robotframework-mock 0.7.1__tar.gz → 0.9.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.
- {robotframework_mock-0.7.1/src/robotframework_mock.egg-info → robotframework_mock-0.9.0}/PKG-INFO +59 -2
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/README.md +58 -1
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/setup.cfg +1 -1
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/MockCoverage/__init__.py +7 -1
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/MockLibrary/__init__.py +2 -0
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/MockResource/__init__.py +113 -40
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/_mock_core/__init__.py +127 -1
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0/src/robotframework_mock.egg-info}/PKG-INFO +59 -2
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/LICENSE +0 -0
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/MANIFEST.in +0 -0
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/pyproject.toml +0 -0
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/setup.py +0 -0
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/robotframework_mock.egg-info/SOURCES.txt +0 -0
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/robotframework_mock.egg-info/dependency_links.txt +0 -0
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/robotframework_mock.egg-info/requires.txt +0 -0
- {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/robotframework_mock.egg-info/top_level.txt +0 -0
{robotframework_mock-0.7.1/src/robotframework_mock.egg-info → robotframework_mock-0.9.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: robotframework-mock
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: A Robot Framework library for mocking keywords in unit tests
|
|
5
5
|
Home-page: https://github.com/olesz/robotframework-mock
|
|
6
6
|
Author: Lajos Olah
|
|
@@ -62,6 +62,7 @@ pip install -r requirements-dev.txt
|
|
|
62
62
|
- Support for keywords with custom names via @keyword decorator
|
|
63
63
|
- Verify keyword calls and call counts
|
|
64
64
|
- Inspect and verify the arguments a mocked keyword was called with
|
|
65
|
+
- Inspect the order in which mocked keywords were called
|
|
65
66
|
- Measure resource-file keyword coverage with configurable thresholds
|
|
66
67
|
- Simple API with three main keywords
|
|
67
68
|
|
|
@@ -434,6 +435,32 @@ Return how many times a mocked keyword was called.
|
|
|
434
435
|
${count}= MockDB.Get Keyword Call Count execute_sql
|
|
435
436
|
```
|
|
436
437
|
|
|
438
|
+
### Get Keyword Call Order
|
|
439
|
+
|
|
440
|
+
Return the mocked keywords in the order they were called, as a list of names.
|
|
441
|
+
|
|
442
|
+
Covers keywords mocked through the same library instance. This is what
|
|
443
|
+
establishes ordering *between* different mocks — an individual mock's call
|
|
444
|
+
history cannot show how it interleaved with its siblings.
|
|
445
|
+
|
|
446
|
+
A keyword called more than once appears once per call. Keywords that were mocked
|
|
447
|
+
but never called do not appear, and calls made on a keyword's return value are
|
|
448
|
+
not included.
|
|
449
|
+
|
|
450
|
+
**Example:**
|
|
451
|
+
```robot
|
|
452
|
+
MockRes.Mock Keyword Init Client return_value=alias
|
|
453
|
+
MockRes.Mock Keyword Run Query return_value=${rows}
|
|
454
|
+
|
|
455
|
+
Fetch Data
|
|
456
|
+
|
|
457
|
+
${order}= MockRes.Get Keyword Call Order
|
|
458
|
+
Should Be Equal ${order} ${{ ['Init Client', 'Run Query'] }}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Use this instead of wiring a `side_effect` that appends to a list, which is the
|
|
462
|
+
only way to observe ordering otherwise.
|
|
463
|
+
|
|
437
464
|
## How It Works
|
|
438
465
|
|
|
439
466
|
### MockLibrary
|
|
@@ -444,9 +471,39 @@ MockLibrary dynamically replaces keyword implementations:
|
|
|
444
471
|
3. Stores original methods before mocking
|
|
445
472
|
4. Replaces methods with mock implementations using Python's unittest.mock.Mock
|
|
446
473
|
5. Returns mocked values or executes side effects
|
|
447
|
-
6. Tracks call counts for verification
|
|
474
|
+
6. Tracks call counts and call arguments for verification
|
|
448
475
|
7. Raises AttributeError if attempting to mock a non-existent keyword
|
|
449
476
|
|
|
477
|
+
#### Mocks are installed on the library class
|
|
478
|
+
|
|
479
|
+
Step 4 replaces the method on the target library's **class**, not on the
|
|
480
|
+
instance. Two consequences are worth knowing, because neither is reported as an
|
|
481
|
+
error:
|
|
482
|
+
|
|
483
|
+
**A mock stays active until `Reset Mocks`, across tests and suites.** The mock
|
|
484
|
+
is not scoped to the test that created it. A test that mocks a keyword and
|
|
485
|
+
leaves without resetting hands the mock to everything that follows. Reset in a
|
|
486
|
+
teardown rather than at the end of a test body, so the mock is also removed when
|
|
487
|
+
the test fails midway.
|
|
488
|
+
|
|
489
|
+
**Two `MockLibrary` instances wrapping the same library share one mock.** Last
|
|
490
|
+
registration wins, and resetting *either* instance restores the real method:
|
|
491
|
+
|
|
492
|
+
```robot
|
|
493
|
+
Library MockLibrary DateTime WITH NAME MockA
|
|
494
|
+
Library MockLibrary DateTime WITH NAME MockB
|
|
495
|
+
...
|
|
496
|
+
MockA.Mock Keyword Convert Time return_value=from-a
|
|
497
|
+
MockB.Mock Keyword Convert Time return_value=from-b
|
|
498
|
+
${result}= Convert Time 12:00:00 # from-b: MockA's mock was overwritten
|
|
499
|
+
MockB.Reset Mocks
|
|
500
|
+
${result}= Convert Time 12:00:00 # the real keyword, although MockA never reset
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Use a single alias per library, and separate aliases only for *different*
|
|
504
|
+
libraries. `MockResource` is not affected: it keys mocks per resource file, so
|
|
505
|
+
instances for different resources are independent.
|
|
506
|
+
|
|
450
507
|
### MockResource
|
|
451
508
|
|
|
452
509
|
MockResource patches Robot Framework's keyword execution:
|
|
@@ -38,6 +38,7 @@ pip install -r requirements-dev.txt
|
|
|
38
38
|
- Support for keywords with custom names via @keyword decorator
|
|
39
39
|
- Verify keyword calls and call counts
|
|
40
40
|
- Inspect and verify the arguments a mocked keyword was called with
|
|
41
|
+
- Inspect the order in which mocked keywords were called
|
|
41
42
|
- Measure resource-file keyword coverage with configurable thresholds
|
|
42
43
|
- Simple API with three main keywords
|
|
43
44
|
|
|
@@ -410,6 +411,32 @@ Return how many times a mocked keyword was called.
|
|
|
410
411
|
${count}= MockDB.Get Keyword Call Count execute_sql
|
|
411
412
|
```
|
|
412
413
|
|
|
414
|
+
### Get Keyword Call Order
|
|
415
|
+
|
|
416
|
+
Return the mocked keywords in the order they were called, as a list of names.
|
|
417
|
+
|
|
418
|
+
Covers keywords mocked through the same library instance. This is what
|
|
419
|
+
establishes ordering *between* different mocks — an individual mock's call
|
|
420
|
+
history cannot show how it interleaved with its siblings.
|
|
421
|
+
|
|
422
|
+
A keyword called more than once appears once per call. Keywords that were mocked
|
|
423
|
+
but never called do not appear, and calls made on a keyword's return value are
|
|
424
|
+
not included.
|
|
425
|
+
|
|
426
|
+
**Example:**
|
|
427
|
+
```robot
|
|
428
|
+
MockRes.Mock Keyword Init Client return_value=alias
|
|
429
|
+
MockRes.Mock Keyword Run Query return_value=${rows}
|
|
430
|
+
|
|
431
|
+
Fetch Data
|
|
432
|
+
|
|
433
|
+
${order}= MockRes.Get Keyword Call Order
|
|
434
|
+
Should Be Equal ${order} ${{ ['Init Client', 'Run Query'] }}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Use this instead of wiring a `side_effect` that appends to a list, which is the
|
|
438
|
+
only way to observe ordering otherwise.
|
|
439
|
+
|
|
413
440
|
## How It Works
|
|
414
441
|
|
|
415
442
|
### MockLibrary
|
|
@@ -420,9 +447,39 @@ MockLibrary dynamically replaces keyword implementations:
|
|
|
420
447
|
3. Stores original methods before mocking
|
|
421
448
|
4. Replaces methods with mock implementations using Python's unittest.mock.Mock
|
|
422
449
|
5. Returns mocked values or executes side effects
|
|
423
|
-
6. Tracks call counts for verification
|
|
450
|
+
6. Tracks call counts and call arguments for verification
|
|
424
451
|
7. Raises AttributeError if attempting to mock a non-existent keyword
|
|
425
452
|
|
|
453
|
+
#### Mocks are installed on the library class
|
|
454
|
+
|
|
455
|
+
Step 4 replaces the method on the target library's **class**, not on the
|
|
456
|
+
instance. Two consequences are worth knowing, because neither is reported as an
|
|
457
|
+
error:
|
|
458
|
+
|
|
459
|
+
**A mock stays active until `Reset Mocks`, across tests and suites.** The mock
|
|
460
|
+
is not scoped to the test that created it. A test that mocks a keyword and
|
|
461
|
+
leaves without resetting hands the mock to everything that follows. Reset in a
|
|
462
|
+
teardown rather than at the end of a test body, so the mock is also removed when
|
|
463
|
+
the test fails midway.
|
|
464
|
+
|
|
465
|
+
**Two `MockLibrary` instances wrapping the same library share one mock.** Last
|
|
466
|
+
registration wins, and resetting *either* instance restores the real method:
|
|
467
|
+
|
|
468
|
+
```robot
|
|
469
|
+
Library MockLibrary DateTime WITH NAME MockA
|
|
470
|
+
Library MockLibrary DateTime WITH NAME MockB
|
|
471
|
+
...
|
|
472
|
+
MockA.Mock Keyword Convert Time return_value=from-a
|
|
473
|
+
MockB.Mock Keyword Convert Time return_value=from-b
|
|
474
|
+
${result}= Convert Time 12:00:00 # from-b: MockA's mock was overwritten
|
|
475
|
+
MockB.Reset Mocks
|
|
476
|
+
${result}= Convert Time 12:00:00 # the real keyword, although MockA never reset
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Use a single alias per library, and separate aliases only for *different*
|
|
480
|
+
libraries. `MockResource` is not affected: it keys mocks per resource file, so
|
|
481
|
+
instances for different resources are independent.
|
|
482
|
+
|
|
426
483
|
### MockResource
|
|
427
484
|
|
|
428
485
|
MockResource patches Robot Framework's keyword execution:
|
|
@@ -60,6 +60,8 @@ except ModuleNotFoundError: # pragma: no cover - exercised only on <3.11
|
|
|
60
60
|
from robot.api import get_resource_model
|
|
61
61
|
from robot.api.parsing import ModelVisitor
|
|
62
62
|
|
|
63
|
+
from _mock_core import is_keyword_mocked
|
|
64
|
+
|
|
63
65
|
|
|
64
66
|
DEFAULT_CONFIG_PATH = "mock-coverage.toml"
|
|
65
67
|
DEFAULT_OUTPUT_PATH = "coverage.json"
|
|
@@ -571,6 +573,10 @@ class ExecutionTracker:
|
|
|
571
573
|
def record(self, owner: Optional[str], name: str) -> None:
|
|
572
574
|
"""Record a keyword execution if it belongs to a measured resource.
|
|
573
575
|
|
|
576
|
+
Keywords whose body is currently replaced by a ``MockResource`` mock are
|
|
577
|
+
ignored: the real body never ran, so counting them would credit the
|
|
578
|
+
resource with coverage it did not earn.
|
|
579
|
+
|
|
574
580
|
Args:
|
|
575
581
|
owner: Robot's owner name for the keyword (resource base name).
|
|
576
582
|
name: The executed keyword's name.
|
|
@@ -579,7 +585,7 @@ class ExecutionTracker:
|
|
|
579
585
|
if owner not in self._owner_names:
|
|
580
586
|
return
|
|
581
587
|
source = self._resolve_source(owner, name)
|
|
582
|
-
if source:
|
|
588
|
+
if source and not is_keyword_mocked(source, name):
|
|
583
589
|
self._executed.setdefault(source, set()).add(name)
|
|
584
590
|
|
|
585
591
|
def _resolve_source(self, owner: str, name: str) -> Optional[str]:
|
|
@@ -160,6 +160,7 @@ class MockLibrary(CallInspectionMixin):
|
|
|
160
160
|
# Create Mock object with specified behavior
|
|
161
161
|
mock = Mock(return_value=return_value, side_effect=side_effect)
|
|
162
162
|
self._mocks[method_name] = mock
|
|
163
|
+
self._track_call_order(keyword_name, mock)
|
|
163
164
|
|
|
164
165
|
# Replace the method on the class or instance
|
|
165
166
|
try:
|
|
@@ -194,6 +195,7 @@ class MockLibrary(CallInspectionMixin):
|
|
|
194
195
|
|
|
195
196
|
# Clear all tracking dictionaries
|
|
196
197
|
self._mocks.clear()
|
|
198
|
+
self._reset_call_order()
|
|
197
199
|
self._original_methods.clear()
|
|
198
200
|
|
|
199
201
|
@keyword
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Mock resource for Robot Framework keyword mocking in unit tests."""
|
|
2
2
|
# pylint: disable=invalid-name
|
|
3
|
+
import weakref
|
|
3
4
|
from typing import Any, Callable
|
|
4
5
|
from unittest.mock import Mock
|
|
5
6
|
|
|
@@ -9,7 +10,12 @@ from robot.running import Return
|
|
|
9
10
|
from robot.running.namespace import Namespace
|
|
10
11
|
from robot.utils import split_from_equals
|
|
11
12
|
|
|
12
|
-
from _mock_core import
|
|
13
|
+
from _mock_core import (
|
|
14
|
+
CallInspectionMixin,
|
|
15
|
+
normalize_keyword_name,
|
|
16
|
+
register_mocked_keyword,
|
|
17
|
+
unregister_mocked_keyword,
|
|
18
|
+
)
|
|
13
19
|
|
|
14
20
|
# Robot resolves the values of a RETURN statement as variable expressions, so a
|
|
15
21
|
# mocked return value cannot be embedded in one directly - anything that is not
|
|
@@ -18,6 +24,74 @@ from _mock_core import CallInspectionMixin
|
|
|
18
24
|
# object (a query result set, a parsed JSON body, a list of pods...).
|
|
19
25
|
RETURN_VARIABLE = '${__mock_resource_return__}'
|
|
20
26
|
|
|
27
|
+
# Namespace.get_runner is patched once per process rather than once per
|
|
28
|
+
# MockResource instance. Patching per instance chained a new wrapper around the
|
|
29
|
+
# previous one for every library import, and nothing ever restored them, so
|
|
30
|
+
# every keyword lookup in the run paid for each import. The single wrapper
|
|
31
|
+
# consults the live instances below and is inert when none of them has mocks.
|
|
32
|
+
_INSTANCES = []
|
|
33
|
+
_ORIGINAL_GET_RUNNER = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _live_instances():
|
|
37
|
+
"""Return the MockResource instances that are still alive.
|
|
38
|
+
|
|
39
|
+
Instances are held weakly so a library instance going out of scope does not
|
|
40
|
+
keep its mocks alive, and dead references are pruned on access.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The live instances, in the order they were created.
|
|
44
|
+
"""
|
|
45
|
+
live = []
|
|
46
|
+
for reference in list(_INSTANCES):
|
|
47
|
+
instance = reference()
|
|
48
|
+
if instance is None:
|
|
49
|
+
_INSTANCES.remove(reference)
|
|
50
|
+
else:
|
|
51
|
+
live.append(instance)
|
|
52
|
+
return live
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _install_shared_patch():
|
|
56
|
+
"""Patch ``Namespace.get_runner`` once, routing to all live instances."""
|
|
57
|
+
global _ORIGINAL_GET_RUNNER # pylint: disable=global-statement
|
|
58
|
+
if _ORIGINAL_GET_RUNNER is not None:
|
|
59
|
+
return
|
|
60
|
+
_ORIGINAL_GET_RUNNER = Namespace.get_runner
|
|
61
|
+
original_get_runner = _ORIGINAL_GET_RUNNER
|
|
62
|
+
|
|
63
|
+
def patched_get_runner(self, keyword_name, recommend_on_failure):
|
|
64
|
+
keyword_runner = original_get_runner(self, keyword_name, recommend_on_failure)
|
|
65
|
+
resource_file = getattr(keyword_runner.keyword, "source", None)
|
|
66
|
+
|
|
67
|
+
for instance in _live_instances():
|
|
68
|
+
if instance.source not in str(resource_file):
|
|
69
|
+
continue
|
|
70
|
+
mock = instance.mocks.get(normalize_keyword_name(keyword_name))
|
|
71
|
+
if not mock:
|
|
72
|
+
continue
|
|
73
|
+
original_run = keyword_runner.run
|
|
74
|
+
|
|
75
|
+
def patched_run(data, result, context, run,
|
|
76
|
+
_mock=mock, _original_run=original_run):
|
|
77
|
+
args, kwargs = _resolve_arguments(
|
|
78
|
+
keyword_runner.keyword, data, context
|
|
79
|
+
)
|
|
80
|
+
mock_result = _mock(*args, **kwargs)
|
|
81
|
+
# Test scope, so the value is visible to the keyword's own
|
|
82
|
+
# body even when the mocked keyword is called from inside
|
|
83
|
+
# another keyword rather than directly from the test.
|
|
84
|
+
context.variables.set_test(RETURN_VARIABLE, mock_result)
|
|
85
|
+
keyword_runner.keyword.body._items = [Return(values=[RETURN_VARIABLE])] # pylint: disable=protected-access
|
|
86
|
+
return _original_run(data, result, context, run)
|
|
87
|
+
|
|
88
|
+
keyword_runner.run = patched_run
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
return keyword_runner
|
|
92
|
+
|
|
93
|
+
Namespace.get_runner = patched_get_runner
|
|
94
|
+
|
|
21
95
|
|
|
22
96
|
def _resolve_arguments(keyword_obj, data, context):
|
|
23
97
|
"""Split a keyword call's raw arguments into resolved args and kwargs.
|
|
@@ -72,44 +146,37 @@ class MockResource(CallInspectionMixin):
|
|
|
72
146
|
|
|
73
147
|
def __init__(self, source):
|
|
74
148
|
self._source = source
|
|
75
|
-
self._original_get_runner = Namespace.get_runner
|
|
76
149
|
self._original_items = {}
|
|
77
150
|
self._original_setups = {}
|
|
78
151
|
self._original_teardowns = {}
|
|
152
|
+
self._mocked_sources = {}
|
|
79
153
|
self._mocks = {}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
keyword_runner.keyword.body._items = [Return(values=[RETURN_VARIABLE])] # pylint: disable=protected-access
|
|
107
|
-
return original_run(data, result, context, run)
|
|
108
|
-
keyword_runner.run = patched_run
|
|
109
|
-
|
|
110
|
-
return keyword_runner
|
|
111
|
-
|
|
112
|
-
Namespace.get_runner = patched_get_runner
|
|
154
|
+
_INSTANCES.append(weakref.ref(self))
|
|
155
|
+
_install_shared_patch()
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def source(self):
|
|
159
|
+
"""Return the resource file this instance mocks keywords from."""
|
|
160
|
+
return self._source
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def mocks(self):
|
|
164
|
+
"""Return the active mocks, keyed by normalized keyword name."""
|
|
165
|
+
return self._mocks
|
|
166
|
+
|
|
167
|
+
def _mock_key(self, keyword_name: str) -> str:
|
|
168
|
+
"""Return the ``self._mocks`` key for *keyword_name*.
|
|
169
|
+
|
|
170
|
+
Mocks are keyed the way Robot Framework matches keyword names, so
|
|
171
|
+
inspecting a call works whatever case or spacing the test used.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
keyword_name: Keyword name as written in the test.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The normalized name used as the mock key.
|
|
178
|
+
"""
|
|
179
|
+
return normalize_keyword_name(keyword_name)
|
|
113
180
|
|
|
114
181
|
@keyword
|
|
115
182
|
def mock_keyword(
|
|
@@ -155,6 +222,7 @@ class MockResource(CallInspectionMixin):
|
|
|
155
222
|
)
|
|
156
223
|
|
|
157
224
|
self._original_items[keyword_name] = keyword_runner.keyword.body._items # pylint: disable=protected-access
|
|
225
|
+
self._mocked_sources[keyword_name] = resource_file
|
|
158
226
|
if skip_setup:
|
|
159
227
|
self._original_setups[keyword_name] = keyword_runner.keyword.setup
|
|
160
228
|
keyword_runner.keyword.setup = None
|
|
@@ -162,7 +230,11 @@ class MockResource(CallInspectionMixin):
|
|
|
162
230
|
self._original_teardowns[keyword_name] = keyword_runner.keyword.teardown
|
|
163
231
|
keyword_runner.keyword.teardown = None
|
|
164
232
|
mock = Mock(return_value=return_value, side_effect=side_effect)
|
|
165
|
-
self._mocks[keyword_name] = mock
|
|
233
|
+
self._mocks[normalize_keyword_name(keyword_name)] = mock
|
|
234
|
+
self._track_call_order(keyword_name, mock)
|
|
235
|
+
# Tell MockCoverage this keyword's body no longer runs, so it is not
|
|
236
|
+
# credited with coverage it did not earn.
|
|
237
|
+
register_mocked_keyword(resource_file, keyword_name)
|
|
166
238
|
return mock
|
|
167
239
|
|
|
168
240
|
@keyword
|
|
@@ -176,6 +248,9 @@ class MockResource(CallInspectionMixin):
|
|
|
176
248
|
| MockRes.Reset Mocks |
|
|
177
249
|
"""
|
|
178
250
|
self._mocks.clear()
|
|
251
|
+
self._reset_call_order()
|
|
252
|
+
for keyword_name, source in self._mocked_sources.items():
|
|
253
|
+
unregister_mocked_keyword(source, keyword_name)
|
|
179
254
|
for keyword_name, items in self._original_items.items():
|
|
180
255
|
keyword_runner = BuiltIn()._namespace.get_runner(keyword_name, True) # pylint: disable=protected-access
|
|
181
256
|
keyword_runner.keyword.body._items = items # pylint: disable=protected-access
|
|
@@ -188,6 +263,7 @@ class MockResource(CallInspectionMixin):
|
|
|
188
263
|
self._original_items.clear()
|
|
189
264
|
self._original_setups.clear()
|
|
190
265
|
self._original_teardowns.clear()
|
|
266
|
+
self._mocked_sources.clear()
|
|
191
267
|
|
|
192
268
|
@keyword
|
|
193
269
|
def verify_keyword_called(self, keyword_name: str, times: int = None):
|
|
@@ -203,9 +279,6 @@ class MockResource(CallInspectionMixin):
|
|
|
203
279
|
Example:
|
|
204
280
|
| MockDB.Verify Keyword Called | Execute Sql | times=1 |
|
|
205
281
|
"""
|
|
206
|
-
|
|
207
|
-
raise AssertionError(f"Keyword '{keyword_name}' was not mocked")
|
|
208
|
-
|
|
209
|
-
mock = self._mocks[keyword_name]
|
|
282
|
+
mock = self._get_mock(keyword_name)
|
|
210
283
|
if times is not None and mock.call_count != times:
|
|
211
284
|
raise AssertionError(f"Expected {times} calls, got {mock.call_count}")
|
|
@@ -8,10 +8,75 @@ implemented once here and mixed into both libraries, so the two cannot drift
|
|
|
8
8
|
apart.
|
|
9
9
|
"""
|
|
10
10
|
# pylint: disable=invalid-name
|
|
11
|
+
import os
|
|
11
12
|
from typing import Any, Dict, List
|
|
12
|
-
from unittest.mock import call
|
|
13
|
+
from unittest.mock import call, Mock
|
|
13
14
|
|
|
14
15
|
from robot.api.deco import keyword
|
|
16
|
+
from robot.utils import normalize
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_keyword_name(name) -> str:
|
|
20
|
+
"""Return a keyword name in the form Robot Framework matches on.
|
|
21
|
+
|
|
22
|
+
Robot treats keyword names as case-, space- and underscore-insensitive, so
|
|
23
|
+
mocks have to be keyed the same way. Without this, mocking ``Fetch Rows``
|
|
24
|
+
and calling ``fetch rows`` would silently run the real keyword.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
name: Keyword name as written anywhere.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The canonical comparison key for that name.
|
|
31
|
+
"""
|
|
32
|
+
return normalize(str(name), ignore='_')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Keywords whose body is currently replaced by a mock, as
|
|
36
|
+
# ``(absolute source path, normalized keyword name)`` pairs.
|
|
37
|
+
#
|
|
38
|
+
# MockCoverage consults this so a keyword that was only ever mocked is not
|
|
39
|
+
# counted as covered: its real body never ran, and counting it would inflate the
|
|
40
|
+
# reported coverage of the resource it lives in.
|
|
41
|
+
MOCKED_KEYWORDS = set()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def mocked_keyword_key(source, keyword_name) -> tuple:
|
|
45
|
+
"""Build the registry key for a keyword in a resource file.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
source: Path of the resource file defining the keyword.
|
|
49
|
+
keyword_name: Keyword name as written.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The ``(source key, normalized name)`` tuple used by the registry.
|
|
53
|
+
"""
|
|
54
|
+
return (os.path.normcase(os.path.abspath(str(source))),
|
|
55
|
+
normalize_keyword_name(keyword_name))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def register_mocked_keyword(source, keyword_name) -> None:
|
|
59
|
+
"""Record that a keyword's body is replaced by a mock."""
|
|
60
|
+
MOCKED_KEYWORDS.add(mocked_keyword_key(source, keyword_name))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def unregister_mocked_keyword(source, keyword_name) -> None:
|
|
64
|
+
"""Record that a keyword's body is restored."""
|
|
65
|
+
MOCKED_KEYWORDS.discard(mocked_keyword_key(source, keyword_name))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_keyword_mocked(source_key: str, keyword_name) -> bool:
|
|
69
|
+
"""Return whether a keyword is currently mocked.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
source_key: Source path already normalised with ``os.path.normcase`` and
|
|
73
|
+
``os.path.abspath``, as :func:`mocked_keyword_key` produces.
|
|
74
|
+
keyword_name: Keyword name as reported at execution time.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
``True`` when the keyword's body is currently a mock.
|
|
78
|
+
"""
|
|
79
|
+
return (source_key, normalize_keyword_name(keyword_name)) in MOCKED_KEYWORDS
|
|
15
80
|
|
|
16
81
|
|
|
17
82
|
class CallInspectionMixin:
|
|
@@ -22,10 +87,37 @@ class CallInspectionMixin:
|
|
|
22
87
|
- ``self._mocks``: mapping of mock key -> :class:`unittest.mock.Mock`
|
|
23
88
|
- ``self._mock_key(keyword_name)``: the ``self._mocks`` key for a keyword
|
|
24
89
|
name, in case the library does not key by the name as written
|
|
90
|
+
- ``self._call_manager`` / ``self._display_names``: created on first use by
|
|
91
|
+
:meth:`_track_call_order`, which the host calls for each new mock
|
|
25
92
|
"""
|
|
26
93
|
|
|
27
94
|
_mocks: Dict[str, Any]
|
|
28
95
|
|
|
96
|
+
def _track_call_order(self, keyword_name: str, mock) -> None:
|
|
97
|
+
"""Attach a mock so its calls are ordered against its siblings.
|
|
98
|
+
|
|
99
|
+
``unittest.mock`` records calls to attached children on the parent in
|
|
100
|
+
invocation order, which is the only way to establish the relative order
|
|
101
|
+
of calls to *different* mocks - each mock's own ``call_args_list`` knows
|
|
102
|
+
nothing about the others.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
keyword_name: Keyword name as written when mocking.
|
|
106
|
+
mock: The mock backing that keyword.
|
|
107
|
+
"""
|
|
108
|
+
if not hasattr(self, '_call_manager'):
|
|
109
|
+
self._call_manager = Mock()
|
|
110
|
+
self._display_names = {}
|
|
111
|
+
key = normalize_keyword_name(keyword_name)
|
|
112
|
+
self._display_names[key] = keyword_name
|
|
113
|
+
self._call_manager.attach_mock(mock, key)
|
|
114
|
+
|
|
115
|
+
def _reset_call_order(self) -> None:
|
|
116
|
+
"""Forget the recorded call order, called when mocks are reset."""
|
|
117
|
+
if hasattr(self, '_call_manager'):
|
|
118
|
+
del self._call_manager
|
|
119
|
+
del self._display_names
|
|
120
|
+
|
|
29
121
|
def _mock_key(self, keyword_name: str) -> str:
|
|
30
122
|
"""Return the ``self._mocks`` key for *keyword_name*.
|
|
31
123
|
|
|
@@ -79,6 +171,40 @@ class CallInspectionMixin:
|
|
|
79
171
|
)
|
|
80
172
|
return calls[index]
|
|
81
173
|
|
|
174
|
+
@keyword
|
|
175
|
+
def get_keyword_call_order(self) -> List[str]:
|
|
176
|
+
"""Return the mocked keywords in the order they were called.
|
|
177
|
+
|
|
178
|
+
Covers keywords mocked through *this* library instance, which is what
|
|
179
|
+
establishes ordering between different mocks - an individual mock's
|
|
180
|
+
call history cannot show how it interleaved with its siblings.
|
|
181
|
+
|
|
182
|
+
A keyword called more than once appears once per call. Keywords mocked
|
|
183
|
+
but never called do not appear at all.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Keyword names, as written when mocking, in invocation order.
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
| MockRes.Mock Keyword | Init Client | return_value=alias |
|
|
190
|
+
| MockRes.Mock Keyword | Run Query | return_value=${rows} |
|
|
191
|
+
| Fetch Data |
|
|
192
|
+
| ${order}= | MockRes.Get Keyword Call Order |
|
|
193
|
+
| Should Be Equal | ${order} | ${{ ['Init Client', 'Run Query'] }} |
|
|
194
|
+
"""
|
|
195
|
+
if not hasattr(self, '_call_manager'):
|
|
196
|
+
return []
|
|
197
|
+
order = []
|
|
198
|
+
for recorded in self._call_manager.mock_calls:
|
|
199
|
+
name = recorded[0]
|
|
200
|
+
# Skip nested calls such as "run_query().json()", which belong to
|
|
201
|
+
# the returned object rather than to a mocked keyword.
|
|
202
|
+
if not name or '.' in name:
|
|
203
|
+
continue
|
|
204
|
+
if name in self._display_names:
|
|
205
|
+
order.append(self._display_names[name])
|
|
206
|
+
return order
|
|
207
|
+
|
|
82
208
|
@keyword
|
|
83
209
|
def get_keyword_call_count(self, keyword_name: str) -> int:
|
|
84
210
|
"""Return how many times a mocked keyword was called.
|
{robotframework_mock-0.7.1 → robotframework_mock-0.9.0/src/robotframework_mock.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: robotframework-mock
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: A Robot Framework library for mocking keywords in unit tests
|
|
5
5
|
Home-page: https://github.com/olesz/robotframework-mock
|
|
6
6
|
Author: Lajos Olah
|
|
@@ -62,6 +62,7 @@ pip install -r requirements-dev.txt
|
|
|
62
62
|
- Support for keywords with custom names via @keyword decorator
|
|
63
63
|
- Verify keyword calls and call counts
|
|
64
64
|
- Inspect and verify the arguments a mocked keyword was called with
|
|
65
|
+
- Inspect the order in which mocked keywords were called
|
|
65
66
|
- Measure resource-file keyword coverage with configurable thresholds
|
|
66
67
|
- Simple API with three main keywords
|
|
67
68
|
|
|
@@ -434,6 +435,32 @@ Return how many times a mocked keyword was called.
|
|
|
434
435
|
${count}= MockDB.Get Keyword Call Count execute_sql
|
|
435
436
|
```
|
|
436
437
|
|
|
438
|
+
### Get Keyword Call Order
|
|
439
|
+
|
|
440
|
+
Return the mocked keywords in the order they were called, as a list of names.
|
|
441
|
+
|
|
442
|
+
Covers keywords mocked through the same library instance. This is what
|
|
443
|
+
establishes ordering *between* different mocks — an individual mock's call
|
|
444
|
+
history cannot show how it interleaved with its siblings.
|
|
445
|
+
|
|
446
|
+
A keyword called more than once appears once per call. Keywords that were mocked
|
|
447
|
+
but never called do not appear, and calls made on a keyword's return value are
|
|
448
|
+
not included.
|
|
449
|
+
|
|
450
|
+
**Example:**
|
|
451
|
+
```robot
|
|
452
|
+
MockRes.Mock Keyword Init Client return_value=alias
|
|
453
|
+
MockRes.Mock Keyword Run Query return_value=${rows}
|
|
454
|
+
|
|
455
|
+
Fetch Data
|
|
456
|
+
|
|
457
|
+
${order}= MockRes.Get Keyword Call Order
|
|
458
|
+
Should Be Equal ${order} ${{ ['Init Client', 'Run Query'] }}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Use this instead of wiring a `side_effect` that appends to a list, which is the
|
|
462
|
+
only way to observe ordering otherwise.
|
|
463
|
+
|
|
437
464
|
## How It Works
|
|
438
465
|
|
|
439
466
|
### MockLibrary
|
|
@@ -444,9 +471,39 @@ MockLibrary dynamically replaces keyword implementations:
|
|
|
444
471
|
3. Stores original methods before mocking
|
|
445
472
|
4. Replaces methods with mock implementations using Python's unittest.mock.Mock
|
|
446
473
|
5. Returns mocked values or executes side effects
|
|
447
|
-
6. Tracks call counts for verification
|
|
474
|
+
6. Tracks call counts and call arguments for verification
|
|
448
475
|
7. Raises AttributeError if attempting to mock a non-existent keyword
|
|
449
476
|
|
|
477
|
+
#### Mocks are installed on the library class
|
|
478
|
+
|
|
479
|
+
Step 4 replaces the method on the target library's **class**, not on the
|
|
480
|
+
instance. Two consequences are worth knowing, because neither is reported as an
|
|
481
|
+
error:
|
|
482
|
+
|
|
483
|
+
**A mock stays active until `Reset Mocks`, across tests and suites.** The mock
|
|
484
|
+
is not scoped to the test that created it. A test that mocks a keyword and
|
|
485
|
+
leaves without resetting hands the mock to everything that follows. Reset in a
|
|
486
|
+
teardown rather than at the end of a test body, so the mock is also removed when
|
|
487
|
+
the test fails midway.
|
|
488
|
+
|
|
489
|
+
**Two `MockLibrary` instances wrapping the same library share one mock.** Last
|
|
490
|
+
registration wins, and resetting *either* instance restores the real method:
|
|
491
|
+
|
|
492
|
+
```robot
|
|
493
|
+
Library MockLibrary DateTime WITH NAME MockA
|
|
494
|
+
Library MockLibrary DateTime WITH NAME MockB
|
|
495
|
+
...
|
|
496
|
+
MockA.Mock Keyword Convert Time return_value=from-a
|
|
497
|
+
MockB.Mock Keyword Convert Time return_value=from-b
|
|
498
|
+
${result}= Convert Time 12:00:00 # from-b: MockA's mock was overwritten
|
|
499
|
+
MockB.Reset Mocks
|
|
500
|
+
${result}= Convert Time 12:00:00 # the real keyword, although MockA never reset
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Use a single alias per library, and separate aliases only for *different*
|
|
504
|
+
libraries. `MockResource` is not affected: it keys mocks per resource file, so
|
|
505
|
+
instances for different resources are independent.
|
|
506
|
+
|
|
450
507
|
### MockResource
|
|
451
508
|
|
|
452
509
|
MockResource patches Robot Framework's keyword execution:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/robotframework_mock.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|