- Use app.run_stdio_async() instead of deprecated stdio_server import - Aligns with FastMCP 2.11.3 API - Server now starts correctly with uv run mcp-office-tools - Maintains all MCPMixin functionality and tool registration
370 lines
14 KiB
Python
370 lines
14 KiB
Python
"""Comprehensive testing strategy for mixin-based FastMCP architecture.
|
|
|
|
This test suite demonstrates the recommended patterns for testing FastMCP servers
|
|
that use the mixin composition pattern. It covers:
|
|
|
|
1. Individual mixin functionality testing
|
|
2. Tool registration verification
|
|
3. Integration testing of the composed server
|
|
4. Mocking strategies for file operations
|
|
5. Tool functionality testing (not just registration)
|
|
"""
|
|
|
|
import pytest
|
|
import tempfile
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from typing import Dict, Any
|
|
|
|
from fastmcp import FastMCP
|
|
# FastMCP testing - using direct tool access
|
|
|
|
from mcp_office_tools.mixins import UniversalMixin, WordMixin, ExcelMixin, PowerPointMixin
|
|
from mcp_office_tools.utils import OfficeFileError
|
|
|
|
|
|
class TestMixinArchitecture:
|
|
"""Test the mixin architecture and tool registration."""
|
|
|
|
def test_mixin_initialization(self):
|
|
"""Test that mixins initialize correctly with FastMCP app."""
|
|
app = FastMCP("Test Office Tools")
|
|
|
|
# Test each mixin initializes without errors
|
|
universal = UniversalMixin(app)
|
|
word = WordMixin(app)
|
|
excel = ExcelMixin(app)
|
|
powerpoint = PowerPointMixin(app)
|
|
|
|
assert universal.app == app
|
|
assert word.app == app
|
|
assert excel.app == app
|
|
assert powerpoint.app == app
|
|
|
|
def test_tool_registration_count(self):
|
|
"""Test that all expected tools are registered."""
|
|
app = FastMCP("Test Office Tools")
|
|
|
|
# Count tools before and after each mixin
|
|
initial_tool_count = len(app._tools)
|
|
|
|
universal = UniversalMixin(app)
|
|
universal_tools = len(app._tools) - initial_tool_count
|
|
assert universal_tools == 6 # 6 universal tools
|
|
|
|
word = WordMixin(app)
|
|
word_tools = len(app._tools) - initial_tool_count - universal_tools
|
|
assert word_tools == 1 # 1 word tool
|
|
|
|
excel = ExcelMixin(app)
|
|
excel_tools = len(app._tools) - initial_tool_count - universal_tools - word_tools
|
|
assert excel_tools == 0 # Placeholder - no tools yet
|
|
|
|
powerpoint = PowerPointMixin(app)
|
|
powerpoint_tools = len(app._tools) - initial_tool_count - universal_tools - word_tools - excel_tools
|
|
assert powerpoint_tools == 0 # Placeholder - no tools yet
|
|
|
|
def test_tool_names_registration(self):
|
|
"""Test that specific tool names are registered correctly."""
|
|
app = FastMCP("Test Office Tools")
|
|
|
|
# Register all mixins
|
|
UniversalMixin(app)
|
|
WordMixin(app)
|
|
ExcelMixin(app)
|
|
PowerPointMixin(app)
|
|
|
|
# Check expected tool names
|
|
tool_names = set(app._tools.keys())
|
|
expected_universal_tools = {
|
|
"extract_text",
|
|
"extract_images",
|
|
"extract_metadata",
|
|
"detect_office_format",
|
|
"analyze_document_health",
|
|
"get_supported_formats"
|
|
}
|
|
expected_word_tools = {"convert_to_markdown"}
|
|
|
|
assert expected_universal_tools.issubset(tool_names)
|
|
assert expected_word_tools.issubset(tool_names)
|
|
|
|
|
|
class TestUniversalMixinUnit:
|
|
"""Unit tests for UniversalMixin tools."""
|
|
|
|
@pytest.fixture
|
|
def universal_mixin(self):
|
|
"""Create a UniversalMixin instance for testing."""
|
|
app = FastMCP("Test Universal")
|
|
return UniversalMixin(app)
|
|
|
|
@pytest.fixture
|
|
def mock_csv_file(self):
|
|
"""Create a mock CSV file for testing."""
|
|
temp_file = tempfile.NamedTemporaryFile(suffix='.csv', delete=False, mode='w')
|
|
temp_file.write("Name,Age,City\nJohn,30,New York\nJane,25,Boston\n")
|
|
temp_file.close()
|
|
yield temp_file.name
|
|
os.unlink(temp_file.name)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_text_error_handling(self, universal_mixin):
|
|
"""Test extract_text error handling for invalid files."""
|
|
with pytest.raises(OfficeFileError):
|
|
await universal_mixin.extract_text("/nonexistent/file.docx")
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('mcp_office_tools.utils.validation.validate_office_file')
|
|
@patch('mcp_office_tools.utils.file_detection.detect_format')
|
|
@patch('mcp_office_tools.utils.validation.resolve_office_file_path')
|
|
async def test_extract_text_csv_success(self, mock_resolve, mock_detect, mock_validate, universal_mixin, mock_csv_file):
|
|
"""Test successful CSV text extraction with proper mocking."""
|
|
# Setup mocks
|
|
mock_resolve.return_value = mock_csv_file
|
|
mock_validate.return_value = {"is_valid": True, "errors": []}
|
|
mock_detect.return_value = {
|
|
"category": "data",
|
|
"extension": ".csv",
|
|
"format_name": "CSV"
|
|
}
|
|
|
|
# Mock the internal method
|
|
with patch.object(universal_mixin, '_extract_text_by_category') as mock_extract:
|
|
mock_extract.return_value = {
|
|
"text": "Name,Age,City\nJohn,30,New York\nJane,25,Boston",
|
|
"method_used": "pandas",
|
|
"methods_tried": ["pandas"]
|
|
}
|
|
|
|
with patch.object(universal_mixin, '_extract_basic_metadata') as mock_metadata:
|
|
mock_metadata.return_value = {"file_size": 1024}
|
|
|
|
result = await universal_mixin.extract_text(mock_csv_file)
|
|
|
|
assert "text" in result
|
|
assert "metadata" in result
|
|
assert result["metadata"]["extraction_method"] == "pandas"
|
|
assert "John" in result["text"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_supported_formats(self, universal_mixin):
|
|
"""Test get_supported_formats returns expected structure."""
|
|
result = await universal_mixin.get_supported_formats()
|
|
|
|
assert isinstance(result, dict)
|
|
assert "supported_extensions" in result
|
|
assert "format_details" in result
|
|
assert "categories" in result
|
|
assert "total_formats" in result
|
|
|
|
# Check that common formats are supported
|
|
extensions = result["supported_extensions"]
|
|
assert ".docx" in extensions
|
|
assert ".xlsx" in extensions
|
|
assert ".pptx" in extensions
|
|
assert ".csv" in extensions
|
|
|
|
|
|
class TestWordMixinUnit:
|
|
"""Unit tests for WordMixin tools."""
|
|
|
|
@pytest.fixture
|
|
def word_mixin(self):
|
|
"""Create a WordMixin instance for testing."""
|
|
app = FastMCP("Test Word")
|
|
return WordMixin(app)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_convert_to_markdown_error_handling(self, word_mixin):
|
|
"""Test convert_to_markdown error handling for invalid files."""
|
|
with pytest.raises(OfficeFileError):
|
|
await word_mixin.convert_to_markdown("/nonexistent/file.docx")
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('mcp_office_tools.utils.validation.validate_office_file')
|
|
@patch('mcp_office_tools.utils.file_detection.detect_format')
|
|
@patch('mcp_office_tools.utils.validation.resolve_office_file_path')
|
|
async def test_convert_to_markdown_non_word_document(self, mock_resolve, mock_detect, mock_validate, word_mixin):
|
|
"""Test that non-Word documents are rejected for markdown conversion."""
|
|
# Setup mocks for a non-Word document
|
|
mock_resolve.return_value = "/test/file.xlsx"
|
|
mock_validate.return_value = {"is_valid": True, "errors": []}
|
|
mock_detect.return_value = {
|
|
"category": "excel",
|
|
"extension": ".xlsx",
|
|
"format_name": "Excel"
|
|
}
|
|
|
|
with pytest.raises(OfficeFileError, match="Markdown conversion currently only supports Word documents"):
|
|
await word_mixin.convert_to_markdown("/test/file.xlsx")
|
|
|
|
|
|
class TestComposedServerIntegration:
|
|
"""Integration tests for the fully composed server."""
|
|
|
|
@pytest.fixture
|
|
def composed_app(self):
|
|
"""Create a fully composed FastMCP app with all mixins."""
|
|
app = FastMCP("MCP Office Tools Test")
|
|
|
|
# Initialize all mixins
|
|
UniversalMixin(app)
|
|
WordMixin(app)
|
|
ExcelMixin(app)
|
|
PowerPointMixin(app)
|
|
|
|
return app
|
|
|
|
def test_all_tools_registered(self, composed_app):
|
|
"""Test that all tools are registered in the composed server."""
|
|
tool_names = set(composed_app._tools.keys())
|
|
|
|
# Expected tools from all mixins
|
|
expected_tools = {
|
|
# Universal tools
|
|
"extract_text",
|
|
"extract_images",
|
|
"extract_metadata",
|
|
"detect_office_format",
|
|
"analyze_document_health",
|
|
"get_supported_formats",
|
|
# Word tools
|
|
"convert_to_markdown"
|
|
# Excel and PowerPoint tools will be added when implemented
|
|
}
|
|
|
|
assert expected_tools.issubset(tool_names)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_execution_direct(self, composed_app):
|
|
"""Test tool execution through direct tool access."""
|
|
# Test get_supported_formats through direct access
|
|
get_supported_formats_tool = composed_app._tools["get_supported_formats"]
|
|
result = await get_supported_formats_tool()
|
|
|
|
assert "supported_extensions" in result
|
|
assert "format_details" in result
|
|
|
|
|
|
class TestMockingStrategies:
|
|
"""Demonstrate effective mocking strategies for FastMCP tools."""
|
|
|
|
@pytest.fixture
|
|
def mock_office_file(self):
|
|
"""Create a realistic mock Office file context."""
|
|
return {
|
|
"path": "/test/document.docx",
|
|
"content": "Mock document content",
|
|
"metadata": {
|
|
"title": "Test Document",
|
|
"author": "Test Author",
|
|
"created": "2024-01-01T00:00:00Z"
|
|
}
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('mcp_office_tools.utils.validation.resolve_office_file_path')
|
|
@patch('mcp_office_tools.utils.validation.validate_office_file')
|
|
@patch('mcp_office_tools.utils.file_detection.detect_format')
|
|
async def test_comprehensive_mocking_pattern(self, mock_detect, mock_validate, mock_resolve, mock_office_file):
|
|
"""Demonstrate comprehensive mocking pattern for tool testing."""
|
|
app = FastMCP("Test App")
|
|
universal = UniversalMixin(app)
|
|
|
|
# Setup comprehensive mocks
|
|
mock_resolve.return_value = mock_office_file["path"]
|
|
mock_validate.return_value = {"is_valid": True, "errors": []}
|
|
mock_detect.return_value = {
|
|
"category": "word",
|
|
"extension": ".docx",
|
|
"format_name": "Word Document"
|
|
}
|
|
|
|
# Mock the internal processing methods
|
|
with patch.object(universal, '_extract_text_by_category') as mock_extract_text:
|
|
mock_extract_text.return_value = {
|
|
"text": mock_office_file["content"],
|
|
"method_used": "python-docx",
|
|
"methods_tried": ["python-docx"]
|
|
}
|
|
|
|
with patch.object(universal, '_extract_basic_metadata') as mock_extract_metadata:
|
|
mock_extract_metadata.return_value = mock_office_file["metadata"]
|
|
|
|
result = await universal.extract_text(mock_office_file["path"])
|
|
|
|
# Verify comprehensive result structure
|
|
assert result["text"] == mock_office_file["content"]
|
|
assert result["metadata"]["extraction_method"] == "python-docx"
|
|
assert result["document_metadata"] == mock_office_file["metadata"]
|
|
|
|
# Verify mocks were called correctly
|
|
mock_resolve.assert_called_once_with(mock_office_file["path"])
|
|
mock_validate.assert_called_once_with(mock_office_file["path"])
|
|
mock_detect.assert_called_once_with(mock_office_file["path"])
|
|
|
|
|
|
class TestFileOperationMocking:
|
|
"""Advanced file operation mocking strategies."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_temporary_file_creation(self):
|
|
"""Test using temporary files for realistic testing."""
|
|
# Create a temporary CSV file
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as tmp:
|
|
tmp.write("Name,Value\nTest,123\n")
|
|
tmp_path = tmp.name
|
|
|
|
try:
|
|
# Test with real file
|
|
app = FastMCP("Test App")
|
|
universal = UniversalMixin(app)
|
|
|
|
# Mock only the validation/detection layers
|
|
with patch('mcp_office_tools.utils.validation.validate_office_file') as mock_validate:
|
|
with patch('mcp_office_tools.utils.file_detection.detect_format') as mock_detect:
|
|
mock_validate.return_value = {"is_valid": True, "errors": []}
|
|
mock_detect.return_value = {
|
|
"category": "data",
|
|
"extension": ".csv",
|
|
"format_name": "CSV"
|
|
}
|
|
|
|
# Test would work with real CSV processing
|
|
# (This demonstrates the pattern without running the full pipeline)
|
|
assert os.path.exists(tmp_path)
|
|
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
|
|
|
|
class TestAsyncPatterns:
|
|
"""Test async patterns specific to FastMCP."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_tool_execution(self):
|
|
"""Test async tool execution patterns."""
|
|
app = FastMCP("Async Test")
|
|
universal = UniversalMixin(app)
|
|
|
|
# Mock all async dependencies
|
|
with patch('mcp_office_tools.utils.validation.resolve_office_file_path') as mock_resolve:
|
|
with patch('mcp_office_tools.utils.validation.validate_office_file') as mock_validate:
|
|
with patch('mcp_office_tools.utils.file_detection.detect_format') as mock_detect:
|
|
# Make mocks properly async
|
|
mock_resolve.return_value = "/test.csv"
|
|
mock_validate.return_value = {"is_valid": True, "errors": []}
|
|
mock_detect.return_value = {"category": "data", "extension": ".csv", "format_name": "CSV"}
|
|
|
|
with patch.object(universal, '_extract_text_by_category') as mock_extract:
|
|
mock_extract.return_value = {"text": "test", "method_used": "pandas"}
|
|
|
|
# This should complete quickly
|
|
result = await universal.extract_text("/test.csv")
|
|
assert "text" in result
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"]) |