diff --git a/pytest.ini b/pytest.ini index be6979d4..646964c6 100644 --- a/pytest.ini +++ b/pytest.ini @@ -9,6 +9,7 @@ addopts = -ra testpaths = tests norecursedirs = .markitect_workspace .git __pycache__ .pytest_cache +asyncio_mode = auto python_files = test_*.py *_test.py python_classes = Test* python_functions = test_* diff --git a/tests/conftest.py b/tests/conftest.py index 4d68778d..c3f3230c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,13 +14,12 @@ from typing import Generator, Dict, Any import sqlite3 import os +# Import async test utilities +from tests.utils.assertions import cleanup_async_mocks, create_async_mock_that_returns -@pytest.fixture(scope="session") -def event_loop(): - """Create an instance of the default event loop for the test session.""" - loop = asyncio.new_event_loop() - yield loop - loop.close() + +# Note: event_loop fixture is now handled by pytest-asyncio with asyncio_mode=auto +# This replaces the manual event loop management for better async test support @pytest.fixture(scope="session") @@ -261,6 +260,44 @@ def async_test_timeout(): return 30.0 # 30 seconds +@pytest.fixture +def async_cleanup(): + """Fixture to help with async test cleanup and prevent coroutine warnings.""" + mocks_to_cleanup = [] + + def register_mock(mock): + """Register a mock for cleanup.""" + mocks_to_cleanup.append(mock) + return mock + + yield register_mock + + # Cleanup all registered mocks + cleanup_async_mocks(*mocks_to_cleanup) + + +@pytest.fixture +def async_mock_client(async_cleanup): + """Provide a properly configured async HTTP client mock.""" + mock_client = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = create_async_mock_that_returns({"status": "success"}) + mock_response.text = create_async_mock_that_returns('{"status": "success"}') + + # Configure the mock client methods + mock_client.get = create_async_mock_that_returns(mock_response) + mock_client.post = create_async_mock_that_returns(mock_response) + mock_client.put = create_async_mock_that_returns(mock_response) + mock_client.delete = create_async_mock_that_returns(mock_response) + + # Register for cleanup + async_cleanup(mock_client) + async_cleanup(mock_response) + + return mock_client + + # Test markers configuration def pytest_configure(config): """Configure pytest markers.""" diff --git a/tests/test_issue_59_gitea_plugin.py b/tests/test_issue_59_gitea_plugin.py index 5ec37d93..826a7855 100644 --- a/tests/test_issue_59_gitea_plugin.py +++ b/tests/test_issue_59_gitea_plugin.py @@ -9,6 +9,9 @@ import pytest from unittest.mock import Mock, patch, AsyncMock from typing import List, Dict, Any +# Import async test utilities +from tests.utils.assertions import AsyncTestCase, create_async_mock_that_returns, create_async_mock_that_raises + # Import classes we'll implement # Note: These imports will fail initially (RED phase) from markitect.issues.plugins.gitea import GiteaPlugin @@ -63,11 +66,12 @@ class TestGiteaPluginInitialization: pass -class TestGiteaPluginListIssues: +class TestGiteaPluginListIssues(AsyncTestCase): """Test suite for listing issues through Gitea plugin.""" def setup_method(self): """Set up test fixtures.""" + super().setup_method() self.config = {'url': 'http://test.com', 'repo': 'test/repo'} @patch('markitect.issues.plugins.gitea.GiteaIssueRepository') @@ -75,28 +79,26 @@ class TestGiteaPluginListIssues: """Test listing all issues regardless of state.""" mock_repo = Mock() mock_repo_class.return_value = mock_repo - mock_repo.get_issues = AsyncMock() # Mock issues data mock_issues = [Mock(spec=Issue), Mock(spec=Issue)] - mock_repo.get_issues.return_value = mock_issues plugin = GiteaPlugin(self.config) - # Use asyncio.run in actual implementation - with patch('asyncio.run') as mock_run: - mock_run.return_value = mock_issues - issues = plugin.list_issues(state='all') + # Mock the async method directly to avoid creating real coroutines + plugin._list_issues_async = self.create_async_mock(return_value=mock_issues) - assert len(issues) == 2 - assert all(isinstance(issue, Mock) for issue in issues) + issues = plugin.list_issues(state='all') + + assert len(issues) == 2 + assert all(isinstance(issue, Mock) for issue in issues) @patch('markitect.issues.plugins.gitea.GiteaIssueRepository') def test_list_open_issues_only(self, mock_repo_class): """Test listing only open issues.""" mock_repo = Mock() mock_repo_class.return_value = mock_repo - mock_repo.get_issues = AsyncMock() + mock_repo.get_issues = self.create_async_mock(return_value=[]) plugin = GiteaPlugin(self.config) @@ -112,7 +114,7 @@ class TestGiteaPluginListIssues: """Test listing only closed issues.""" mock_repo = Mock() mock_repo_class.return_value = mock_repo - mock_repo.get_issues = AsyncMock() + mock_repo.get_issues = self.create_async_mock(return_value=[]) plugin = GiteaPlugin(self.config) @@ -136,11 +138,12 @@ class TestGiteaPluginListIssues: plugin.list_issues() -class TestGiteaPluginGetIssue: +class TestGiteaPluginGetIssue(AsyncTestCase): """Test suite for getting individual issues through Gitea plugin.""" def setup_method(self): """Set up test fixtures.""" + super().setup_method() self.config = {'url': 'http://test.com', 'repo': 'test/repo'} @patch('markitect.issues.plugins.gitea.GiteaIssueRepository') diff --git a/tests/utils/assertions.py b/tests/utils/assertions.py index 6d54fce1..ed10ebf8 100644 --- a/tests/utils/assertions.py +++ b/tests/utils/assertions.py @@ -3,9 +3,11 @@ Custom assertions and test utilities for MarkiTect tests. """ import json -from typing import Any, Dict, List, Optional, Union, Callable +import asyncio +from typing import Any, Dict, List, Optional, Union, Callable, Awaitable from datetime import datetime, timezone from pathlib import Path +from unittest.mock import AsyncMock import pytest @@ -229,6 +231,80 @@ def mark_integration_test(external_service: str = None): return markers +# Async testing utilities +def create_async_mock_that_returns(return_value: Any) -> AsyncMock: + """Create an AsyncMock that returns a specific value.""" + mock = AsyncMock() + mock.return_value = return_value + return mock + + +def create_async_mock_that_raises(exception: Exception) -> AsyncMock: + """Create an AsyncMock that raises a specific exception.""" + mock = AsyncMock() + mock.side_effect = exception + return mock + + +def await_safely(coro: Awaitable[Any]) -> Any: + """Safely await a coroutine in a test context. + + This function properly handles the async context and ensures + no coroutine warnings are generated. + """ + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + return loop.run_until_complete(coro) + finally: + # Clean up any pending tasks to avoid warnings + pending = asyncio.all_tasks(loop) + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + + +def cleanup_async_mocks(*mocks): + """Cleanup async mocks to prevent coroutine warnings. + + This should be called in test teardown to properly cleanup + any async mocks that might have created unawaited coroutines. + """ + for mock in mocks: + if hasattr(mock, '_mock_children'): + for child in mock._mock_children.values(): + if asyncio.iscoroutine(child): + child.close() + if hasattr(mock, 'return_value') and asyncio.iscoroutine(mock.return_value): + mock.return_value.close() + + +class AsyncTestCase: + """Base class for async test cases with proper cleanup.""" + + def setup_method(self): + """Setup for each test method.""" + self.async_mocks = [] + + def teardown_method(self): + """Cleanup after each test method.""" + cleanup_async_mocks(*self.async_mocks) + self.async_mocks.clear() + + def create_async_mock(self, return_value: Any = None, side_effect: Any = None) -> AsyncMock: + """Create an async mock and track it for cleanup.""" + mock = AsyncMock() + if return_value is not None: + mock.return_value = return_value + if side_effect is not None: + mock.side_effect = side_effect + self.async_mocks.append(mock) + return mock + + # Test data validation helpers def validate_issue_data(data: Dict[str, Any]) -> bool: """Validate that data represents a valid issue."""