feat: Implement comprehensive Testing Architecture Enhancement
Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Establishes robust testing framework with clean architecture patterns: ## Phase 1: Test Infrastructure Foundation - Global test configuration with pytest.ini and conftest.py - Isolated test workspaces and environment management - Comprehensive fixture library for all test types - Test requirements and dependency management ## Phase 2: Advanced Testing Patterns - Test builders using builder pattern for domain objects - Mock factories for repositories, services, and configs - API response builders for external system simulation - Enhanced unit tests with proper mocking and isolation ## Phase 3: Test Performance and Quality - Performance testing framework with benchmarks - Memory usage monitoring and leak detection - Custom assertions for domain-specific validation - Parametrized testing for comprehensive coverage ## Phase 4: CI/CD Integration - GitHub Actions workflow for automated testing - Multi-stage testing: unit → integration → e2e → performance - Code quality checks with flake8, mypy, black, isort - Security scanning with safety and bandit ## Testing Architecture Benefits ✅ 100+ new test infrastructure components ✅ Standardized test organization (unit/integration/e2e) ✅ Mock-based testing with no external dependencies ✅ Performance regression detection ✅ Comprehensive fixture library ✅ CI/CD pipeline with quality gates The testing framework supports the domain logic separation and provides a solid foundation for maintaining high code quality as the system evolves. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
3
tests/utils/__init__.py
Normal file
3
tests/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Test utilities and helpers for MarkiTect tests.
|
||||
"""
|
||||
274
tests/utils/assertions.py
Normal file
274
tests/utils/assertions.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Custom assertions and test utilities for MarkiTect tests.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Union, Callable
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
def assert_issue_equal(actual, expected, ignore_timestamps: bool = False):
|
||||
"""Assert that two Issue objects are equal."""
|
||||
assert actual.number == expected.number, f"Issue numbers don't match: {actual.number} != {expected.number}"
|
||||
assert actual.title == expected.title, f"Issue titles don't match: {actual.title} != {expected.title}"
|
||||
assert actual.state == expected.state, f"Issue states don't match: {actual.state} != {expected.state}"
|
||||
assert len(actual.labels) == len(expected.labels), f"Label counts don't match: {len(actual.labels)} != {len(expected.labels)}"
|
||||
|
||||
# Compare labels
|
||||
actual_label_names = {label.name for label in actual.labels}
|
||||
expected_label_names = {label.name for label in expected.labels}
|
||||
assert actual_label_names == expected_label_names, f"Labels don't match: {actual_label_names} != {expected_label_names}"
|
||||
|
||||
if not ignore_timestamps:
|
||||
assert actual.created_at == expected.created_at, f"Created timestamps don't match"
|
||||
assert actual.updated_at == expected.updated_at, f"Updated timestamps don't match"
|
||||
assert actual.closed_at == expected.closed_at, f"Closed timestamps don't match"
|
||||
|
||||
|
||||
def assert_project_equal(actual, expected, ignore_timestamps: bool = False):
|
||||
"""Assert that two Project objects are equal."""
|
||||
assert actual.name == expected.name, f"Project names don't match: {actual.name} != {expected.name}"
|
||||
assert actual.description == expected.description, f"Project descriptions don't match"
|
||||
assert actual.state == expected.state, f"Project states don't match: {actual.state} != {expected.state}"
|
||||
assert actual.kanban_columns == expected.kanban_columns, f"Kanban columns don't match"
|
||||
assert len(actual.milestones) == len(expected.milestones), f"Milestone counts don't match"
|
||||
|
||||
if not ignore_timestamps:
|
||||
assert actual.created_at == expected.created_at, f"Created timestamps don't match"
|
||||
assert actual.updated_at == expected.updated_at, f"Updated timestamps don't match"
|
||||
assert actual.archived_at == expected.archived_at, f"Archived timestamps don't match"
|
||||
|
||||
|
||||
def assert_milestone_equal(actual, expected):
|
||||
"""Assert that two Milestone objects are equal."""
|
||||
assert actual.id == expected.id, f"Milestone IDs don't match: {actual.id} != {expected.id}"
|
||||
assert actual.title == expected.title, f"Milestone titles don't match: {actual.title} != {expected.title}"
|
||||
assert actual.description == expected.description, f"Milestone descriptions don't match"
|
||||
assert actual.state == expected.state, f"Milestone states don't match: {actual.state} != {expected.state}"
|
||||
assert actual.open_issues == expected.open_issues, f"Open issue counts don't match"
|
||||
assert actual.closed_issues == expected.closed_issues, f"Closed issue counts don't match"
|
||||
assert actual.due_date == expected.due_date, f"Due dates don't match"
|
||||
|
||||
|
||||
def assert_json_equal(actual: Union[str, Dict], expected: Union[str, Dict]):
|
||||
"""Assert that two JSON objects are equal."""
|
||||
if isinstance(actual, str):
|
||||
actual = json.loads(actual)
|
||||
if isinstance(expected, str):
|
||||
expected = json.loads(expected)
|
||||
|
||||
assert actual == expected, f"JSON objects don't match:\nActual: {json.dumps(actual, indent=2)}\nExpected: {json.dumps(expected, indent=2)}"
|
||||
|
||||
|
||||
def assert_markdown_structure_equal(actual: str, expected: str):
|
||||
"""Assert that two markdown documents have the same structure (ignoring whitespace differences)."""
|
||||
actual_lines = [line.strip() for line in actual.split('\n') if line.strip()]
|
||||
expected_lines = [line.strip() for line in expected.split('\n') if line.strip()]
|
||||
|
||||
assert len(actual_lines) == len(expected_lines), f"Line count mismatch: {len(actual_lines)} != {len(expected_lines)}"
|
||||
|
||||
for i, (actual_line, expected_line) in enumerate(zip(actual_lines, expected_lines)):
|
||||
assert actual_line == expected_line, f"Line {i+1} mismatch:\nActual: {actual_line}\nExpected: {expected_line}"
|
||||
|
||||
|
||||
def assert_file_exists(file_path: Union[str, Path], message: str = None):
|
||||
"""Assert that a file exists."""
|
||||
path = Path(file_path)
|
||||
assert path.exists(), message or f"File does not exist: {path}"
|
||||
assert path.is_file(), message or f"Path is not a file: {path}"
|
||||
|
||||
|
||||
def assert_directory_exists(dir_path: Union[str, Path], message: str = None):
|
||||
"""Assert that a directory exists."""
|
||||
path = Path(dir_path)
|
||||
assert path.exists(), message or f"Directory does not exist: {path}"
|
||||
assert path.is_dir(), message or f"Path is not a directory: {path}"
|
||||
|
||||
|
||||
def assert_file_contains(file_path: Union[str, Path], content: str, message: str = None):
|
||||
"""Assert that a file contains specific content."""
|
||||
path = Path(file_path)
|
||||
assert_file_exists(path)
|
||||
|
||||
file_content = path.read_text()
|
||||
assert content in file_content, message or f"File {path} does not contain: {content}"
|
||||
|
||||
|
||||
def assert_file_not_contains(file_path: Union[str, Path], content: str, message: str = None):
|
||||
"""Assert that a file does not contain specific content."""
|
||||
path = Path(file_path)
|
||||
assert_file_exists(path)
|
||||
|
||||
file_content = path.read_text()
|
||||
assert content not in file_content, message or f"File {path} unexpectedly contains: {content}"
|
||||
|
||||
|
||||
def assert_time_approximately_equal(actual: datetime, expected: datetime, tolerance_seconds: int = 1):
|
||||
"""Assert that two datetime objects are approximately equal within tolerance."""
|
||||
diff = abs((actual - expected).total_seconds())
|
||||
assert diff <= tolerance_seconds, f"Times differ by {diff} seconds, tolerance is {tolerance_seconds}"
|
||||
|
||||
|
||||
def assert_performance_within_bounds(execution_time: float, max_time: float, operation: str = "operation"):
|
||||
"""Assert that an operation completed within performance bounds."""
|
||||
assert execution_time <= max_time, f"{operation} took {execution_time:.3f}s, expected <= {max_time:.3f}s"
|
||||
|
||||
|
||||
def assert_memory_usage_within_bounds(memory_usage_mb: float, max_memory_mb: float, operation: str = "operation"):
|
||||
"""Assert that memory usage is within bounds."""
|
||||
assert memory_usage_mb <= max_memory_mb, f"{operation} used {memory_usage_mb:.2f}MB, expected <= {max_memory_mb:.2f}MB"
|
||||
|
||||
|
||||
def assert_mock_called_with_pattern(mock, pattern: Callable[[Any], bool], message: str = None):
|
||||
"""Assert that a mock was called with arguments matching a pattern."""
|
||||
found_match = False
|
||||
for call in mock.call_args_list:
|
||||
if pattern(call):
|
||||
found_match = True
|
||||
break
|
||||
|
||||
assert found_match, message or f"Mock was not called with expected pattern. Calls: {mock.call_args_list}"
|
||||
|
||||
|
||||
def assert_sequence_equal(actual: List[Any], expected: List[Any], compare_fn: Optional[Callable[[Any, Any], bool]] = None):
|
||||
"""Assert that two sequences are equal using optional custom comparison."""
|
||||
assert len(actual) == len(expected), f"Sequence lengths don't match: {len(actual)} != {len(expected)}"
|
||||
|
||||
for i, (actual_item, expected_item) in enumerate(zip(actual, expected)):
|
||||
if compare_fn:
|
||||
assert compare_fn(actual_item, expected_item), f"Items at index {i} don't match"
|
||||
else:
|
||||
assert actual_item == expected_item, f"Items at index {i} don't match: {actual_item} != {expected_item}"
|
||||
|
||||
|
||||
def assert_contains_all(container: Union[List, Dict, str], items: List[Any], message: str = None):
|
||||
"""Assert that a container contains all specified items."""
|
||||
missing_items = []
|
||||
for item in items:
|
||||
if item not in container:
|
||||
missing_items.append(item)
|
||||
|
||||
assert not missing_items, message or f"Container missing items: {missing_items}"
|
||||
|
||||
|
||||
def assert_contains_none(container: Union[List, Dict, str], items: List[Any], message: str = None):
|
||||
"""Assert that a container contains none of the specified items."""
|
||||
found_items = []
|
||||
for item in items:
|
||||
if item in container:
|
||||
found_items.append(item)
|
||||
|
||||
assert not found_items, message or f"Container unexpectedly contains items: {found_items}"
|
||||
|
||||
|
||||
def assert_label_categories_valid(categories):
|
||||
"""Assert that label categories are valid and properly separated."""
|
||||
from domain.issues.models import LabelCategories
|
||||
|
||||
assert isinstance(categories, LabelCategories), "Categories must be LabelCategories instance"
|
||||
|
||||
# Check for overlaps between categories
|
||||
all_labels = (
|
||||
categories.state_labels +
|
||||
categories.priority_labels +
|
||||
categories.type_labels +
|
||||
categories.other_labels
|
||||
)
|
||||
|
||||
# No label should appear in multiple categories
|
||||
seen_labels = set()
|
||||
for label in all_labels:
|
||||
assert label not in seen_labels, f"Label '{label}' appears in multiple categories"
|
||||
seen_labels.add(label)
|
||||
|
||||
|
||||
def assert_kanban_column_valid(column: str, valid_columns: List[str]):
|
||||
"""Assert that a kanban column is valid."""
|
||||
assert column in valid_columns, f"Invalid kanban column '{column}'. Valid columns: {valid_columns}"
|
||||
|
||||
|
||||
def assert_business_rule_violated(exception_type: type, exception_message_pattern: str = None):
|
||||
"""Context manager to assert that a business rule violation occurs."""
|
||||
return pytest.raises(exception_type, match=exception_message_pattern)
|
||||
|
||||
|
||||
def assert_async_operation_succeeds(async_func: Callable, timeout: float = 30.0):
|
||||
"""Assert that an async operation succeeds within timeout."""
|
||||
import asyncio
|
||||
|
||||
async def run_with_timeout():
|
||||
return await asyncio.wait_for(async_func(), timeout=timeout)
|
||||
|
||||
try:
|
||||
result = asyncio.run(run_with_timeout())
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
pytest.fail(f"Async operation timed out after {timeout} seconds")
|
||||
except Exception as e:
|
||||
pytest.fail(f"Async operation failed: {e}")
|
||||
|
||||
|
||||
# Custom pytest markers for different types of assertions
|
||||
def mark_performance_test(max_time: float = None, max_memory_mb: float = None):
|
||||
"""Mark a test as a performance test with optional bounds."""
|
||||
markers = [pytest.mark.performance]
|
||||
if max_time:
|
||||
markers.append(pytest.mark.parametrize("max_execution_time", [max_time]))
|
||||
if max_memory_mb:
|
||||
markers.append(pytest.mark.parametrize("max_memory_usage", [max_memory_mb]))
|
||||
return markers
|
||||
|
||||
|
||||
def mark_integration_test(external_service: str = None):
|
||||
"""Mark a test as an integration test."""
|
||||
markers = [pytest.mark.integration]
|
||||
if external_service:
|
||||
markers.append(pytest.mark.parametrize("external_service", [external_service]))
|
||||
return markers
|
||||
|
||||
|
||||
# Test data validation helpers
|
||||
def validate_issue_data(data: Dict[str, Any]) -> bool:
|
||||
"""Validate that data represents a valid issue."""
|
||||
required_fields = ["number", "title", "state", "labels", "created_at", "updated_at"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return False
|
||||
|
||||
if not isinstance(data["number"], int) or data["number"] <= 0:
|
||||
return False
|
||||
|
||||
if not isinstance(data["title"], str) or not data["title"].strip():
|
||||
return False
|
||||
|
||||
if data["state"] not in ["open", "closed"]:
|
||||
return False
|
||||
|
||||
if not isinstance(data["labels"], list):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def validate_project_data(data: Dict[str, Any]) -> bool:
|
||||
"""Validate that data represents a valid project."""
|
||||
required_fields = ["name", "state", "milestones", "kanban_columns", "created_at", "updated_at"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return False
|
||||
|
||||
if not isinstance(data["name"], str) or not data["name"].strip():
|
||||
return False
|
||||
|
||||
if data["state"] not in ["active", "archived"]:
|
||||
return False
|
||||
|
||||
if not isinstance(data["milestones"], list):
|
||||
return False
|
||||
|
||||
if not isinstance(data["kanban_columns"], list):
|
||||
return False
|
||||
|
||||
return True
|
||||
346
tests/utils/mock_factories.py
Normal file
346
tests/utils/mock_factories.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
Mock factories for creating test doubles and mocks.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, AsyncMock, MagicMock
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class MockRepositoryFactory:
|
||||
"""Factory for creating mock repository objects."""
|
||||
|
||||
@staticmethod
|
||||
def create_issue_repository() -> Mock:
|
||||
"""Create a mock issue repository."""
|
||||
repo = AsyncMock()
|
||||
repo.get_issue = AsyncMock()
|
||||
repo.create_issue = AsyncMock()
|
||||
repo.update_issue = AsyncMock()
|
||||
repo.delete_issue = AsyncMock()
|
||||
repo.list_issues = AsyncMock()
|
||||
repo.search_issues = AsyncMock()
|
||||
return repo
|
||||
|
||||
@staticmethod
|
||||
def create_project_repository() -> Mock:
|
||||
"""Create a mock project repository."""
|
||||
repo = AsyncMock()
|
||||
repo.get_project = AsyncMock()
|
||||
repo.create_project = AsyncMock()
|
||||
repo.update_project = AsyncMock()
|
||||
repo.delete_project = AsyncMock()
|
||||
repo.list_projects = AsyncMock()
|
||||
repo.get_issue_project_info = AsyncMock()
|
||||
return repo
|
||||
|
||||
@staticmethod
|
||||
def create_document_repository() -> Mock:
|
||||
"""Create a mock document repository."""
|
||||
repo = AsyncMock()
|
||||
repo.store_document = AsyncMock()
|
||||
repo.get_document = AsyncMock()
|
||||
repo.update_document = AsyncMock()
|
||||
repo.delete_document = AsyncMock()
|
||||
repo.list_documents = AsyncMock()
|
||||
repo.search_content = AsyncMock()
|
||||
return repo
|
||||
|
||||
|
||||
class MockServiceFactory:
|
||||
"""Factory for creating mock service objects."""
|
||||
|
||||
@staticmethod
|
||||
def create_http_client() -> Mock:
|
||||
"""Create a mock HTTP client."""
|
||||
client = AsyncMock()
|
||||
|
||||
# Default successful response
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={"status": "success"})
|
||||
mock_response.text = AsyncMock(return_value='{"status": "success"}')
|
||||
mock_response.headers = {"Content-Type": "application/json"}
|
||||
|
||||
client.get.return_value = mock_response
|
||||
client.post.return_value = mock_response
|
||||
client.put.return_value = mock_response
|
||||
client.delete.return_value = mock_response
|
||||
client.close = AsyncMock()
|
||||
|
||||
return client
|
||||
|
||||
@staticmethod
|
||||
def create_database_connection() -> Mock:
|
||||
"""Create a mock database connection."""
|
||||
conn = Mock()
|
||||
cursor = Mock()
|
||||
|
||||
conn.cursor.return_value = cursor
|
||||
conn.execute.return_value = cursor
|
||||
conn.commit = Mock()
|
||||
conn.rollback = Mock()
|
||||
conn.close = Mock()
|
||||
|
||||
# Default empty results
|
||||
cursor.fetchone.return_value = None
|
||||
cursor.fetchall.return_value = []
|
||||
cursor.fetchmany.return_value = []
|
||||
cursor.lastrowid = 1
|
||||
cursor.rowcount = 0
|
||||
|
||||
return conn
|
||||
|
||||
@staticmethod
|
||||
def create_cache_manager() -> Mock:
|
||||
"""Create a mock cache manager."""
|
||||
cache = AsyncMock()
|
||||
cache.get = AsyncMock(return_value=None)
|
||||
cache.set = AsyncMock()
|
||||
cache.delete = AsyncMock()
|
||||
cache.clear = AsyncMock()
|
||||
cache.exists = AsyncMock(return_value=False)
|
||||
cache.expire = AsyncMock()
|
||||
return cache
|
||||
|
||||
@staticmethod
|
||||
def create_file_system() -> Mock:
|
||||
"""Create a mock file system."""
|
||||
fs = Mock()
|
||||
fs.read_file = Mock(return_value="mock file content")
|
||||
fs.write_file = Mock()
|
||||
fs.delete_file = Mock()
|
||||
fs.exists = Mock(return_value=True)
|
||||
fs.list_files = Mock(return_value=[])
|
||||
fs.create_directory = Mock()
|
||||
fs.delete_directory = Mock()
|
||||
return fs
|
||||
|
||||
|
||||
class MockConfigFactory:
|
||||
"""Factory for creating mock configuration objects."""
|
||||
|
||||
@staticmethod
|
||||
def create_test_config(overrides: Optional[Dict[str, Any]] = None) -> Mock:
|
||||
"""Create a mock configuration object."""
|
||||
config = Mock()
|
||||
|
||||
# Default configuration values
|
||||
defaults = {
|
||||
"workspace_dir": "/tmp/test-workspace",
|
||||
"database_path": "/tmp/test.db",
|
||||
"cache_dir": "/tmp/test-cache",
|
||||
"gitea_url": "http://test-gitea.com",
|
||||
"gitea_token": "test-token",
|
||||
"repo_owner": "test",
|
||||
"repo_name": "repo",
|
||||
"log_level": "DEBUG",
|
||||
"max_retries": 3,
|
||||
"timeout": 30,
|
||||
"batch_size": 100
|
||||
}
|
||||
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
|
||||
for key, value in defaults.items():
|
||||
setattr(config, key, value)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class MockEventFactory:
|
||||
"""Factory for creating mock event objects and event handlers."""
|
||||
|
||||
@staticmethod
|
||||
def create_event_emitter() -> Mock:
|
||||
"""Create a mock event emitter."""
|
||||
emitter = Mock()
|
||||
emitter.emit = Mock()
|
||||
emitter.on = Mock()
|
||||
emitter.off = Mock()
|
||||
emitter.once = Mock()
|
||||
emitter.listeners = Mock(return_value=[])
|
||||
return emitter
|
||||
|
||||
@staticmethod
|
||||
def create_event_handler() -> Mock:
|
||||
"""Create a mock event handler."""
|
||||
handler = Mock()
|
||||
handler.handle = AsyncMock()
|
||||
handler.can_handle = Mock(return_value=True)
|
||||
handler.priority = 1
|
||||
return handler
|
||||
|
||||
|
||||
class MockNetworkFactory:
|
||||
"""Factory for creating network-related mocks."""
|
||||
|
||||
@staticmethod
|
||||
def create_rate_limiter() -> Mock:
|
||||
"""Create a mock rate limiter."""
|
||||
limiter = AsyncMock()
|
||||
limiter.acquire = AsyncMock()
|
||||
limiter.release = AsyncMock()
|
||||
limiter.is_available = AsyncMock(return_value=True)
|
||||
limiter.reset = AsyncMock()
|
||||
return limiter
|
||||
|
||||
@staticmethod
|
||||
def create_circuit_breaker() -> Mock:
|
||||
"""Create a mock circuit breaker."""
|
||||
breaker = Mock()
|
||||
breaker.call = AsyncMock()
|
||||
breaker.is_open = Mock(return_value=False)
|
||||
breaker.is_closed = Mock(return_value=True)
|
||||
breaker.is_half_open = Mock(return_value=False)
|
||||
breaker.reset = Mock()
|
||||
return breaker
|
||||
|
||||
|
||||
class MockTimeFactory:
|
||||
"""Factory for creating time-related mocks."""
|
||||
|
||||
@staticmethod
|
||||
def create_timer() -> Mock:
|
||||
"""Create a mock timer."""
|
||||
timer = Mock()
|
||||
timer.start = Mock()
|
||||
timer.stop = Mock()
|
||||
timer.elapsed = 0.1
|
||||
timer.reset = Mock()
|
||||
return timer
|
||||
|
||||
@staticmethod
|
||||
def create_scheduler() -> Mock:
|
||||
"""Create a mock task scheduler."""
|
||||
scheduler = AsyncMock()
|
||||
scheduler.schedule = AsyncMock()
|
||||
scheduler.cancel = AsyncMock()
|
||||
scheduler.is_scheduled = Mock(return_value=False)
|
||||
scheduler.start = AsyncMock()
|
||||
scheduler.stop = AsyncMock()
|
||||
return scheduler
|
||||
|
||||
|
||||
class MockResponseBuilder:
|
||||
"""Builder for creating mock HTTP responses."""
|
||||
|
||||
def __init__(self):
|
||||
self.status = 200
|
||||
self.headers = {"Content-Type": "application/json"}
|
||||
self.body = {"status": "success"}
|
||||
self.delay = 0.0
|
||||
self.exception = None
|
||||
|
||||
def with_status(self, status: int) -> "MockResponseBuilder":
|
||||
"""Set response status code."""
|
||||
self.status = status
|
||||
return self
|
||||
|
||||
def with_headers(self, headers: Dict[str, str]) -> "MockResponseBuilder":
|
||||
"""Set response headers."""
|
||||
self.headers.update(headers)
|
||||
return self
|
||||
|
||||
def with_json_body(self, body: Dict[str, Any]) -> "MockResponseBuilder":
|
||||
"""Set JSON response body."""
|
||||
self.body = body
|
||||
self.headers["Content-Type"] = "application/json"
|
||||
return self
|
||||
|
||||
def with_text_body(self, body: str) -> "MockResponseBuilder":
|
||||
"""Set text response body."""
|
||||
self.body = body
|
||||
self.headers["Content-Type"] = "text/plain"
|
||||
return self
|
||||
|
||||
def with_delay(self, delay: float) -> "MockResponseBuilder":
|
||||
"""Add delay to response."""
|
||||
self.delay = delay
|
||||
return self
|
||||
|
||||
def with_exception(self, exception: Exception) -> "MockResponseBuilder":
|
||||
"""Make response raise an exception."""
|
||||
self.exception = exception
|
||||
return self
|
||||
|
||||
def build(self) -> Mock:
|
||||
"""Build the mock response."""
|
||||
if self.exception:
|
||||
# Create a coroutine that raises the exception
|
||||
async def raise_exception():
|
||||
await asyncio.sleep(self.delay)
|
||||
raise self.exception
|
||||
return raise_exception()
|
||||
|
||||
response = AsyncMock()
|
||||
response.status = self.status
|
||||
response.headers = self.headers
|
||||
|
||||
if isinstance(self.body, dict):
|
||||
response.json = AsyncMock(return_value=self.body)
|
||||
response.text = AsyncMock(return_value=str(self.body))
|
||||
else:
|
||||
response.text = AsyncMock(return_value=self.body)
|
||||
response.json = AsyncMock(side_effect=ValueError("Not JSON"))
|
||||
|
||||
# Add delay if specified
|
||||
if self.delay > 0:
|
||||
original_json = response.json
|
||||
original_text = response.text
|
||||
|
||||
async def delayed_json():
|
||||
await asyncio.sleep(self.delay)
|
||||
return await original_json()
|
||||
|
||||
async def delayed_text():
|
||||
await asyncio.sleep(self.delay)
|
||||
return await original_text()
|
||||
|
||||
response.json = delayed_json
|
||||
response.text = delayed_text
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def create_failing_mock(exception: Exception) -> Mock:
|
||||
"""Create a mock that always raises the specified exception."""
|
||||
mock = Mock()
|
||||
mock.side_effect = exception
|
||||
return mock
|
||||
|
||||
|
||||
def create_async_failing_mock(exception: Exception) -> AsyncMock:
|
||||
"""Create an async mock that always raises the specified exception."""
|
||||
mock = AsyncMock()
|
||||
mock.side_effect = exception
|
||||
return mock
|
||||
|
||||
|
||||
def create_sequence_mock(values: List[Any]) -> Mock:
|
||||
"""Create a mock that returns values in sequence."""
|
||||
mock = Mock()
|
||||
mock.side_effect = values
|
||||
return mock
|
||||
|
||||
|
||||
def create_async_sequence_mock(values: List[Any]) -> AsyncMock:
|
||||
"""Create an async mock that returns values in sequence."""
|
||||
mock = AsyncMock()
|
||||
mock.side_effect = values
|
||||
return mock
|
||||
|
||||
|
||||
def create_conditional_mock(condition: Callable[..., bool], true_value: Any, false_value: Any) -> Mock:
|
||||
"""Create a mock that returns different values based on a condition."""
|
||||
def side_effect(*args, **kwargs):
|
||||
if condition(*args, **kwargs):
|
||||
return true_value
|
||||
return false_value
|
||||
|
||||
mock = Mock()
|
||||
mock.side_effect = side_effect
|
||||
return mock
|
||||
338
tests/utils/test_builders.py
Normal file
338
tests/utils/test_builders.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Test data builders using the builder pattern for creating domain objects.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Dict, Any
|
||||
from domain.issues.models import Issue, Label, IssueState, LabelCategories
|
||||
from domain.projects.models import Project, Milestone, ProjectState
|
||||
|
||||
|
||||
class IssueBuilder:
|
||||
"""Builder for creating Issue domain objects for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.number = 1
|
||||
self.title = "Test Issue"
|
||||
self.state = IssueState.OPEN
|
||||
self.labels: List[Label] = []
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
self.closed_at: Optional[datetime] = None
|
||||
|
||||
def with_number(self, number: int) -> "IssueBuilder":
|
||||
"""Set issue number."""
|
||||
self.number = number
|
||||
return self
|
||||
|
||||
def with_title(self, title: str) -> "IssueBuilder":
|
||||
"""Set issue title."""
|
||||
self.title = title
|
||||
return self
|
||||
|
||||
def with_state(self, state: IssueState) -> "IssueBuilder":
|
||||
"""Set issue state."""
|
||||
self.state = state
|
||||
if state == IssueState.CLOSED and self.closed_at is None:
|
||||
self.closed_at = datetime.now(timezone.utc)
|
||||
return self
|
||||
|
||||
def with_labels(self, *label_names: str) -> "IssueBuilder":
|
||||
"""Add labels to the issue."""
|
||||
self.labels = [Label(name) for name in label_names]
|
||||
return self
|
||||
|
||||
def with_label_objects(self, *labels: Label) -> "IssueBuilder":
|
||||
"""Add label objects to the issue."""
|
||||
self.labels = list(labels)
|
||||
return self
|
||||
|
||||
def with_timestamps(self, created_at: datetime, updated_at: datetime, closed_at: Optional[datetime] = None) -> "IssueBuilder":
|
||||
"""Set issue timestamps."""
|
||||
self.created_at = created_at
|
||||
self.updated_at = updated_at
|
||||
self.closed_at = closed_at
|
||||
return self
|
||||
|
||||
def as_closed(self, closed_at: Optional[datetime] = None) -> "IssueBuilder":
|
||||
"""Mark issue as closed."""
|
||||
self.state = IssueState.CLOSED
|
||||
self.closed_at = closed_at or datetime.now(timezone.utc)
|
||||
return self
|
||||
|
||||
def as_bug(self) -> "IssueBuilder":
|
||||
"""Add bug label."""
|
||||
self.labels.append(Label("bug"))
|
||||
return self
|
||||
|
||||
def as_enhancement(self) -> "IssueBuilder":
|
||||
"""Add enhancement label."""
|
||||
self.labels.append(Label("enhancement"))
|
||||
return self
|
||||
|
||||
def with_priority(self, priority: str) -> "IssueBuilder":
|
||||
"""Add priority label."""
|
||||
if priority not in ["low", "medium", "high", "critical"]:
|
||||
raise ValueError("Priority must be one of: low, medium, high, critical")
|
||||
self.labels.append(Label(f"priority:{priority}"))
|
||||
return self
|
||||
|
||||
def with_status(self, status: str) -> "IssueBuilder":
|
||||
"""Add status label."""
|
||||
self.labels.append(Label(f"status:{status}"))
|
||||
return self
|
||||
|
||||
def build(self) -> Issue:
|
||||
"""Build the Issue object."""
|
||||
return Issue(
|
||||
number=self.number,
|
||||
title=self.title,
|
||||
state=self.state,
|
||||
labels=self.labels,
|
||||
created_at=self.created_at,
|
||||
updated_at=self.updated_at,
|
||||
closed_at=self.closed_at
|
||||
)
|
||||
|
||||
|
||||
class LabelBuilder:
|
||||
"""Builder for creating Label objects for testing."""
|
||||
|
||||
def __init__(self, name: str = "test-label"):
|
||||
self.name = name
|
||||
|
||||
def as_state_label(self, status: str) -> "LabelBuilder":
|
||||
"""Create a state label."""
|
||||
self.name = f"status:{status}"
|
||||
return self
|
||||
|
||||
def as_priority_label(self, priority: str) -> "LabelBuilder":
|
||||
"""Create a priority label."""
|
||||
if priority not in ["low", "medium", "high", "critical"]:
|
||||
raise ValueError("Priority must be one of: low, medium, high, critical")
|
||||
self.name = f"priority:{priority}"
|
||||
return self
|
||||
|
||||
def as_type_label(self, type_name: str) -> "LabelBuilder":
|
||||
"""Create a type label."""
|
||||
if type_name not in ["bug", "enhancement", "feature", "documentation"]:
|
||||
raise ValueError("Type must be one of: bug, enhancement, feature, documentation")
|
||||
self.name = type_name
|
||||
return self
|
||||
|
||||
def with_custom_name(self, name: str) -> "LabelBuilder":
|
||||
"""Set custom label name."""
|
||||
self.name = name
|
||||
return self
|
||||
|
||||
def build(self) -> Label:
|
||||
"""Build the Label object."""
|
||||
return Label(self.name)
|
||||
|
||||
|
||||
class MilestoneBuilder:
|
||||
"""Builder for creating Milestone objects for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.id = 1
|
||||
self.title = "Test Milestone"
|
||||
self.description: Optional[str] = None
|
||||
self.due_date: Optional[datetime] = None
|
||||
self.state = "open"
|
||||
self.open_issues = 0
|
||||
self.closed_issues = 0
|
||||
|
||||
def with_id(self, id: int) -> "MilestoneBuilder":
|
||||
"""Set milestone ID."""
|
||||
self.id = id
|
||||
return self
|
||||
|
||||
def with_title(self, title: str) -> "MilestoneBuilder":
|
||||
"""Set milestone title."""
|
||||
self.title = title
|
||||
return self
|
||||
|
||||
def with_description(self, description: str) -> "MilestoneBuilder":
|
||||
"""Set milestone description."""
|
||||
self.description = description
|
||||
return self
|
||||
|
||||
def with_due_date(self, due_date: datetime) -> "MilestoneBuilder":
|
||||
"""Set milestone due date."""
|
||||
self.due_date = due_date
|
||||
return self
|
||||
|
||||
def with_state(self, state: str) -> "MilestoneBuilder":
|
||||
"""Set milestone state."""
|
||||
if state not in ["open", "closed"]:
|
||||
raise ValueError("State must be 'open' or 'closed'")
|
||||
self.state = state
|
||||
return self
|
||||
|
||||
def with_issue_counts(self, open_issues: int, closed_issues: int) -> "MilestoneBuilder":
|
||||
"""Set issue counts."""
|
||||
self.open_issues = open_issues
|
||||
self.closed_issues = closed_issues
|
||||
return self
|
||||
|
||||
def as_overdue(self) -> "MilestoneBuilder":
|
||||
"""Make milestone overdue."""
|
||||
from datetime import timedelta
|
||||
self.due_date = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
return self
|
||||
|
||||
def as_completed(self) -> "MilestoneBuilder":
|
||||
"""Mark milestone as completed."""
|
||||
self.state = "closed"
|
||||
return self
|
||||
|
||||
def build(self) -> Milestone:
|
||||
"""Build the Milestone object."""
|
||||
return Milestone(
|
||||
id=self.id,
|
||||
title=self.title,
|
||||
description=self.description,
|
||||
due_date=self.due_date,
|
||||
state=self.state,
|
||||
open_issues=self.open_issues,
|
||||
closed_issues=self.closed_issues
|
||||
)
|
||||
|
||||
|
||||
class ProjectBuilder:
|
||||
"""Builder for creating Project objects for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.name = "Test Project"
|
||||
self.description: Optional[str] = None
|
||||
self.state = ProjectState.ACTIVE
|
||||
self.milestones: List[Milestone] = []
|
||||
self.kanban_columns = ["Todo", "In Progress", "Done"]
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
self.archived_at: Optional[datetime] = None
|
||||
|
||||
def with_name(self, name: str) -> "ProjectBuilder":
|
||||
"""Set project name."""
|
||||
self.name = name
|
||||
return self
|
||||
|
||||
def with_description(self, description: str) -> "ProjectBuilder":
|
||||
"""Set project description."""
|
||||
self.description = description
|
||||
return self
|
||||
|
||||
def with_state(self, state: ProjectState) -> "ProjectBuilder":
|
||||
"""Set project state."""
|
||||
self.state = state
|
||||
if state == ProjectState.ARCHIVED and self.archived_at is None:
|
||||
self.archived_at = datetime.now(timezone.utc)
|
||||
return self
|
||||
|
||||
def with_milestones(self, *milestones: Milestone) -> "ProjectBuilder":
|
||||
"""Add milestones to the project."""
|
||||
self.milestones = list(milestones)
|
||||
return self
|
||||
|
||||
def with_kanban_columns(self, *columns: str) -> "ProjectBuilder":
|
||||
"""Set kanban columns."""
|
||||
self.kanban_columns = list(columns)
|
||||
return self
|
||||
|
||||
def with_timestamps(self, created_at: datetime, updated_at: datetime, archived_at: Optional[datetime] = None) -> "ProjectBuilder":
|
||||
"""Set project timestamps."""
|
||||
self.created_at = created_at
|
||||
self.updated_at = updated_at
|
||||
self.archived_at = archived_at
|
||||
return self
|
||||
|
||||
def as_archived(self, archived_at: Optional[datetime] = None) -> "ProjectBuilder":
|
||||
"""Mark project as archived."""
|
||||
self.state = ProjectState.ARCHIVED
|
||||
self.archived_at = archived_at or datetime.now(timezone.utc)
|
||||
return self
|
||||
|
||||
def build(self) -> Project:
|
||||
"""Build the Project object."""
|
||||
return Project(
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
state=self.state,
|
||||
milestones=self.milestones,
|
||||
kanban_columns=self.kanban_columns,
|
||||
created_at=self.created_at,
|
||||
updated_at=self.updated_at,
|
||||
archived_at=self.archived_at
|
||||
)
|
||||
|
||||
|
||||
# Convenience functions for common test scenarios
|
||||
def create_sample_issue(number: int = 1, title: str = "Sample Issue") -> Issue:
|
||||
"""Create a basic sample issue for testing."""
|
||||
return (IssueBuilder()
|
||||
.with_number(number)
|
||||
.with_title(title)
|
||||
.as_bug()
|
||||
.with_priority("medium")
|
||||
.with_status("new")
|
||||
.build())
|
||||
|
||||
|
||||
def create_in_progress_issue(number: int = 1) -> Issue:
|
||||
"""Create an in-progress issue for testing."""
|
||||
return (IssueBuilder()
|
||||
.with_number(number)
|
||||
.with_title("In Progress Issue")
|
||||
.as_enhancement()
|
||||
.with_priority("high")
|
||||
.with_status("in-progress")
|
||||
.build())
|
||||
|
||||
|
||||
def create_closed_issue(number: int = 1) -> Issue:
|
||||
"""Create a closed issue for testing."""
|
||||
return (IssueBuilder()
|
||||
.with_number(number)
|
||||
.with_title("Closed Issue")
|
||||
.as_bug()
|
||||
.with_priority("low")
|
||||
.as_closed()
|
||||
.build())
|
||||
|
||||
|
||||
def create_sample_milestone(id: int = 1, title: str = "Sample Milestone") -> Milestone:
|
||||
"""Create a basic sample milestone for testing."""
|
||||
return (MilestoneBuilder()
|
||||
.with_id(id)
|
||||
.with_title(title)
|
||||
.with_description(f"Description for {title}")
|
||||
.with_issue_counts(3, 7)
|
||||
.build())
|
||||
|
||||
|
||||
def create_sample_project(name: str = "Sample Project") -> Project:
|
||||
"""Create a basic sample project for testing."""
|
||||
milestone1 = create_sample_milestone(1, "Version 1.0")
|
||||
milestone2 = create_sample_milestone(2, "Version 2.0")
|
||||
|
||||
return (ProjectBuilder()
|
||||
.with_name(name)
|
||||
.with_description(f"Description for {name}")
|
||||
.with_milestones(milestone1, milestone2)
|
||||
.build())
|
||||
|
||||
|
||||
def create_complex_issue_with_labels() -> Issue:
|
||||
"""Create an issue with various types of labels for testing categorization."""
|
||||
return (IssueBuilder()
|
||||
.with_number(42)
|
||||
.with_title("Complex Issue with Multiple Labels")
|
||||
.with_labels(
|
||||
"bug", # type label
|
||||
"priority:critical", # priority label
|
||||
"status:blocked", # state label
|
||||
"frontend", # other label
|
||||
"needs-testing", # other label
|
||||
"enhancement" # type label (multiple types allowed)
|
||||
)
|
||||
.build())
|
||||
Reference in New Issue
Block a user