diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7236b724..4f2cb491 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,8 +91,9 @@ jobs: run: | dotnet build --configuration Debug --property WarningLevel=0 api-validator-dotnet pip3 install -r python-examples/${{ matrix.project_name }}/requirements_ci.txt - pip3 install ./testit-python-commons - pip3 install ./${{ matrix.adapter_name }} + pip3 install -e ./testit-python-commons + pip3 install -e ./${{ matrix.adapter_name }} + python3 -c "from adapters_api.model.custom_attribute_api_result import CustomAttributeApiResult; assert 'code' in CustomAttributeApiResult.openapi_types" - name: Test run: | @@ -129,8 +130,9 @@ jobs: run: | dotnet build --configuration Debug --property WarningLevel=0 api-validator-dotnet pip3 install -r python-examples/${{ matrix.project_name }}/requirements_ci.txt - pip3 install ./testit-python-commons - pip3 install ./${{ matrix.adapter_name }} + pip3 install -e ./testit-python-commons + pip3 install -e ./${{ matrix.adapter_name }} + python3 -c "from adapters_api.model.custom_attribute_api_result import CustomAttributeApiResult; assert 'code' in CustomAttributeApiResult.openapi_types" - name: Test import realtime false diff --git a/docs/tz-autotest-layer.md b/docs/tz-autotest-layer.md new file mode 100644 index 00000000..7e18032c --- /dev/null +++ b/docs/tz-autotest-layer.md @@ -0,0 +1,228 @@ +# Technical assignment: autotest layer (test pyramid) + +## Context + +TMS supports a **test pyramid layer** on an **autotest** card. The layer can be assigned manually, by rules, or from automation runs. + +Adapters can set the layer when creating or updating an autotest during a test run. The layer source in API is **`Run`** (set by the adapter). + +## Business need + +As an automation engineer, I want to declare the pyramid level of each autotest **in test code** so that after a run the autotest card shows: + +- the layer name (e.g. `API`, `E2E`); +- the source **Run** (adapter), distinct from Manual / Rule / Report. + +## Goal + +In every adapter that creates or updates autotests, support an optional **layer** metadata field: + +- read from test code only (annotation / mark / tag — per language); +- send on **create** and **update** autotest API calls when present; +- **omit** the field when the user did not specify a layer. + +## Scope + +Apply consistently to all Test IT adapters (any language / framework) and any entry point that upserts autotests via the standard adapters API. + +**In scope:** autotest `layer` on create/update. + +**Out of scope:** + +- layer on **test run** entity; +- layer on **test result** as a separate API field (UI may show layer from the linked autotest); +- configuration / env / CLI default layer for a whole run; +- runtime API to set layer during test execution; +- validating layer names against a fixed whitelist (adapters pass the string as-is). + +## API contract + +Use existing autotest endpoints and models: + +```json +{ + "layer": { + "name": "API", + "source": "Run" + } +} +``` + +| Operation | Behaviour | +|-----------|-----------| +| **Create autotest** | Include `layer` only when the test declares a non-empty layer | +| **Update autotest** | Always send `resetLayer: false`; include `layer` only when the test declares a non-empty layer | +| **No layer in test** | Send `resetLayer: false`; do **not** send `layer` | + +Recommended layer names (constants in client libraries, not enforced): `E2E`, `UI`, `API`, `Contract`, `Integration`, `Component`, `Unit`. + +Any other non-empty string is valid. + +`LayerSource` values: `Manual`, `Report`, `Run`, `Rule`. Adapters always use **`Run`**. + +## Functional requirements + +### 1. Declaration in test code only + +The user sets layer on the **test method / scenario**, not via adapter config. + +| Stack | Suggested syntax | +|-------|------------------| +| Java (JUnit / TestNG) | `@Layer("API")` or `@Layer(TestLayers.API)` on test method | +| Python (pytest) | `@pytest.mark.layer("api")` | +| C# (NUnit / xUnit) | `[Layer("API")]` on test method | +| JavaScript (Jest / Mocha) | `@layer('API')` or framework-specific decorator | +| Cucumber (Gherkin) | `@Layer=API` on scenario | +| JBehave | Meta: `@Layer API` on scenario | + +Parameter substitution (e.g. `{param}` in Java) should work if the framework already supports it for other annotations. + +### 2. Mapping flow + +``` +test code (annotation / tag) + → internal test result model (optional layer field) + → Converter / mapper + → AutoTestCreateApiModel.layer / AutoTestUpdateApiModel.layer + → TMS +``` + +### 3. Independence from other metadata + +- Layer ≠ labels / tags on autotest. +- Layer ≠ test run tags. +- Do not change existing labels/tags behaviour. + +### 4. Failed-test update path + +Some adapters send a minimal update when a test fails (copy existing autotest from TMS). Still apply `layer` from the test annotation when present. + +## Non-goals + +- Default layer for all tests in a run via config. +- `Adapter.addLayer()` or similar dynamic API. +- Resetting layer when annotation is absent (`resetLayer: true`). +- OpenAPI / client regeneration in adapter repos (use existing generated `LayerApiModel`). + +## Acceptance criteria + +1. User adds layer in test code → autotest in TMS shows that layer with source **Run** after create/update. +2. User omits layer → adapter does not send `layer` on create/update. +3. Custom layer string is accepted without validation. +4. Recommended constants documented; arbitrary strings work. +5. Cross-language doc describes the same rules with per-language syntax examples. +6. Unit tests: with layer / without layer / custom string / failed-update path. + +## Suggested implementation outline (language-agnostic) + +1. Add `@Layer` (or equivalent) in shared annotations package. +2. Add optional `layer` field to internal `TestResult` (or equivalent). +3. Extract layer from test method in each framework listener. +4. In create/update mapper: if layer non-empty → `LayerApiModel { name, source: Run }`; on update always send `resetLayer: false`. +5. BDD adapters: parse `@Layer=` / meta `Layer` like other scenario tags. +6. Document in adapter README; add samples and tests. + +## Example (Java) + +```java +import ru.testit.annotations.Layer; +import ru.testit.models.TestLayers; + +@Layer(TestLayers.API) +@Test +void createUser() { + // ... +} + +@Layer("my-custom-layer") +@Test +void customLayer() { + // ... +} +``` + +## Example (pytest) + +```python +import pytest +from testit import layer, TestLayers + +@pytest.mark.layer("api") +def test_create_user_with_mark(): + ... + +@layer(TestLayers.API) +def test_create_user_with_decorator(): + ... + +@layer("my-custom-layer") +def test_with_custom_layer(): + ... +``` + +--- + +## Java adapters status + +Implemented in `testit-java-commons` (shared by all Java adapters): + +- `@Layer` on test method (`ru.testit.annotations.Layer`) +- `TestLayers` constants (`ru.testit.models.TestLayers`) +- `TestResult.layer`, `Utils.extractLayer` +- `Converter`: `layer` + `LayerSource.RUN` on create when set; on update always `resetLayer: false`, `layer` only when set +- Framework listeners: JUnit 4/5, TestNG, Cucumber 4–7 (`@Layer=`), JBehave (meta `Layer`) + +## Python adapters status + +Implemented in `testit-python-commons` (shared by pytest / behave / nose / robot): + +- Decorator: `@layer("API")` or `@layer(TestLayers.API)` (`testit.layer`, `testit.TestLayers`) +- `TestResult.set_layer` / `get_layer` +- `Converter.layer_to_api_model`: `LayerApiModel(name, source=Run)` on create when set; on update always `resetLayer: false`, `layer` only when set +- No config/env/CLI layer; no layer on test run or test result + +### Syntax by adapter + +| Adapter | How to declare layer in test code | +|---------|-----------------------------------| +| pytest | `@pytest.mark.layer("API")` or `@layer(TestLayers.API)` from `testit` | +| behave | `@Layer=API` on scenario/feature | +| nose (nose2) | `@layer("API")` from `testit` on test function | +| robotframework | `[Tags] testit.layer:API` | + +### Recommended constants + +```python +from testit import TestLayers + +TestLayers.E2E +TestLayers.UI +TestLayers.API +TestLayers.CONTRACT +TestLayers.INTEGRATION +TestLayers.COMPONENT +TestLayers.UNIT +``` + +Any other non-empty string is accepted without validation. + +### API mapping rules + +| Scenario | Adapter behaviour | +|----------|-------------------| +| Layer set in test | create: send `layer: { name, source: Run }` | +| Layer set in test | update: send `layer` + `resetLayer: false` | +| Layer not set | send `resetLayer: false`; do not send `layer` | + +### Cross-language implementation guide + +When porting to another language, keep the same contract: + +1. Read layer **only from test code** (annotation / mark / tag). +2. Store it in the internal test result model as an optional string. +3. On autotest **create**, include `layer` only when non-empty. +4. On autotest **update**, always send `resetLayer: false`; include `layer` only when non-empty. +5. Always set `source` to `Run`. +6. Do not validate layer name against a whitelist; document recommended constants only. +7. Do not add config/env/CLI defaults for run-level layer. +8. Do not send layer on test run or test result entities. diff --git a/testit-adapter-behave/setup.py b/testit-adapter-behave/setup.py index 4a1cbb14..da4572ed 100644 --- a/testit-adapter-behave/setup.py +++ b/testit-adapter-behave/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = "5.1.6" +VERSION = "5.2.0" setup( name='testit-adapter-behave', diff --git a/testit-adapter-behave/src/testit_adapter_behave/models/tags.py b/testit-adapter-behave/src/testit_adapter_behave/models/tags.py index 3578a544..df0715a8 100644 --- a/testit-adapter-behave/src/testit_adapter_behave/models/tags.py +++ b/testit-adapter-behave/src/testit_adapter_behave/models/tags.py @@ -9,3 +9,4 @@ class TagType: TAGS = 'Tags=' NAMESPACE = 'NameSpace=' CLASSNAME = 'ClassName=' + LAYER = 'Layer=' diff --git a/testit-adapter-behave/src/testit_adapter_behave/scenario_parser.py b/testit-adapter-behave/src/testit_adapter_behave/scenario_parser.py index 5c2fcee3..fcdc85b6 100644 --- a/testit-adapter-behave/src/testit_adapter_behave/scenario_parser.py +++ b/testit-adapter-behave/src/testit_adapter_behave/scenario_parser.py @@ -57,6 +57,7 @@ def parse_scenario(scenario): 'links': [], 'labels': [], 'tags': [], + 'layer': None, 'workItemsID': [], "externalKey": get_scenario_name(scenario) # TODO: Add to python-commons @@ -89,6 +90,9 @@ def parse_scenario(scenario): # TODO: Fix in python-commons to "workItemIds" executable_test['workItemsID'] = tags[TagType.WORK_ITEM_IDS] + if TagType.LAYER in tags and tags[TagType.LAYER]: + executable_test['layer'] = tags[TagType.LAYER] + return executable_test diff --git a/testit-adapter-behave/src/testit_adapter_behave/tags_parser.py b/testit-adapter-behave/src/testit_adapter_behave/tags_parser.py index 78eed727..9781c6cd 100644 --- a/testit-adapter-behave/src/testit_adapter_behave/tags_parser.py +++ b/testit-adapter-behave/src/testit_adapter_behave/tags_parser.py @@ -15,6 +15,7 @@ def parse_test_tags(tags): TagType.TAGS: [], TagType.NAMESPACE: None, TagType.CLASSNAME: None, + TagType.LAYER: None, } for tag in tags: @@ -52,6 +53,9 @@ def parse_test_tags(tags): elif TagType.CLASSNAME in tag: parsed_tags[TagType.CLASSNAME] = __parse_space_in_tag(tag[len(TagType.CLASSNAME):]) + elif TagType.LAYER in tag: + parsed_tags[TagType.LAYER] = __parse_space_in_tag(tag[len(TagType.LAYER):]) + return parsed_tags diff --git a/testit-adapter-behave/src/testit_adapter_behave/utils.py b/testit-adapter-behave/src/testit_adapter_behave/utils.py index b37503eb..097d2dcf 100644 --- a/testit-adapter-behave/src/testit_adapter_behave/utils.py +++ b/testit-adapter-behave/src/testit_adapter_behave/utils.py @@ -151,6 +151,7 @@ def convert_executable_test_to_test_result_model(executable_test: dict) -> TestR .set_result_links(executable_test['resultLinks'])\ .set_labels(executable_test['labels']) \ .set_tags(executable_test['tags'])\ + .set_layer(executable_test.get('layer'))\ .set_work_item_ids(executable_test['workItemsID'])\ .set_message(executable_test['message'])\ .set_external_key(executable_test['externalKey']) diff --git a/testit-adapter-behave/tests/test_behave_layer.py b/testit-adapter-behave/tests/test_behave_layer.py new file mode 100644 index 00000000..e289a275 --- /dev/null +++ b/testit-adapter-behave/tests/test_behave_layer.py @@ -0,0 +1,8 @@ +from testit_adapter_behave.models.tags import TagType +from testit_adapter_behave.tags_parser import parse_test_tags + + +def test_parse_layer_tag(): + parsed = parse_test_tags(['Layer=API']) + + assert parsed[TagType.LAYER] == 'API' diff --git a/testit-adapter-nose/setup.py b/testit-adapter-nose/setup.py index 3cf3bfe9..728c82ab 100644 --- a/testit-adapter-nose/setup.py +++ b/testit-adapter-nose/setup.py @@ -1,6 +1,6 @@ from setuptools import setup, find_packages -VERSION = "5.1.6" +VERSION = "5.2.0" setup( name='testit-adapter-nose', diff --git a/testit-adapter-nose/src/testit_adapter_nose/utils.py b/testit-adapter-nose/src/testit_adapter_nose/utils.py index 35b80c1c..06e32ec6 100644 --- a/testit-adapter-nose/src/testit_adapter_nose/utils.py +++ b/testit-adapter-nose/src/testit_adapter_nose/utils.py @@ -86,6 +86,7 @@ def form_test(item, top_level_directory): 'links': __get_links_from(item, function), 'labels': __get_labels_from(item, function), 'tags': __get_tags_from(item, function), + 'layer': __get_layer_from(item, function), 'workItemsID': __get_work_item_ids_from(item, function), 'message': None, 'externalKey': __get_fullname(function, top_level_directory) @@ -242,6 +243,16 @@ def __get_tags_from(item, function) -> List[str]: return tags +def __get_layer_from(item, function): + test_layer = __search_attribute(function, 'test_layer') + if not test_layer: + return None + + return str(collect_parameters_in_string_attribute( + test_layer, + get_all_parameters(item))) + + def __get_work_item_ids_from(item, function): test_workitems_id = __search_attribute(function, 'test_workitems_id') @@ -405,6 +416,7 @@ def convert_executable_test_to_test_result_model(executable_test: dict) -> TestR .set_result_links(executable_test['resultLinks'])\ .set_labels(executable_test['labels'])\ .set_tags(executable_test['tags'])\ + .set_layer(executable_test.get('layer'))\ .set_work_item_ids(executable_test['workItemsID'])\ .set_message(executable_test['message'])\ .set_external_key(executable_test['externalKey']) diff --git a/testit-adapter-pytest/setup.py b/testit-adapter-pytest/setup.py index f63793d9..afc0a97d 100644 --- a/testit-adapter-pytest/setup.py +++ b/testit-adapter-pytest/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = "5.1.6" +VERSION = "5.2.0" setup( name='testit-adapter-pytest', diff --git a/testit-adapter-pytest/src/testit_adapter_pytest/models/executable_test.py b/testit-adapter-pytest/src/testit_adapter_pytest/models/executable_test.py index 66475997..6df98942 100644 --- a/testit-adapter-pytest/src/testit_adapter_pytest/models/executable_test.py +++ b/testit-adapter-pytest/src/testit_adapter_pytest/models/executable_test.py @@ -29,5 +29,6 @@ class ExecutableTest: labels = attrib(default=Factory(list)) tags = attrib(default=Factory(list)) work_item_ids = attrib(default=Factory(list)) + layer = attrib(default=None) message = attrib(default=None) node_id = attrib(default=None) diff --git a/testit-adapter-pytest/src/testit_adapter_pytest/utils.py b/testit-adapter-pytest/src/testit_adapter_pytest/utils.py index d73fe6c7..46287d50 100644 --- a/testit-adapter-pytest/src/testit_adapter_pytest/utils.py +++ b/testit-adapter-pytest/src/testit_adapter_pytest/utils.py @@ -35,6 +35,7 @@ def form_test(item) -> ExecutableTest: links=__get_links_from(item), labels=__get_labels_from(item), tags=__get_tags_from(item), + layer=__get_layer_from(item), work_item_ids=__get_work_item_ids_from(item), node_id=item.nodeid ) @@ -52,13 +53,9 @@ def __set_outcome_and_message_from_markers(executable_test: ExecutableTest, mark if marker.name == 'xfail': executable_test.outcome = 'Xfailed' if marker.name in ('skip', 'skipif', 'xfail'): - if len(marker.args) == 1 and isinstance(marker.args, str): + if len(marker.args) == 1 and isinstance(marker.args[0], str): executable_test.message = marker.args[0] - - condition_in_args = marker.args and marker.args[0] - condition_in_kwargs = marker.kwargs and 'condition' in marker.kwargs and marker.kwargs['condition'] - - if (condition_in_kwargs or condition_in_args) and 'reason' in marker.kwargs: + elif 'reason' in marker.kwargs: executable_test.message = marker.kwargs['reason'] return executable_test @@ -226,6 +223,22 @@ def __get_tags_from(item) -> List[str]: return tags +def __get_layer_from(item): + for marker in item.iter_markers(name='layer'): + if marker.args: + return str(marker.args[0]) + if 'name' in marker.kwargs: + return str(marker.kwargs['name']) + + test_layer = __search_attribute(item, 'test_layer') + if not test_layer: + return None + + return str(collect_parameters_in_string_attribute( + test_layer, + get_all_parameters(item))) + + def __get_work_item_ids_from(item): test_workitem_ids = __search_attribute(item, 'test_workitems_id') @@ -366,6 +379,7 @@ def convert_executable_test_to_test_result_model(executable_test: ExecutableTest .set_result_links(executable_test.result_links)\ .set_labels(executable_test.labels)\ .set_tags(executable_test.tags)\ + .set_layer(executable_test.layer)\ .set_work_item_ids(executable_test.work_item_ids) \ .set_message(executable_test.message) \ .set_external_key(pytest_autotest_keys) @@ -443,7 +457,8 @@ def get_skip_message_from_report(report) -> str or None: return None if report.longrepr is not None: return str(report.longrepr) - return None + skip_repr = getattr(report, 'longreprtext', None) + return skip_repr if skip_repr else None def get_message(etype, value): diff --git a/testit-adapter-pytest/tests/test_pytest_layer.py b/testit-adapter-pytest/tests/test_pytest_layer.py new file mode 100644 index 00000000..e859fc12 --- /dev/null +++ b/testit-adapter-pytest/tests/test_pytest_layer.py @@ -0,0 +1,42 @@ +from types import SimpleNamespace + +import pytest + +from testit_adapter_pytest.utils import __get_layer_from + + +class _FakeMarker: + def __init__(self, args=(), kwargs=None): + self.args = args + self.kwargs = kwargs or {} + + +class _FakeItem: + def __init__(self, markers=None, function=None, cls=None): + self._markers = markers or [] + self.function = function or SimpleNamespace() + self.cls = cls + + def iter_markers(self, name=None): + for marker in self._markers: + if name is None or marker.name == name: + yield marker + + +def test_get_layer_from_pytest_mark(): + item = _FakeItem(markers=[SimpleNamespace(name='layer', args=('API',), kwargs={})]) + + assert __get_layer_from(item) == 'API' + + +def test_get_layer_from_decorator_attribute(): + function = SimpleNamespace(test_layer='UI') + item = _FakeItem(function=function) + + assert __get_layer_from(item) == 'UI' + + +def test_get_layer_from_missing_returns_none(): + item = _FakeItem() + + assert __get_layer_from(item) is None diff --git a/testit-adapter-robotframework/setup.py b/testit-adapter-robotframework/setup.py index 559204f3..0726c351 100644 --- a/testit-adapter-robotframework/setup.py +++ b/testit-adapter-robotframework/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = "5.1.6" +VERSION = "5.2.0" setup( name='testit-adapter-robotframework', diff --git a/testit-adapter-robotframework/src/testit_adapter_robotframework/models.py b/testit-adapter-robotframework/src/testit_adapter_robotframework/models.py index 02f33d4c..668c50b1 100644 --- a/testit-adapter-robotframework/src/testit_adapter_robotframework/models.py +++ b/testit-adapter-robotframework/src/testit_adapter_robotframework/models.py @@ -106,6 +106,7 @@ class Autotest(Default): links = attrib(default=Factory(list)) labels = attrib(default=Factory(list)) tags = attrib(default=Factory(list)) + layer = attrib(default=None) workItemsID = attrib(default=Factory(list)) # noqa: N815 message = attrib(default="") started_on = attrib(default=None) @@ -174,6 +175,8 @@ def add_attributes(self, attrs): self.namespace = _clean_value(value) elif attr == 'classname': self.classname = _clean_value(value) + elif attr == 'layer': + self.layer = _clean_value(value) else: logger.error(f"[TestIt] Unknown attribute: {attr}") if not self.externalID: diff --git a/testit-adapter-robotframework/src/testit_adapter_robotframework/utils.py b/testit-adapter-robotframework/src/testit_adapter_robotframework/utils.py index ce9f7ed0..193d5767 100644 --- a/testit-adapter-robotframework/src/testit_adapter_robotframework/utils.py +++ b/testit-adapter-robotframework/src/testit_adapter_robotframework/utils.py @@ -39,6 +39,7 @@ def convert_executable_test_to_test_result_model(executable_test: dict) -> TestR .set_result_links(executable_test['resultLinks'])\ .set_labels(executable_test['labels']) \ .set_tags(executable_test['tags'])\ + .set_layer(executable_test.get('layer'))\ .set_work_item_ids(executable_test['workItemsID'])\ .set_message(executable_test['message'])\ .set_external_key(executable_test['externalKey']) diff --git a/testit-adapter-robotframework/tests/test_listeners.py b/testit-adapter-robotframework/tests/test_listeners.py index fce44d1c..a697686b 100644 --- a/testit-adapter-robotframework/tests/test_listeners.py +++ b/testit-adapter-robotframework/tests/test_listeners.py @@ -85,4 +85,10 @@ def test_title_has_priority_over_display_name(self): ])) assert autotest.autoTestName == "DisplayName" - assert autotest.title == "CardTitle" \ No newline at end of file + assert autotest.title == "CardTitle" + + def test_layer_tag_is_parsed(self): + autotest = Autotest(autoTestName="Initial") + autotest.add_attributes(self._build_attrs(["testit.layer:API"])) + + assert autotest.layer == "API" \ No newline at end of file diff --git a/testit-python-commons/setup.py b/testit-python-commons/setup.py index 6ecf41ca..0d7100df 100644 --- a/testit-python-commons/setup.py +++ b/testit-python-commons/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = "5.1.6" +VERSION = "5.2.0" setup( name='testit-python-commons', diff --git a/testit-python-commons/src/adapters_api/model/custom_attribute_api_result.py b/testit-python-commons/src/adapters_api/model/custom_attribute_api_result.py index e066ad28..cbe557d6 100644 --- a/testit-python-commons/src/adapters_api/model/custom_attribute_api_result.py +++ b/testit-python-commons/src/adapters_api/model/custom_attribute_api_result.py @@ -90,9 +90,9 @@ def openapi_types(): 'is_enabled': (bool,), # noqa: E501 'is_required': (bool,), # noqa: E501 'is_global': (bool,), # noqa: E501 - 'is_read_only': (bool, none_type,), # noqa: E501 - 'is_system': (bool, none_type,), # noqa: E501 - 'targets': ([str], none_type,), # noqa: E501 + 'is_read_only': (bool,), # noqa: E501 + 'is_system': (bool,), # noqa: E501 + 'targets': ([str],), # noqa: E501 'code': (str, none_type,), # noqa: E501 } @@ -123,7 +123,7 @@ def discriminator(): @classmethod @convert_js_args_to_python_args - def _from_openapi_data(cls, id, options, type, is_deleted, name, is_enabled, is_required, is_global, *args, **kwargs): # noqa: E501 + def _from_openapi_data(cls, id, options, type, is_deleted, name, is_enabled, is_required, is_global, is_read_only, is_system, targets, *args, **kwargs): # noqa: E501 """CustomAttributeApiResult - a model defined in OpenAPI Args: @@ -135,11 +135,11 @@ def _from_openapi_data(cls, id, options, type, is_deleted, name, is_enabled, is_ is_enabled (bool): Indicates if the attribute is enabled is_required (bool): Indicates if the attribute value is mandatory to specify is_global (bool): Indicates if the attribute is available across all projects + is_read_only (bool): Indicates if the attribute is read-only + is_system (bool): Indicates if the attribute is system + targets ([str]): Collection of the attribute targets Defines where the attribute can be used (e.g., TestCases, AutoTestCases, TestPlans) Keyword Args: - is_read_only (bool): Indicates if the attribute is read-only. [optional] # noqa: E501 - is_system (bool): Indicates if the attribute is system. [optional] # noqa: E501 - targets ([str]): Collection of the attribute targets. [optional] # noqa: E501 _check_type (bool): if True, values for parameters in openapi_types will be type checked and a TypeError will be raised if the wrong type is input. @@ -178,9 +178,6 @@ def _from_openapi_data(cls, id, options, type, is_deleted, name, is_enabled, is_ _path_to_item = kwargs.pop('_path_to_item', ()) _configuration = kwargs.pop('_configuration', None) _visited_composed_classes = kwargs.pop('_visited_composed_classes', ()) - is_read_only = kwargs.pop('is_read_only', False) - is_system = kwargs.pop('is_system', False) - targets = kwargs.pop('targets', None) self = super(OpenApiModel, cls).__new__(cls) @@ -236,7 +233,7 @@ def _from_openapi_data(cls, id, options, type, is_deleted, name, is_enabled, is_ ]) @convert_js_args_to_python_args - def __init__(self, id, options, type, is_deleted, name, is_enabled, is_required, is_global, *args, **kwargs): # noqa: E501 + def __init__(self, id, options, type, is_deleted, name, is_enabled, is_required, is_global, is_read_only, is_system, targets, *args, **kwargs): # noqa: E501 """CustomAttributeApiResult - a model defined in OpenAPI Args: @@ -248,11 +245,11 @@ def __init__(self, id, options, type, is_deleted, name, is_enabled, is_required, is_enabled (bool): Indicates if the attribute is enabled is_required (bool): Indicates if the attribute value is mandatory to specify is_global (bool): Indicates if the attribute is available across all projects + is_read_only (bool): Indicates if the attribute is read-only + is_system (bool): Indicates if the attribute is system + targets ([str]): Collection of the attribute targets Defines where the attribute can be used (e.g., TestCases, AutoTestCases, TestPlans) Keyword Args: - is_read_only (bool): Indicates if the attribute is read-only. [optional] # noqa: E501 - is_system (bool): Indicates if the attribute is system. [optional] # noqa: E501 - targets ([str]): Collection of the attribute targets. [optional] # noqa: E501 _check_type (bool): if True, values for parameters in openapi_types will be type checked and a TypeError will be raised if the wrong type is input. @@ -291,9 +288,6 @@ def __init__(self, id, options, type, is_deleted, name, is_enabled, is_required, _path_to_item = kwargs.pop('_path_to_item', ()) _configuration = kwargs.pop('_configuration', None) _visited_composed_classes = kwargs.pop('_visited_composed_classes', ()) - is_read_only = kwargs.pop('is_read_only', False) - is_system = kwargs.pop('is_system', False) - targets = kwargs.pop('targets', None) if args: for arg in args: diff --git a/testit-python-commons/src/adapters_api/model/layer_source.py b/testit-python-commons/src/adapters_api/model/layer_source.py index d5092f64..dd2943f2 100644 --- a/testit-python-commons/src/adapters_api/model/layer_source.py +++ b/testit-python-commons/src/adapters_api/model/layer_source.py @@ -53,6 +53,7 @@ class LayerSource(ModelSimple): allowed_values = { ('value',): { 'MANUAL': "Manual", + 'REPORT': "Report", 'RUN': "Run", 'RULE': "Rule", }, @@ -106,10 +107,10 @@ def __init__(self, *args, **kwargs): Note that value can be passed either in args or in kwargs, but not in both. Args: - args[0] (str):, must be one of ["Manual", "Run", "Rule", ] # noqa: E501 + args[0] (str):, must be one of ["Manual", "Report", "Run", "Rule", ] # noqa: E501 Keyword Args: - value (str):, must be one of ["Manual", "Run", "Rule", ] # noqa: E501 + value (str):, must be one of ["Manual", "Report", "Run", "Rule", ] # noqa: E501 _check_type (bool): if True, values for parameters in openapi_types will be type checked and a TypeError will be raised if the wrong type is input. @@ -200,10 +201,10 @@ def _from_openapi_data(cls, *args, **kwargs): Note that value can be passed either in args or in kwargs, but not in both. Args: - args[0] (str):, must be one of ["Manual", "Run", "Rule", ] # noqa: E501 + args[0] (str):, must be one of ["Manual", "Report", "Run", "Rule", ] # noqa: E501 Keyword Args: - value (str):, must be one of ["Manual", "Run", "Rule", ] # noqa: E501 + value (str):, must be one of ["Manual", "Report", "Run", "Rule", ] # noqa: E501 _check_type (bool): if True, values for parameters in openapi_types will be type checked and a TypeError will be raised if the wrong type is input. diff --git a/testit-python-commons/src/testit.py b/testit-python-commons/src/testit.py index 7c7bd993..55b2949d 100644 --- a/testit-python-commons/src/testit.py +++ b/testit-python-commons/src/testit.py @@ -5,6 +5,7 @@ externalID, externalId, labels, + layer, tags, link, links, @@ -32,6 +33,7 @@ message ) from testit_python_commons.models import LinkType +from testit_python_commons.models.test_layers import TestLayers from testit_python_commons.step import step __all__ = [ @@ -45,6 +47,7 @@ 'title', 'description', 'labels', + 'layer', 'tags', 'link', 'links', @@ -65,5 +68,6 @@ 'addTags', 'addParameter', 'step', - 'LinkType' + 'LinkType', + 'TestLayers', ] \ No newline at end of file diff --git a/testit-python-commons/src/testit_python_commons/client/converter.py b/testit-python-commons/src/testit_python_commons/client/converter.py index b7d017c1..4b53d62e 100644 --- a/testit-python-commons/src/testit_python_commons/client/converter.py +++ b/testit-python-commons/src/testit_python_commons/client/converter.py @@ -36,6 +36,8 @@ TestStatusApiType, CreateLinkApiModel, LabelApiModel, + AutoTestCreateApiModelLayer, + LayerSource, ) from testit_python_commons.models.link import Link @@ -241,6 +243,36 @@ def labels_to_label_api_models(labels: List) -> Optional[List[LabelApiModel]]: return label_models or None + @staticmethod + @adapter_logger + def layer_to_api_model(layer: Optional[str]) -> Optional[AutoTestCreateApiModelLayer]: + if not layer or not str(layer).strip(): + return None + + return AutoTestCreateApiModelLayer( + name=str(layer).strip(), + source=LayerSource("Run"), + ) + + @classmethod + @adapter_logger + def _autotest_layer_kwargs_for_create(cls, test_result: TestResult) -> dict: + layer = cls.layer_to_api_model(test_result.get_layer()) + if not layer: + return {} + + return {'layer': layer} + + @classmethod + @adapter_logger + def _autotest_layer_kwargs_for_update(cls, test_result: TestResult) -> dict: + kwargs = {'reset_layer': False} + layer = cls.layer_to_api_model(test_result.get_layer()) + if layer: + kwargs['layer'] = layer + + return kwargs + @classmethod @adapter_logger def test_result_to_autotest_post_model( @@ -265,7 +297,8 @@ def test_result_to_autotest_post_model( labels=cls.labels_to_label_api_models(test_result.get_labels()), tags=test_result.get_tags(), should_create_work_item=test_result.get_automatic_creation_test_cases(), - external_key=test_result.get_external_key() + external_key=test_result.get_external_key(), + **cls._autotest_layer_kwargs_for_create(test_result), ) @classmethod @@ -292,7 +325,8 @@ def test_result_to_create_autotest_request( labels=cls.labels_to_label_api_models(test_result.get_labels()), tags=test_result.get_tags(), should_create_work_item=test_result.get_automatic_creation_test_cases(), - external_key=test_result.get_external_key() + external_key=test_result.get_external_key(), + **cls._autotest_layer_kwargs_for_create(test_result), ) @classmethod @@ -305,7 +339,6 @@ def test_result_to_autotest_put_model( external_id=test_result.get_external_id(), project_id=project_id, name=test_result.get_autotest_name(), - reset_layer=False, steps=cls.step_results_to_autotest_steps_model( test_result.get_step_results()), setup=cls.step_results_to_autotest_steps_model( @@ -319,7 +352,8 @@ def test_result_to_autotest_put_model( links=cls.links_to_links_put_model(test_result.get_links()), labels=cls.labels_to_label_api_models(test_result.get_labels()), tags=test_result.get_tags(), - external_key=test_result.get_external_key() + external_key=test_result.get_external_key(), + **cls._autotest_layer_kwargs_for_update(test_result), ) @classmethod @@ -332,7 +366,6 @@ def test_result_to_update_autotest_request( external_id=test_result.get_external_id(), project_id=project_id, name=test_result.get_autotest_name(), - reset_layer=False, steps=cls.step_results_to_autotest_steps_model( test_result.get_step_results()), setup=cls.step_results_to_autotest_steps_model( @@ -346,7 +379,8 @@ def test_result_to_update_autotest_request( links=cls.links_to_links_put_model(test_result.get_links()), labels=cls.labels_to_label_api_models(test_result.get_labels()), tags=test_result.get_tags(), - external_key=test_result.get_external_key() + external_key=test_result.get_external_key(), + **cls._autotest_layer_kwargs_for_update(test_result), ) @classmethod diff --git a/testit-python-commons/src/testit_python_commons/decorators.py b/testit-python-commons/src/testit_python_commons/decorators.py index 7f6a0701..0d1ac353 100644 --- a/testit-python-commons/src/testit_python_commons/decorators.py +++ b/testit-python-commons/src/testit_python_commons/decorators.py @@ -171,6 +171,18 @@ def outer(function): return outer +@adapter_logger +def layer(test_layer: str): + """ + specifies the test pyramid layer for the autotest card + """ + def outer(function): + function.test_layer = test_layer + return inner(function) + + return outer + + @Utils.deprecated('Use "links" instead.') @adapter_logger def link(url: str, title: str = None, type: str = None, description: str = None): # noqa: A002,VNE003 diff --git a/testit-python-commons/src/testit_python_commons/models/test_layers.py b/testit-python-commons/src/testit_python_commons/models/test_layers.py new file mode 100644 index 00000000..89f95e83 --- /dev/null +++ b/testit-python-commons/src/testit_python_commons/models/test_layers.py @@ -0,0 +1,8 @@ +class TestLayers: + E2E = "E2E" + UI = "UI" + API = "API" + CONTRACT = "Contract" + INTEGRATION = "Integration" + COMPONENT = "Component" + UNIT = "Unit" diff --git a/testit-python-commons/src/testit_python_commons/models/test_result.py b/testit-python-commons/src/testit_python_commons/models/test_result.py index 4ffbe913..fe29c45d 100644 --- a/testit-python-commons/src/testit_python_commons/models/test_result.py +++ b/testit-python-commons/src/testit_python_commons/models/test_result.py @@ -33,6 +33,7 @@ class TestResult: __properties: dict = {} __automatic_creation_test_cases: bool = False __external_key: str = None + __layer: str = None @adapter_logger def set_external_id(self, external_id: str): @@ -295,3 +296,13 @@ def set_external_key(self, external_key: str): @adapter_logger def get_external_key(self) -> str: return self.__external_key + + @adapter_logger + def set_layer(self, layer: str): + self.__layer = HtmlEscapeUtils.escape_html_tags(layer) if layer else None + + return self + + @adapter_logger + def get_layer(self) -> str: + return self.__layer diff --git a/testit-python-commons/tests/client/test_converter_layer.py b/testit-python-commons/tests/client/test_converter_layer.py new file mode 100644 index 00000000..8c9c221a --- /dev/null +++ b/testit-python-commons/tests/client/test_converter_layer.py @@ -0,0 +1,105 @@ +from unittest.mock import Mock + +from adapters_api.models import LayerSource + +from testit_python_commons.client.converter import Converter +from testit_python_commons.models.test_layers import TestLayers + + +def _mock_test_result(**overrides): + test_result = Mock() + defaults = { + 'get_external_id': 'ext-1', + 'get_autotest_name': 'test name', + 'get_step_results': [], + 'get_setup_results': [], + 'get_teardown_results': [], + 'get_namespace': 'ns', + 'get_classname': 'cls', + 'get_title': 'title', + 'get_description': None, + 'get_links': [], + 'get_labels': [], + 'get_tags': [], + 'get_automatic_creation_test_cases': False, + 'get_external_key': None, + 'get_layer': None, + } + for key, value in defaults.items(): + getter = overrides.pop(key, value) + getattr(test_result, key).return_value = getter + return test_result + + +def test_layer_to_api_model_with_recommended_constant(): + layer = Converter.layer_to_api_model(TestLayers.API) + + assert layer.name == 'API' + assert layer.source == LayerSource('Run') + + +def test_layer_to_api_model_with_custom_string(): + layer = Converter.layer_to_api_model('my-custom-layer') + + assert layer.name == 'my-custom-layer' + assert layer.source == LayerSource('Run') + + +def test_layer_to_api_model_empty(): + assert Converter.layer_to_api_model(None) is None + assert Converter.layer_to_api_model('') is None + assert Converter.layer_to_api_model(' ') is None + + +def test_create_autotest_request_includes_layer_when_set(): + test_result = _mock_test_result(get_layer='API') + + request = Converter.test_result_to_create_autotest_request(test_result, 'project-id') + + assert request.layer.name == 'API' + assert request.layer.source == LayerSource('Run') + + +def test_create_autotest_request_omits_layer_when_not_set(): + test_result = _mock_test_result() + + request = Converter.test_result_to_create_autotest_request(test_result, 'project-id') + + assert 'layer' not in request._data_store + + +def test_update_autotest_request_includes_layer_and_reset_layer_when_set(): + test_result = _mock_test_result(get_layer='API') + + request = Converter.test_result_to_update_autotest_request(test_result, 'project-id') + + assert request.layer.name == 'API' + assert request.layer.source == LayerSource('Run') + assert request.reset_layer is False + + +def test_update_autotest_request_always_includes_reset_layer_false(): + test_result = _mock_test_result() + + request = Converter.test_result_to_update_autotest_request(test_result, 'project-id') + + assert request.reset_layer is False + assert 'layer' not in request._data_store + + +def test_bulk_update_model_includes_reset_layer_false_when_layer_set(): + test_result = _mock_test_result(get_layer='API') + + model = Converter.test_result_to_autotest_put_model(test_result, 'project-id') + + assert model.layer.name == 'API' + assert model.reset_layer is False + + +def test_bulk_update_model_always_includes_reset_layer_false(): + test_result = _mock_test_result() + + model = Converter.test_result_to_autotest_put_model(test_result, 'project-id') + + assert model.reset_layer is False + assert 'layer' not in model._data_store diff --git a/update_versions.sh b/update_versions.sh index 2a5734b4..d79a943f 100644 --- a/update_versions.sh +++ b/update_versions.sh @@ -1,6 +1,6 @@ #!/bin/bash -NEW_VERSION="5.1.6" +NEW_VERSION="5.2.0" echo "Updating all adapters to version: $NEW_VERSION"