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.
Files changed (16) hide show
  1. {robotframework_mock-0.7.1/src/robotframework_mock.egg-info → robotframework_mock-0.9.0}/PKG-INFO +59 -2
  2. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/README.md +58 -1
  3. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/setup.cfg +1 -1
  4. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/MockCoverage/__init__.py +7 -1
  5. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/MockLibrary/__init__.py +2 -0
  6. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/MockResource/__init__.py +113 -40
  7. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/_mock_core/__init__.py +127 -1
  8. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0/src/robotframework_mock.egg-info}/PKG-INFO +59 -2
  9. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/LICENSE +0 -0
  10. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/MANIFEST.in +0 -0
  11. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/pyproject.toml +0 -0
  12. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/setup.py +0 -0
  13. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/robotframework_mock.egg-info/SOURCES.txt +0 -0
  14. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/robotframework_mock.egg-info/dependency_links.txt +0 -0
  15. {robotframework_mock-0.7.1 → robotframework_mock-0.9.0}/src/robotframework_mock.egg-info/requires.txt +0 -0
  16. {robotframework_mock-0.7.1 → robotframework_mock-0.9.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.1
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:
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = robotframework-mock
3
- version = 0.7.1
3
+ version = 0.9.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]:
@@ -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 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(
@@ -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
- if keyword_name not in self._mocks:
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotframework-mock
3
- Version: 0.7.1
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: