robotframework-mock 0.7.0__tar.gz → 0.8.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.
Files changed (16) hide show
  1. {robotframework_mock-0.7.0/src/robotframework_mock.egg-info → robotframework_mock-0.8.0}/PKG-INFO +32 -2
  2. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/README.md +31 -1
  3. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/setup.cfg +1 -1
  4. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/src/MockCoverage/__init__.py +7 -1
  5. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/src/MockResource/__init__.py +114 -41
  6. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/src/_mock_core/__init__.py +65 -0
  7. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0/src/robotframework_mock.egg-info}/PKG-INFO +32 -2
  8. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/LICENSE +0 -0
  9. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/MANIFEST.in +0 -0
  10. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/pyproject.toml +0 -0
  11. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/setup.py +0 -0
  12. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/src/MockLibrary/__init__.py +0 -0
  13. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/src/robotframework_mock.egg-info/SOURCES.txt +0 -0
  14. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/src/robotframework_mock.egg-info/dependency_links.txt +0 -0
  15. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/src/robotframework_mock.egg-info/requires.txt +0 -0
  16. {robotframework_mock-0.7.0 → robotframework_mock-0.8.0}/src/robotframework_mock.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotframework-mock
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -444,9 +444,39 @@ MockLibrary dynamically replaces keyword implementations:
444
444
  3. Stores original methods before mocking
445
445
  4. Replaces methods with mock implementations using Python's unittest.mock.Mock
446
446
  5. Returns mocked values or executes side effects
447
- 6. Tracks call counts for verification
447
+ 6. Tracks call counts and call arguments for verification
448
448
  7. Raises AttributeError if attempting to mock a non-existent keyword
449
449
 
450
+ #### Mocks are installed on the library class
451
+
452
+ Step 4 replaces the method on the target library's **class**, not on the
453
+ instance. Two consequences are worth knowing, because neither is reported as an
454
+ error:
455
+
456
+ **A mock stays active until `Reset Mocks`, across tests and suites.** The mock
457
+ is not scoped to the test that created it. A test that mocks a keyword and
458
+ leaves without resetting hands the mock to everything that follows. Reset in a
459
+ teardown rather than at the end of a test body, so the mock is also removed when
460
+ the test fails midway.
461
+
462
+ **Two `MockLibrary` instances wrapping the same library share one mock.** Last
463
+ registration wins, and resetting *either* instance restores the real method:
464
+
465
+ ```robot
466
+ Library MockLibrary DateTime WITH NAME MockA
467
+ Library MockLibrary DateTime WITH NAME MockB
468
+ ...
469
+ MockA.Mock Keyword Convert Time return_value=from-a
470
+ MockB.Mock Keyword Convert Time return_value=from-b
471
+ ${result}= Convert Time 12:00:00 # from-b: MockA's mock was overwritten
472
+ MockB.Reset Mocks
473
+ ${result}= Convert Time 12:00:00 # the real keyword, although MockA never reset
474
+ ```
475
+
476
+ Use a single alias per library, and separate aliases only for *different*
477
+ libraries. `MockResource` is not affected: it keys mocks per resource file, so
478
+ instances for different resources are independent.
479
+
450
480
  ### MockResource
451
481
 
452
482
  MockResource patches Robot Framework's keyword execution:
@@ -420,9 +420,39 @@ MockLibrary dynamically replaces keyword implementations:
420
420
  3. Stores original methods before mocking
421
421
  4. Replaces methods with mock implementations using Python's unittest.mock.Mock
422
422
  5. Returns mocked values or executes side effects
423
- 6. Tracks call counts for verification
423
+ 6. Tracks call counts and call arguments for verification
424
424
  7. Raises AttributeError if attempting to mock a non-existent keyword
425
425
 
426
+ #### Mocks are installed on the library class
427
+
428
+ Step 4 replaces the method on the target library's **class**, not on the
429
+ instance. Two consequences are worth knowing, because neither is reported as an
430
+ error:
431
+
432
+ **A mock stays active until `Reset Mocks`, across tests and suites.** The mock
433
+ is not scoped to the test that created it. A test that mocks a keyword and
434
+ leaves without resetting hands the mock to everything that follows. Reset in a
435
+ teardown rather than at the end of a test body, so the mock is also removed when
436
+ the test fails midway.
437
+
438
+ **Two `MockLibrary` instances wrapping the same library share one mock.** Last
439
+ registration wins, and resetting *either* instance restores the real method:
440
+
441
+ ```robot
442
+ Library MockLibrary DateTime WITH NAME MockA
443
+ Library MockLibrary DateTime WITH NAME MockB
444
+ ...
445
+ MockA.Mock Keyword Convert Time return_value=from-a
446
+ MockB.Mock Keyword Convert Time return_value=from-b
447
+ ${result}= Convert Time 12:00:00 # from-b: MockA's mock was overwritten
448
+ MockB.Reset Mocks
449
+ ${result}= Convert Time 12:00:00 # the real keyword, although MockA never reset
450
+ ```
451
+
452
+ Use a single alias per library, and separate aliases only for *different*
453
+ libraries. `MockResource` is not affected: it keys mocks per resource file, so
454
+ instances for different resources are independent.
455
+
426
456
  ### MockResource
427
457
 
428
458
  MockResource patches Robot Framework's keyword execution:
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = robotframework-mock
3
- version = 0.7.0
3
+ version = 0.8.0
4
4
  author = Lajos Olah
5
5
  author_email = lajos.olah.jr@gmail.com
6
6
  description = A Robot Framework library for mocking keywords in unit tests
@@ -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]:
@@ -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 CallInspectionMixin
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
- self._install_patch()
81
-
82
- def _install_patch(self):
83
- original_get_runner = self._original_get_runner
84
- active_mocks = self._mocks
85
- source = self._source
86
-
87
- def patched_get_runner(self, keyword_name, recommend_on_failure):
88
- keyword_runner = original_get_runner(self, keyword_name, recommend_on_failure)
89
- resource_file = getattr(keyword_runner.keyword, "source", None)
90
-
91
- if source not in str(resource_file):
92
- return keyword_runner
93
-
94
- mock = active_mocks.get(keyword_name)
95
- if mock:
96
- original_run = keyword_runner.run
97
- def patched_run(data, result, context, run):
98
- args, kwargs = _resolve_arguments(
99
- keyword_runner.keyword, data, context
100
- )
101
- mock_result = mock(*args, **kwargs)
102
- # Test scope, so the value is visible to the keyword's own
103
- # body even when the mocked keyword is called from inside
104
- # another keyword rather than directly from the test.
105
- context.variables.set_test(RETURN_VARIABLE, mock_result)
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(
@@ -150,9 +217,12 @@ class MockResource(CallInspectionMixin):
150
217
  resource_file = getattr(keyword_runner.keyword, "source", None)
151
218
 
152
219
  if self._source not in str(resource_file):
153
- raise AttributeError("fKeyword '{keyword_name}' not found in {self._source}")
220
+ raise AttributeError(
221
+ f"Keyword '{keyword_name}' not found in {self._source}"
222
+ )
154
223
 
155
224
  self._original_items[keyword_name] = keyword_runner.keyword.body._items # pylint: disable=protected-access
225
+ self._mocked_sources[keyword_name] = resource_file
156
226
  if skip_setup:
157
227
  self._original_setups[keyword_name] = keyword_runner.keyword.setup
158
228
  keyword_runner.keyword.setup = None
@@ -160,7 +230,10 @@ class MockResource(CallInspectionMixin):
160
230
  self._original_teardowns[keyword_name] = keyword_runner.keyword.teardown
161
231
  keyword_runner.keyword.teardown = None
162
232
  mock = Mock(return_value=return_value, side_effect=side_effect)
163
- self._mocks[keyword_name] = mock
233
+ self._mocks[normalize_keyword_name(keyword_name)] = mock
234
+ # Tell MockCoverage this keyword's body no longer runs, so it is not
235
+ # credited with coverage it did not earn.
236
+ register_mocked_keyword(resource_file, keyword_name)
164
237
  return mock
165
238
 
166
239
  @keyword
@@ -174,6 +247,8 @@ class MockResource(CallInspectionMixin):
174
247
  | MockRes.Reset Mocks |
175
248
  """
176
249
  self._mocks.clear()
250
+ for keyword_name, source in self._mocked_sources.items():
251
+ unregister_mocked_keyword(source, keyword_name)
177
252
  for keyword_name, items in self._original_items.items():
178
253
  keyword_runner = BuiltIn()._namespace.get_runner(keyword_name, True) # pylint: disable=protected-access
179
254
  keyword_runner.keyword.body._items = items # pylint: disable=protected-access
@@ -186,6 +261,7 @@ class MockResource(CallInspectionMixin):
186
261
  self._original_items.clear()
187
262
  self._original_setups.clear()
188
263
  self._original_teardowns.clear()
264
+ self._mocked_sources.clear()
189
265
 
190
266
  @keyword
191
267
  def verify_keyword_called(self, keyword_name: str, times: int = None):
@@ -201,9 +277,6 @@ class MockResource(CallInspectionMixin):
201
277
  Example:
202
278
  | MockDB.Verify Keyword Called | Execute Sql | times=1 |
203
279
  """
204
- if keyword_name not in self._mocks:
205
- raise AssertionError(f"Keyword '{keyword_name}' was not mocked")
206
-
207
- mock = self._mocks[keyword_name]
280
+ mock = self._get_mock(keyword_name)
208
281
  if times is not None and mock.call_count != times:
209
282
  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
13
  from unittest.mock import call
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotframework-mock
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -444,9 +444,39 @@ MockLibrary dynamically replaces keyword implementations:
444
444
  3. Stores original methods before mocking
445
445
  4. Replaces methods with mock implementations using Python's unittest.mock.Mock
446
446
  5. Returns mocked values or executes side effects
447
- 6. Tracks call counts for verification
447
+ 6. Tracks call counts and call arguments for verification
448
448
  7. Raises AttributeError if attempting to mock a non-existent keyword
449
449
 
450
+ #### Mocks are installed on the library class
451
+
452
+ Step 4 replaces the method on the target library's **class**, not on the
453
+ instance. Two consequences are worth knowing, because neither is reported as an
454
+ error:
455
+
456
+ **A mock stays active until `Reset Mocks`, across tests and suites.** The mock
457
+ is not scoped to the test that created it. A test that mocks a keyword and
458
+ leaves without resetting hands the mock to everything that follows. Reset in a
459
+ teardown rather than at the end of a test body, so the mock is also removed when
460
+ the test fails midway.
461
+
462
+ **Two `MockLibrary` instances wrapping the same library share one mock.** Last
463
+ registration wins, and resetting *either* instance restores the real method:
464
+
465
+ ```robot
466
+ Library MockLibrary DateTime WITH NAME MockA
467
+ Library MockLibrary DateTime WITH NAME MockB
468
+ ...
469
+ MockA.Mock Keyword Convert Time return_value=from-a
470
+ MockB.Mock Keyword Convert Time return_value=from-b
471
+ ${result}= Convert Time 12:00:00 # from-b: MockA's mock was overwritten
472
+ MockB.Reset Mocks
473
+ ${result}= Convert Time 12:00:00 # the real keyword, although MockA never reset
474
+ ```
475
+
476
+ Use a single alias per library, and separate aliases only for *different*
477
+ libraries. `MockResource` is not affected: it keys mocks per resource file, so
478
+ instances for different resources are independent.
479
+
450
480
  ### MockResource
451
481
 
452
482
  MockResource patches Robot Framework's keyword execution: