mcp-office-tools/tests/test_mixins.py
Ryan Malloy 0748eec48d Fix FastMCP stdio server import
- 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
2025-09-26 15:49:00 -06:00

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"])