diff --git a/examples/behave/tests/features/attachments.feature b/examples/behave/tests/features/attachments.feature new file mode 100644 index 00000000..fe931a03 --- /dev/null +++ b/examples/behave/tests/features/attachments.feature @@ -0,0 +1,40 @@ +Feature: Test Attachments + As a test developer + I want to attach files and content to my test results + So that I can provide additional context for test failures + + @qase.id:10 + Scenario: Attach file to test + Given I have a test with attachments + When I attach a file to the test + Then the attachments should be included in the test result + + @qase.id:20 + Scenario: Attach text content + Given I have a test with attachments + When I attach content as text + Then the attachments should be included in the test result + + @qase.id:30 + Scenario: Attach JSON data + Given I have a test with attachments + When I attach JSON data + Then the attachments should be included in the test result + + @qase.id:40 + Scenario: Attach screenshot + Given I want to attach a screenshot + When I attach the screenshot + Then the attachments should be included in the test result + + @qase.id:50 + Scenario: Add comments to test + Given I have a test with attachments + When I add a comment about the test + Then the attachments should be included in the test result + + @qase.id:60 + Scenario: Add debug information + Given I have a test with attachments + When I add debug information + Then the attachments should be included in the test result diff --git a/examples/behave/tests/features/steps/attachment_steps.py b/examples/behave/tests/features/steps/attachment_steps.py new file mode 100644 index 00000000..e137079c --- /dev/null +++ b/examples/behave/tests/features/steps/attachment_steps.py @@ -0,0 +1,72 @@ +from behave import * +from qase.behave import qase +import tempfile +import os + + +@given('I have a test with attachments') +def step_impl(context): + # Create a temporary file for testing + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("This is a test attachment content") + context.temp_file_path = f.name + + + +@when('I attach a file to the test') +def step_impl(context): + # Attach the temporary file + qase.attach(file_path=context.temp_file_path) + + +@when('I attach content as text') +def step_impl(context): + # Attach text content directly + qase.attach(content="This is some text content", file_name="text_content.txt") + + +@when('I attach JSON data') +def step_impl(context): + # Attach JSON content + json_data = '{"test": "data", "value": 123}' + qase.attach(content=json_data, file_name="test_data.json", mime_type="application/json") + + +@then('the attachments should be included in the test result') +def step_impl(context): + # # Clean up temporary file + if hasattr(context, 'temp_file_path') and os.path.exists(context.temp_file_path): + os.unlink(context.temp_file_path) + + + +@given('I want to attach a screenshot') +def step_impl(context): + # Simulate creating a screenshot (in real scenario, this would be actual screenshot data) + context.screenshot_data = b"fake_screenshot_data" + pass + + +@when('I attach the screenshot') +def step_impl(context): + # Attach binary data (screenshot) + qase.attach( + content=context.screenshot_data, + file_name="screenshot.png", + mime_type="image/png" + ) + + +@when('I add a comment about the test') +def step_impl(context): + # Add a comment to the test result + qase.comment("Screenshot captured successfully") + qase.comment("User was logged in at the time of capture") + + +@when('I add debug information') +def step_impl(context): + # Add debug information as comments + import datetime + qase.comment(f"Debug: Current timestamp is {datetime.datetime.now()}") + qase.comment("Debug: All elements were found and interacted with") diff --git a/qase-behave/README.md b/qase-behave/README.md index dd20d0f0..6531cf11 100644 --- a/qase-behave/README.md +++ b/qase-behave/README.md @@ -31,6 +31,8 @@ parameterize your tests. For detailed instructions on using annotations and methods, refer to [Usage](docs/usage.md). +For information about attaching files and content or adding comments to test results, see [Attachments](docs/ATTACHMENTS.md). + For example: ```gherkin @@ -106,4 +108,3 @@ We maintain the reporter on [LTS versions of Python](https://devguide.python.org -[auth]: https://developers.qase.io/#authentication diff --git a/qase-behave/changelog.md b/qase-behave/changelog.md index 83e71feb..662f6aad 100644 --- a/qase-behave/changelog.md +++ b/qase-behave/changelog.md @@ -1,3 +1,37 @@ +# qase-behave 1.1.3 + +## What's new + +- Added support for file and content attachments to test results using `qase.attach()` method. +- Added support for adding comments to test results using `qase.comment()` method. +- Improved MIME type detection for attachments. +- Updated documentation with examples and usage instructions. + +### Attachment Usage + +```python +from qase.behave import qase + +# Attach a file +qase.attach(file_path="/path/to/file.txt") + +# Attach content directly +qase.attach(content="test data", file_name="data.txt") + +# Attach binary data +qase.attach(content=b"binary data", file_name="screenshot.png", mime_type="image/png") +``` + +### Comment Usage + +```python +from qase.behave import qase + +# Add comments to test results +qase.comment("Test completed successfully") +qase.comment("Debug info: user logged in") +``` + # qase-behave 1.1.2 ## What's new diff --git a/qase-behave/docs/ATTACHMENTS.md b/qase-behave/docs/ATTACHMENTS.md new file mode 100644 index 00000000..67d0715d --- /dev/null +++ b/qase-behave/docs/ATTACHMENTS.md @@ -0,0 +1,155 @@ +# Attachments in Qase Behave + +Qase Behave supports attaching files and content to test results. This allows you to provide additional context for test failures, such as screenshots, logs, or data files. + +## Usage + +### Import the qase object + +```python +from qase.behave import qase +``` + +### Attach a file + +```python +@given('I have a test with a file') +def step_impl(context): + # Attach an existing file + qase.attach(file_path="/path/to/your/file.txt") +``` + +### Attach content directly + +```python +@when('I attach text content') +def step_impl(context): + # Attach text content + qase.attach(content="This is some text content", file_name="content.txt") +``` + +### Attach binary data + +```python +@when('I attach a screenshot') +def step_impl(context): + # Attach binary data (e.g., screenshot) + screenshot_data = b"binary_screenshot_data" + qase.attach( + content=screenshot_data, + file_name="screenshot.png", + mime_type="image/png" + ) +``` + +### Attach JSON data + +```python +@when('I attach JSON data') +def step_impl(context): + json_data = '{"test": "data", "value": 123}' + qase.attach( + content=json_data, + file_name="test_data.json", + mime_type="application/json" + ) +``` + +## Method Signature + +```python +qase.attach( + file_path: Optional[str] = None, + content: Optional[Union[str, bytes]] = None, + file_name: Optional[str] = None, + mime_type: Optional[str] = None +) -> None +``` + +### Parameters + +- **file_path**: Path to the file to attach (mutually exclusive with `content`) +- **content**: Content to attach as string or bytes (mutually exclusive with `file_path`) +- **file_name**: Name for the attachment (auto-detected from `file_path` if not provided) +- **mime_type**: MIME type of the attachment (auto-detected if not provided) + +### Notes + +- Either `file_path` or `content` must be provided, but not both +- If `file_name` is not provided, it will be derived from `file_path` or default to "attachment.txt" +- If `mime_type` is not provided, it will be auto-detected from the file extension or default to "text/plain" +- Attachments are automatically included in the test result when the scenario completes + +## Examples + +### Complete Example + +```python +from behave import * +from qase.behave import qase +import tempfile +import os + +@given('I have a test with attachments') +def step_impl(context): + # Create a temporary file for testing + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("This is a test attachment content") + context.temp_file_path = f.name + +@when('I attach multiple items') +def step_impl(context): + # Attach the temporary file + qase.attach(file_path=context.temp_file_path) + + # Attach text content + qase.attach(content="Additional text content", file_name="additional.txt") + + # Attach JSON data + json_data = '{"status": "success", "timestamp": "2024-01-01T00:00:00Z"}' + qase.attach(content=json_data, file_name="status.json", mime_type="application/json") + +@then('clean up temporary files') +def step_impl(context): + if hasattr(context, 'temp_file_path') and os.path.exists(context.temp_file_path): + os.unlink(context.temp_file_path) +``` + +### Error Handling + +The `qase.attach()` method will raise appropriate exceptions: + +- `RuntimeError`: If called outside of an active scenario +- `ValueError`: If both `file_path` and `content` are provided, or if neither is provided +- `FileNotFoundError`: If the specified file path doesn't exist (when using `file_path`) + +## Adding Comments + +You can also add comments to your test scenarios using the `qase.comment()` method: + +```python +@when('I add a comment') +def step_impl(context): + qase.comment("This is a test comment") + qase.comment("Another comment with additional context") +``` + +### Comment Method Signature + +```python +qase.comment(message: str) -> None +``` + +### Parameters + +- **message**: The comment message to add to the scenario + +### Notes + +- Comments are automatically included in the test result when the scenario completes +- Multiple comments are concatenated with newlines +- If no active scenario is available, a RuntimeError will be raised + +## Integration with Qase TestOps + +Attachments and comments added using `qase.attach()` and `qase.comment()` will be automatically uploaded to Qase TestOps when the test results are submitted. The attachments and comments will be associated with the corresponding test case and will be available in the Qase TestOps interface. diff --git a/qase-behave/pyproject.toml b/qase-behave/pyproject.toml index 9ab98ca1..b7086335 100644 --- a/qase-behave/pyproject.toml +++ b/qase-behave/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "qase-behave" -version = "1.1.2" +version = "1.1.3" description = "Qase Behave Plugin for Qase TestOps and Qase Report" readme = "README.md" keywords = ["qase", "behave", "plugin", "testops", "report", "qase reporting", "test observability"] @@ -70,9 +70,14 @@ extras = where = ["src"] [tool.pytest.ini_options] -addopts = "--cov-report=term-missing --verbose" +addopts = "--cov-report=term-missing --verbose --tb=short --strict-markers" norecursedirs = ["dist", "build", ".tox"] testpaths = ["tests"] +markers = [ + "unit: Unit tests", + "integration: Integration tests", + "slow: Slow running tests" +] [tool.flake8] exclude = [".tox", "build", "dist", ".eggs"] diff --git a/qase-behave/requirements.txt b/qase-behave/requirements.txt index cd80a076..cb94e19c 100644 --- a/qase-behave/requirements.txt +++ b/qase-behave/requirements.txt @@ -1,3 +1,4 @@ attrs==23.2.0 behave>=1.2.6 -qase-python-commons~=3.5.0 +qase-python-commons~=3.5.1 +pytest>=7.0.0 diff --git a/qase-behave/src/qase/behave/__init__.py b/qase-behave/src/qase/behave/__init__.py index e69de29b..f0e3490e 100644 --- a/qase-behave/src/qase/behave/__init__.py +++ b/qase-behave/src/qase/behave/__init__.py @@ -0,0 +1,4 @@ +from .formatter import QaseFormatter +from .qase_global import qase + +__all__ = ['QaseFormatter', 'qase'] diff --git a/qase-behave/src/qase/behave/formatter.py b/qase-behave/src/qase/behave/formatter.py index c5629960..bf974fd1 100644 --- a/qase-behave/src/qase/behave/formatter.py +++ b/qase-behave/src/qase/behave/formatter.py @@ -4,6 +4,7 @@ from qase.commons.reporters import QaseCoreReporter from qase.behave.utils import filter_scenarios, parse_scenario, parse_step +from qase.behave.qase_global import qase class QaseFormatter(Formatter): @@ -38,6 +39,8 @@ def scenario(self, scenario: Scenario): self.reporter.add_result(self.__current_scenario) self.__current_scenario = None self.__current_scenario = parse_scenario(scenario) + # Update global qase object with current scenario + qase._set_current_scenario(self.__current_scenario) pass def result(self, result: Step): diff --git a/qase-behave/src/qase/behave/qase_global.py b/qase-behave/src/qase/behave/qase_global.py new file mode 100644 index 00000000..e3611dfb --- /dev/null +++ b/qase-behave/src/qase/behave/qase_global.py @@ -0,0 +1,118 @@ +import os +import mimetypes +import logging +from typing import Optional, Union +from qase.commons.models import Attachment + +logger = logging.getLogger(__name__) + + +class QaseGlobal: + """ + Global Qase object for behave integration. + Provides attachment functionality for test scenarios. + """ + + def __init__(self): + self._current_scenario = None + + def _set_current_scenario(self, scenario): + """Set the current scenario for attachment tracking""" + self._current_scenario = scenario + + def attach(self, + file_path: Optional[str] = None, + content: Optional[Union[str, bytes]] = None, + file_name: Optional[str] = None, + mime_type: Optional[str] = None) -> None: + """ + Attach a file or content to the current test scenario. + + Args: + file_path: Path to the file to attach + content: Content to attach (string or bytes) + file_name: Name for the attachment (if not provided, will be derived from file_path) + mime_type: MIME type of the attachment (if not provided, will be auto-detected) + """ + + if self._current_scenario is None: + raise RuntimeError("No active scenario. Cannot attach file.") + + if file_path and content: + raise ValueError("Either file_path or content must be provided, not both.") + + if not file_path and not content: + raise ValueError("Either file_path or content must be provided.") + + # Determine file name + if file_name is None: + if file_path: + file_name = os.path.basename(file_path) + else: + file_name = "attachment.txt" + + # Determine MIME type + if mime_type is None: + if file_path: + mime_type, _ = mimetypes.guess_type(file_path) + elif file_name: + mime_type, _ = mimetypes.guess_type(file_name) + elif isinstance(content, bytes): + mime_type = "application/octet-stream" + else: + mime_type = "text/plain" + + if mime_type is None: + mime_type = "application/octet-stream" + + # Create attachment + if file_path: + attachment = Attachment( + file_name=file_name, + mime_type=mime_type, + file_path=file_path + ) + else: + attachment = Attachment( + file_name=file_name, + mime_type=mime_type, + content=content + ) + + # Add attachment to current scenario + if not hasattr(self._current_scenario, 'attachments'): + self._current_scenario.attachments = [] + + self._current_scenario.attachments.append(attachment) + + + def comment(self, message: str) -> None: + """ + Add a comment to the current test scenario. + + This method allows you to add comments that will be included in the test result. + Comments are useful for providing additional context about test execution, + debugging information, or any other relevant notes. + + Args: + message: The comment message to add to the scenario + + Raises: + RuntimeError: If no active scenario is available + + Example: + >>> qase.comment("Test completed successfully") + >>> qase.comment("Debug info: user logged in") + """ + if self._current_scenario is None: + raise RuntimeError("No active scenario. Cannot add comment.") + + # If this is the first comment or message is None, set it directly + if not hasattr(self._current_scenario, 'message') or self._current_scenario.message is None: + self._current_scenario.message = message + else: + # If message already exists, append the new comment + self._current_scenario.message = self._current_scenario.message + '\n' + message + +# Global instance +qase = QaseGlobal() diff --git a/qase-behave/tests/test_attachments.py b/qase-behave/tests/test_attachments.py new file mode 100644 index 00000000..19377f81 --- /dev/null +++ b/qase-behave/tests/test_attachments.py @@ -0,0 +1,296 @@ +import pytest +import tempfile +import os +from qase.behave.qase_global import QaseGlobal, qase +from qase.commons.models import Result +import mimetypes + + +@pytest.fixture +def qase_global(): + """Fixture for QaseGlobal instance""" + return QaseGlobal() + + +@pytest.fixture +def test_scenario(): + """Fixture for test scenario""" + return Result("Test Scenario", "test_signature") + + +@pytest.fixture +def qase_with_scenario(qase_global, test_scenario): + """Fixture for QaseGlobal with active scenario""" + qase_global._set_current_scenario(test_scenario) + return qase_global, test_scenario + + +@pytest.mark.unit +def test_attach_file(qase_with_scenario): + """Test attaching a file""" + qase_global, test_scenario = qase_with_scenario + + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("test content") + temp_path = f.name + + try: + qase_global.attach(file_path=temp_path) + + assert len(test_scenario.attachments) == 1 + attachment = test_scenario.attachments[0] + assert attachment.file_name == os.path.basename(temp_path) + assert attachment.file_path == temp_path + assert attachment.content == 'null' + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +@pytest.mark.unit +def test_attach_content(qase_with_scenario): + """Test attaching content directly""" + qase_global, test_scenario = qase_with_scenario + + content = "test content" + qase_global.attach(content=content, file_name="test.txt") + + assert len(test_scenario.attachments) == 1 + attachment = test_scenario.attachments[0] + assert attachment.file_name == "test.txt" + assert attachment.content == content + assert attachment.file_path is None + + +@pytest.mark.unit +def test_attach_binary_content(qase_with_scenario): + """Test attaching binary content""" + qase_global, test_scenario = qase_with_scenario + + binary_content = b"binary data" + qase_global.attach( + content=binary_content, + file_name="test.bin", + mime_type="application/octet-stream" + ) + + assert len(test_scenario.attachments) == 1 + attachment = test_scenario.attachments[0] + assert attachment.file_name == "test.bin" + assert attachment.content == binary_content + assert attachment.mime_type == "application/octet-stream" + + +@pytest.mark.unit +def test_auto_detect_mime_type(qase_with_scenario): + """Test automatic MIME type detection""" + qase_global, test_scenario = qase_with_scenario + + qase_global.attach(content="json data", file_name="data.json") + + attachment = test_scenario.attachments[0] + print(attachment) + assert attachment.mime_type == "application/json" + + +@pytest.mark.unit +def test_auto_detect_filename(qase_with_scenario): + """Test automatic filename detection""" + qase_global, test_scenario = qase_with_scenario + + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("test content") + temp_path = f.name + + try: + qase_global.attach(file_path=temp_path) + + attachment = test_scenario.attachments[0] + assert attachment.file_name == os.path.basename(temp_path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +@pytest.mark.unit +def test_no_scenario_error(): + """Test error when no scenario is active""" + qase_no_scenario = QaseGlobal() + + with pytest.raises(RuntimeError, match="No active scenario"): + qase_no_scenario.attach(content="test") + + +@pytest.mark.unit +def test_both_file_path_and_content_error(qase_with_scenario): + """Test error when both file_path and content are provided""" + qase_global, _ = qase_with_scenario + + with pytest.raises(ValueError, match="Either file_path or content must be provided"): + qase_global.attach(file_path="/path/to/file", content="content") + + +@pytest.mark.unit +def test_neither_file_path_nor_content_error(qase_with_scenario): + """Test error when neither file_path nor content is provided""" + qase_global, _ = qase_with_scenario + + with pytest.raises(ValueError, match="Either file_path or content must be provided"): + qase_global.attach() + + +@pytest.mark.unit +def test_multiple_attachments(qase_with_scenario): + """Test adding multiple attachments""" + qase_global, test_scenario = qase_with_scenario + + qase_global.attach(content="first", file_name="first.txt") + qase_global.attach(content="second", file_name="second.txt") + + assert len(test_scenario.attachments) == 2 + assert test_scenario.attachments[0].file_name == "first.txt" + assert test_scenario.attachments[1].file_name == "second.txt" + + +@pytest.mark.unit +def test_default_mime_type_for_text(qase_with_scenario): + """Test default MIME type for text content""" + qase_global, test_scenario = qase_with_scenario + + qase_global.attach(content="plain text") + + attachment = test_scenario.attachments[0] + assert attachment.mime_type == "text/plain" + assert attachment.file_name == "attachment.txt" + + +@pytest.mark.unit +def test_default_mime_type_for_binary(qase_with_scenario): + """Test default MIME type for binary content""" + qase_global, test_scenario = qase_with_scenario + + qase_global.attach(content=b"binary data", file_name="data.bin") + + attachment = test_scenario.attachments[0] + assert attachment.mime_type == "application/octet-stream" + + +@pytest.mark.unit +def test_attach_with_custom_mime_type(qase_with_scenario): + """Test attaching with custom MIME type""" + qase_global, test_scenario = qase_with_scenario + + qase_global.attach( + content="custom content", + file_name="custom.txt", + mime_type="application/custom" + ) + + attachment = test_scenario.attachments[0] + assert attachment.mime_type == "application/custom" + assert attachment.file_name == "custom.txt" + + +@pytest.mark.unit +def test_attach_file_with_custom_name(qase_with_scenario): + """Test attaching file with custom name""" + qase_global, test_scenario = qase_with_scenario + + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("test content") + temp_path = f.name + + try: + qase_global.attach(file_path=temp_path, file_name="custom_name.txt") + + attachment = test_scenario.attachments[0] + assert attachment.file_name == "custom_name.txt" + assert attachment.file_path == temp_path + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +# Comment method tests +@pytest.mark.unit +def test_comment_single_message(qase_with_scenario): + """Test adding a single comment to scenario""" + qase_global, test_scenario = qase_with_scenario + + qase_global.comment("Test completed successfully") + + assert test_scenario.message == "Test completed successfully" + + +@pytest.mark.unit +def test_comment_multiple_messages(qase_with_scenario): + """Test adding multiple comments to scenario""" + qase_global, test_scenario = qase_with_scenario + + qase_global.comment("First comment") + qase_global.comment("Second comment") + qase_global.comment("Third comment") + + expected_message = "First comment\nSecond comment\nThird comment" + assert test_scenario.message == expected_message + + +@pytest.mark.unit +def test_comment_empty_message(qase_with_scenario): + """Test adding empty comment to scenario""" + qase_global, test_scenario = qase_with_scenario + + qase_global.comment("") + + assert test_scenario.message == "" + + +@pytest.mark.unit +def test_comment_with_newlines(qase_with_scenario): + """Test adding comment with newlines""" + qase_global, test_scenario = qase_with_scenario + + qase_global.comment("Line 1") + qase_global.comment("Line 2\nWith internal newline") + qase_global.comment("Line 3") + + expected_message = "Line 1\nLine 2\nWith internal newline\nLine 3" + assert test_scenario.message == expected_message + + +@pytest.mark.unit +def test_comment_no_scenario_error(): + """Test error when trying to add comment without active scenario""" + qase_no_scenario = QaseGlobal() + + with pytest.raises(RuntimeError, match="No active scenario"): + qase_no_scenario.comment("test comment") + + +@pytest.mark.unit +def test_comment_overwrite_existing_message(qase_with_scenario): + """Test that comment overwrites existing message attribute when message is None""" + qase_global, test_scenario = qase_with_scenario + + # Ensure message is None initially + test_scenario.message = None + + # Add new comment + qase_global.comment("New comment") + + assert test_scenario.message == "New comment" + + +@pytest.mark.unit +def test_comment_preserves_existing_message(qase_with_scenario): + """Test that comment preserves existing message when adding multiple""" + qase_global, test_scenario = qase_with_scenario + + # Set existing message + test_scenario.message = "Existing message" + + # Add new comment + qase_global.comment("Additional comment") + + expected_message = "Existing message\nAdditional comment" + assert test_scenario.message == expected_message