diff --git a/pyproject.toml b/pyproject.toml index bacde0a..b47c4c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ version_provider = "pep621" update_changelog_on_bump = false [project] name = "pytestomatio" -version = "2.10.0" +version = "2.10.1b5" dependencies = [ "requests>=2.29.0", diff --git a/pytestomatio/main.py b/pytestomatio/main.py index 927e73d..8f0927b 100644 --- a/pytestomatio/main.py +++ b/pytestomatio/main.py @@ -1,4 +1,5 @@ import os, pytest, logging, json, time +import warnings from pytest import Parser, Session, Config, Item, CallInfo from pytestomatio.connect.connector import Connector @@ -92,8 +93,12 @@ def pytest_collection_modifyitems(session: Session, config: Config, items: list[ meta, test_files, test_names = collect_tests(items) match config.getoption(testomatio): case 'sync': + tests = [item for item in meta if item.type != 'bdd'] + if not len(tests) == len(meta): + warnings.warn('BDD tests excluded from sync. You need to sync them separately into another project ' + 'via check-cucumber. For details, see https://github.com/testomatio/check-cucumber') pytest.testomatio.connector.load_tests( - meta, + tests, no_empty=config.getoption('no_empty'), no_detach=config.getoption('no_detach'), structure=config.getoption('keep_structure'), @@ -151,7 +156,7 @@ def pytest_runtest_makereport(item: Item, call: CallInfo): 'status': None, 'title': test_item.exec_title, 'run_time': call.duration, - 'suite_title': test_item.file_name, + 'suite_title': test_item.suite_title, 'suite_id': None, 'test_id': test_id, 'message': None, diff --git a/pytestomatio/testing/testItem.py b/pytestomatio/testing/testItem.py index 15ce31e..5a07409 100644 --- a/pytestomatio/testing/testItem.py +++ b/pytestomatio/testing/testItem.py @@ -6,29 +6,39 @@ import inspect MARKER = 'testomatio' +TEST_TYPES = [ + (lambda f: hasattr(f, '__scenario__'), 'bdd'), + (lambda f: True, 'regular') +] + + class TestItem: def __init__(self, item: Item): self.uid = uuid.uuid4() - self.id: str = TestItem.get_test_id(item) + self.type = self._get_test_type(item.function) + self.id: str = self.get_test_id(item) self.title = self._get_pytest_title(item.name) self.sync_title = self._get_sync_test_title(item) self.resync_title = self._get_resync_test_title(item) self.exec_title = self._get_execution_test_title(item) self.parameters = self._get_test_parameter_key(item) self.file_name = item.path.name + self.suite_title = self._get_suite_title(item.function) self.abs_path = str(item.path) self.file_path = item.location[0] self.module = item.module.__name__ self.source_code = inspect.getsource(item.function) self.class_name = item.cls.__name__ if item.cls else None self.artifacts = item.stash.get("artifact_urls", []) - + def to_dict(self) -> dict: result = dict() result['uid'] = str(self.uid) result['id'] = self.id result['title'] = self.title result['fileName'] = self.file_name + result['type'] = self.type + result['suite_title'] = self.suite_title result['absolutePath'] = self.abs_path result['filePath'] = self.file_path result['module'] = self.module @@ -40,8 +50,11 @@ def to_dict(self) -> dict: def json(self) -> str: return json.dumps(self.to_dict(), indent=4) - @staticmethod - def get_test_id(item: Item) -> str | None: + def get_test_id(self, item: Item) -> str | None: + if self.type == 'bdd': + for marker in item.iter_markers(): + if marker.name.startswith('T'): + return '@' + marker.name for marker in item.iter_markers(MARKER): if marker.args: return marker.args[0] @@ -58,13 +71,28 @@ def _get_pytest_title(self, name: str) -> str: return name[0:point] return name + def _get_test_type(self, test): + """Returns test type based on predicate check.""" + for predicate, test_type in TEST_TYPES: + if predicate(test): + return test_type + + def _get_suite_title(self, test): + """Returns suite title based on test type. For bdd test suite title equals Feature name, + for regular - filename""" + if self.type == 'bdd': + scenario = test.__scenario__ + if scenario and hasattr(scenario, 'feature'): + return scenario.feature.name + return self.file_name + # Testomatio resolves test id on BE by parsing test name to find test id def _get_sync_test_title(self, item: Item) -> str: test_name = self.pytest_title_to_testomatio_title(item.name) test_name = self._resolve_parameter_key_in_test_name(item, test_name) # Test id is present on already synced tests # New test don't have testomatio test id. - test_id = TestItem.get_test_id(item) + test_id = self.id if (test_id): test_name = f'{test_name} {test_id}' # ex. "User adds item to cart" @@ -95,9 +123,9 @@ def _get_resync_test_title(self, name: str) -> str: else: return name - def _get_test_parameter_key(self, item: Item): + def _get_test_parameter_key(self, item: Item) -> list: """Return a list of parameter names for a given test item.""" - param_names = set() + param_names = [] # 1) Look for @pytest.mark.parametrize for mark in item.iter_markers('parametrize'): @@ -107,9 +135,9 @@ def _get_test_parameter_key(self, item: Item): arg_string = mark.args[0] # If the string has commas, split it into multiple names if ',' in arg_string: - param_names.update(name.strip() for name in arg_string.split(',')) + param_names.extend([name.strip() for name in arg_string.split(',') if name not in param_names]) else: - param_names.add(arg_string.strip()) + param_names.append(arg_string.strip()) # 2) Look for fixture parameterization (including dynamically generated) # via callspec, which holds *all* final parameters for an item. @@ -117,11 +145,14 @@ def _get_test_parameter_key(self, item: Item): if callspec: # callspec.params is a dict: fixture_name -> parameter_value # We only want fixture names, not the values. - param_names.update(callspec.params.keys()) - - # Return them as a list, or keep it as a set—whatever you prefer. - return list(param_names) - + callspec_params = callspec.params + if self.type == 'bdd': + bdd_fixture_wrapper_name = '_pytest_bdd_example' + if bdd_fixture_wrapper_name in param_names: + param_names.remove(bdd_fixture_wrapper_name) + callspec_params = callspec.params.get(bdd_fixture_wrapper_name, {}) + param_names.extend([name for name in callspec_params.keys() if name not in param_names]) + return param_names def _resolve_parameter_key_in_test_name(self, item: Item, test_name: str) -> str: test_params = self._get_test_parameter_key(item) @@ -148,7 +179,8 @@ def _resolve_parameter_value_in_test_name(self, item: Item, test_name: str) -> s def repl(match): key = match.group(1) - value = item.callspec.params.get(key, '') + value = item.callspec.params.get(key, '') if not self.type == 'bdd' else \ + item.callspec.params.get('_pytest_bdd_example', {}).get(key, '') string_value = self._to_string_value(value) # TODO: handle "value with space" on testomatio BE https://github.com/testomatio/check-tests/issues/147 diff --git a/tests/test_main.py b/tests/test_main.py index 22ea764..52204a0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -194,6 +194,40 @@ def test_sync_mode(self, mock_exit, mock_session, mock_config, multiple_test_ite assert mock_add_enrich.call_count == 1 mock_exit.assert_called_once_with('Sync completed without test execution') + @patch('pytestomatio.main.pytest.exit') + def test_bdd_tests_excluded_from_sync(self, mock_exit, mock_session, mock_config, multiple_test_items): + """Test sync mode""" + mock_config.getoption.side_effect = lambda x: { + 'testomatio': 'sync', + 'no_empty': False, + 'no_detach': False, + 'keep_structure': False, + 'create': False, + 'directory': None + }.get(x) + items = multiple_test_items.copy() + + scenario_mock = Mock() + feature_mock = Mock() + feature_mock.name = 'mock feature' + scenario_mock.feature = feature_mock + items[0].function.__scenario__ = scenario_mock + + pytest.testomatio = Mock() + pytest.testomatio.connector = Mock() + pytest.testomatio.connector.get_tests.return_value = [] + + with patch('pytestomatio.main.add_and_enrich_tests') as mock_add_enrich: + main.pytest_collection_modifyitems(mock_session, mock_config, items) + + assert pytest.testomatio.connector.load_tests.call_count == 1 + passed_meta = pytest.testomatio.connector.load_tests.call_args[0][0] + assert len(passed_meta) != len(items) + assert passed_meta[0].title == items[1].name + + assert mock_add_enrich.call_count == 1 + mock_exit.assert_called_once_with('Sync completed without test execution') + @patch('pytestomatio.main.update_tests') @patch('pytestomatio.main.pytest.exit') def test_remove_mode(self, mock_exit, mock_update_tests, mock_session, mock_config, single_test_item, diff --git a/tests/test_testing/test_TestItem.py b/tests/test_testing/test_TestItem.py index 74cafaf..5bb25c0 100644 --- a/tests/test_testing/test_TestItem.py +++ b/tests/test_testing/test_TestItem.py @@ -27,6 +27,11 @@ def mock_item(self): item.iter_markers.return_value = iter([]) return item + @pytest.fixture + def mock_bdd_item(self, mock_item): + mock_item.function.__scenario__ = True + return mock_item + @pytest.fixture def mock_item_with_marker(self, mock_item): """Mock Item with testomatio marker""" @@ -50,6 +55,7 @@ def test_init_basic(self, mock_getsource, mock_item): assert test_item.source_code == "def test_example(): pass" assert test_item.class_name is None assert test_item.artifacts == [] + assert test_item.type == 'regular' @patch('inspect.getsource') def test_init_with_marker(self, mock_getsource, mock_item_with_marker): @@ -71,23 +77,81 @@ def test_init_with_class(self, mock_getsource, mock_item): assert test_item.class_name == "TestClass" - def test_get_test_id_with_marker(self, mock_item): + @patch("inspect.getsource") + def test_get_test_id_with_marker(self, mock_source, mock_item): """Test get_test_id with marker""" marker = Mock() + marker.name = 'testomatio' marker.args = ["@T87654321"] mock_item.iter_markers.return_value = iter([marker]) - - result = TestItem.get_test_id(mock_item) + test_item = TestItem(mock_item) + mock_item.iter_markers.return_value = iter([marker]) + result = test_item.get_test_id(mock_item) assert result == "@T87654321" + assert result == test_item.id + + @patch("inspect.getsource") + def test_get_test_id_with_other_markers(self, mock_source, mock_item): + """Test get_test_id with other marker""" + marker = Mock() + marker.name = 'other' + marker.args = ["@T87654321"] + markers = [marker] + mock_item.iter_markers.side_effect = lambda name=None: iter( + [m for m in markers if name is None or m.name == name]) + test_item = TestItem(mock_item) + result = test_item.get_test_id(mock_item) - def test_get_test_id_without_marker(self, mock_item): + assert result is None + assert test_item.id is None + + @patch("inspect.getsource") + def test_get_test_id_without_marker(self, mock_source, mock_item): """Test get_test_id without marker""" mock_item.iter_markers.return_value = iter([]) + test_item = TestItem(mock_item) + result = test_item.get_test_id(mock_item) - result = TestItem.get_test_id(mock_item) + assert result is None + assert test_item.id is None + @patch("inspect.getsource") + def test_get_test_id_for_bdd_test(self, mock_source, mock_item): + """Test get_test_id with correct marker for bdd test""" + marker = Mock() + marker.name = "T87654321" + mock_item.iter_markers.return_value = iter([marker]) + mock_item.function.__scenario__ = True + test_item = TestItem(mock_item) + mock_item.iter_markers.return_value = iter([marker]) + result = test_item.get_test_id(mock_item) + assert result == "@T87654321" + assert test_item.id == result + + @patch("inspect.getsource") + def test_get_test_id_for_bdd_test_with_other_marker(self, mock_source, mock_item): + """Test get_test_id without correct marker for bdd test""" + marker = Mock() + marker.name = 'other_marker' + marker.args = ["@T87654321"] + mock_item.iter_markers.return_value = iter([marker]) + mock_item.function.__scenario__ = True + test_item = TestItem(mock_item) + result = test_item.get_test_id(mock_item) assert result is None + assert test_item.id is None + + @patch("inspect.getsource") + def test_get_test_id_for_bdd_test_without_marker(self, mock_source, mock_item): + """Test get_test_id without marker for bdd test""" + mock_item.iter_markers.return_value = iter([]) + mock_item.function.__scenario__ = True + test_item = TestItem(mock_item) + result = test_item.get_test_id(mock_item) + + assert result is None + assert test_item.id is None def test_get_pytest_title_simple(self, mock_item): """Test _get_pytest_title without params""" @@ -123,6 +187,17 @@ def test_get_test_parameter_key_no_params(self, mock_item): assert result == [] + @patch('inspect.getsource') + def test_get_test_parameter_key_no_params_for_bdd_test(self, mock_source, mock_bdd_item): + """Test _get_test_parameter_key without params for bdd test""" + mock_bdd_item.iter_markers.return_value = iter([]) + test_item = TestItem(mock_bdd_item) + + result = test_item._get_test_parameter_key(mock_bdd_item) + + assert test_item.type == 'bdd' + assert result == [] + def test_get_test_parameter_key_with_parametrize(self, mock_item): """Test _get_test_parameter_key with @pytest.mark.parametrize""" param_marker = Mock() @@ -136,27 +211,68 @@ def test_get_test_parameter_key_with_parametrize(self, mock_item): assert set(result) == {"param1", "param2"} - def test_get_test_parameter_key_with_callspec(self, mock_item): + @patch('inspect.getsource') + def test_get_test_parameter_key_with_callspec(self, mock_source, mock_item): """Test _get_test_parameter_key with callspec""" mock_item.iter_markers.return_value = iter([]) mock_item.callspec = Mock() mock_item.callspec.params = {"fixture1": "value1", "fixture2": "value2"} - test_item = TestItem.__new__(TestItem) + test_item = TestItem(mock_item) result = test_item._get_test_parameter_key(mock_item) assert set(result) == {"fixture1", "fixture2"} - def test_resolve_parameter_key_in_test_name(self, mock_item): + @patch('inspect.getsource') + def test_get_test_parameter_key_keeps_key_order(self, mock_source, mock_item): + """Test _get_test_parameter_key keeps key order""" + mock_item.iter_markers.return_value = iter([]) + mock_item.callspec = Mock() + mock_item.callspec.params = {"fixture1": "value1", "fixture2": "value2"} + + test_item = TestItem(mock_item) + + result = test_item._get_test_parameter_key(mock_item) + + assert set(result) == {"fixture1", "fixture2"} + assert result[0] == 'fixture1' + assert result[1] == 'fixture2' + + @patch('inspect.getsource') + def test_get_test_parameter_key_with_callspec_for_bdd_test(self, mock_source, mock_bdd_item): + """Test _get_test_parameter_key with callspec for bdd test""" + mock_bdd_item.iter_markers.return_value = iter([]) + mock_bdd_item.callspec = Mock() + mock_bdd_item.callspec.params = {'_pytest_bdd_example': {"fixture1": "value1", "fixture2": "value2"}} + test_item = TestItem(mock_bdd_item) + + result = test_item._get_test_parameter_key(mock_bdd_item) + + assert test_item.type == 'bdd' + assert set(result) == {"fixture1", "fixture2"} + + @patch('inspect.getsource') + def test_resolve_parameter_key_in_test_name(self, mock_source, mock_item): """Test _resolve_parameter_key_in_test_name""" - test_item = TestItem.__new__(TestItem) + test_item = TestItem(mock_item) with patch.object(test_item, '_get_test_parameter_key', return_value=["param1", "param2"]): result = test_item._resolve_parameter_key_in_test_name(mock_item, "Test name[value]") assert result == "Test name ${param1} ${param2}" + @patch('inspect.getsource') + def test_resolve_parameter_key_in_test_name_for_bdd_test(self, mock_source, mock_bdd_item): + """Test _resolve_parameter_key_in_test_name for bdd test""" + test_item = TestItem(mock_bdd_item) + + with patch.object(test_item, '_get_test_parameter_key', return_value=["param1", "param2"]): + result = test_item._resolve_parameter_key_in_test_name(mock_bdd_item, "Test name[value]") + + assert test_item.type == 'bdd' + assert result == "Test name ${param1} ${param2}" + def test_resolve_parameter_key_no_params(self, mock_item): """Test _resolve_parameter_key_in_test_name without params""" test_item = TestItem.__new__(TestItem) @@ -166,6 +282,17 @@ def test_resolve_parameter_key_no_params(self, mock_item): assert result == "Test name" + @patch('inspect.getsource') + def test_resolve_parameter_key_no_params_for_bdd_test(self, mock_source, mock_bdd_item): + """Test _resolve_parameter_key_in_test_name without params for bdd test""" + test_item = TestItem(mock_bdd_item) + + with patch.object(test_item, '_get_test_parameter_key', return_value=[]): + result = test_item._resolve_parameter_key_in_test_name(mock_bdd_item, "Test name") + + assert test_item.type == 'bdd' + assert result == "Test name" + def test_to_string_value_various_types(self, mock_item): """Test _to_string_value with different types""" test_item = TestItem.__new__(TestItem) @@ -253,9 +380,10 @@ def test_str_and_repr(self, mock_getsource, mock_item_with_marker): assert str_result == expected assert repr_result == expected - def test_resolve_parameter_value_in_test_name(self, mock_item): + @patch('inspect.getsource') + def test_resolve_parameter_value_in_test_name(self, mock_source, mock_item): """Test _resolve_parameter_value_in_test_name method""" - test_item = TestItem.__new__(TestItem) + test_item = TestItem(mock_item) mock_item.callspec = Mock() mock_item.callspec.params = {"param1": "value1", "param2": "value with spaces"} @@ -267,12 +395,68 @@ def test_resolve_parameter_value_in_test_name(self, mock_item): assert "value1" in result assert "value_with_spaces" in result - def test_resolve_parameter_value_no_callspec(self, mock_item): + @patch('inspect.getsource') + def test_resolve_parameter_value_in_test_name_for_bdd_test(self, mock_source, mock_bdd_item): + """Test _resolve_parameter_value_in_test_name method for bdd test""" + test_item = TestItem(mock_bdd_item) + + mock_bdd_item.callspec = Mock() + mock_bdd_item.callspec.params = {'_pytest_bdd_example': {"param1": "value1", "param2": "value with spaces"}} + + with patch.object(test_item, '_get_test_parameter_key', return_value=["param1", "param2"]): + with patch.object(test_item, '_get_sync_test_title', return_value="Test ${param1} and ${param2}"): + result = test_item._resolve_parameter_value_in_test_name(mock_bdd_item, "Test name") + + assert test_item.type == 'bdd' + assert "value1" in result + assert "value_with_spaces" in result + + @patch("inspect.getsource") + def test_resolve_parameter_value_no_callspec(self, mock_source, mock_item): """Test _resolve_parameter_value_in_test_name without callspec""" - test_item = TestItem.__new__(TestItem) + test_item = TestItem(mock_item) mock_item.callspec = None with patch.object(test_item, '_get_test_parameter_key', return_value=["param1"]): result = test_item._resolve_parameter_value_in_test_name(mock_item, "Test name") assert result == "Test name" + + @patch('inspect.getsource') + def test_get_regular_type(self, mock_getsource, mock_item): + mock_getsource.return_value = "def test(): pass" + test_item = TestItem(mock_item) + assert test_item.type == 'regular' + + @patch('inspect.getsource') + def test_get_bdd_type(self, mock_getsource, mock_item): + mock_getsource.return_value = "def test(): pass" + mock_item.function.__scenario__ = True + test_item = TestItem(mock_item) + assert test_item.type == 'bdd' + + @patch('inspect.getsource') + def test_suite_title_for_regular_test(self, mock_getsource, mock_item): + mock_getsource.return_value = "def test(): pass" + test_item = TestItem(mock_item) + assert test_item.suite_title == 'test_file.py' + + @patch('inspect.getsource') + def test_suite_title_for_bdd_test(self, mock_getsource, mock_item): + mock_getsource.return_value = "def test(): pass" + scenario_mock = Mock() + feature_mock = Mock() + feature_mock.name = 'Test feature' + scenario_mock.feature = feature_mock + mock_item.function.__scenario__ = scenario_mock + test_item = TestItem(mock_item) + assert test_item.suite_title == 'Test feature' + + @patch('inspect.getsource') + def test_suite_title_return_filename_for_bdd_test(self, mock_getsource, mock_item): + """Test suite title returns filename for bdd test if feature unavailable""" + mock_getsource.return_value = "def test(): pass" + scenario_mock = Mock(spec=[]) + mock_item.function.__scenario__ = scenario_mock + test_item = TestItem(mock_item) + assert test_item.suite_title == 'test_file.py'