# Standardized Client Testing Framework
This directory contains a standardized testing framework for all FastMCP2 Google Workspace client tests, based on the excellent patterns established in `test_auth_pattern_improvement_fixed.py`.
## ๐ฏ Framework Goals
- **Consistent**: All tests use the same patterns and helpers
- **Reliable**: Automatic protocol detection and connection fallback
- **Maintainable**: Reusable components reduce code duplication
- **Comprehensive**: Built-in validation for auth patterns and service responses
- **Easy**: Simple template for creating new compliant tests
## ๐ Framework Components
### Core Files
| File | Purpose |
|------|---------|
| [`conftest.py`](conftest.py) | Pytest configuration and global fixtures |
| [`base_test_config.py`](base_test_config.py) | Server connection and protocol detection |
| [`test_helpers.py`](test_helpers.py) | Utilities for response validation and test execution |
| [`resource_helpers.py`](resource_helpers.py) | Real ID fetching from service:// resources |
| [`test_template.py`](test_template.py) | Template for creating new tests |
| [`pytest.ini`](pytest.ini) | Pytest configuration and markers |
### Helper Classes
- **`TestResponseValidator`**: Validates service responses and auth patterns
- **`ToolTestRunner`**: Runs standardized tool tests with auth pattern validation
- **`create_test_client()`**: Creates properly configured clients with fallback
- **`ResourceIDFetcher`**: Fetches real IDs from service:// resources for realistic testing
## ๐ How the Client Fixture Works (Under the Hood)
### The Magic of Pytest Dependency Injection
When you write a test like this:
```python
@pytest.mark.asyncio
async def test_gmail_tools_available(self, client):
"""Test that Gmail tools are available."""
tools = await client.list_tools()
tool_names = [tool.name for tool in tools]
assert "send_gmail_message" in tool_names
```
The `client` parameter is **automatically provided by pytest's fixture system**. Here's what happens:
#### 1. Fixture Definition (in conftest.py)
```python
# In conftest.py
@pytest.fixture(scope="function") # One client per test
async def client():
"""Provide a configured FastMCP client for tests."""
# Create a properly configured client
test_client = await create_test_client(TEST_EMAIL)
try:
yield test_client # Provide the client to the test
finally:
await test_client.close() # Clean up after test
```
#### 2. Automatic Injection
When pytest sees a test parameter named `client`:
1. It looks for a fixture with that name
2. Calls the fixture function to get the instance
3. Passes the instance to your test method
4. Handles cleanup automatically (the `finally` block)
#### 3. Scope Management
The `scope="function"` means:
- **New client for each test**: Each test gets a fresh, isolated client
- **Automatic cleanup**: Client is closed after each test
- **No state leakage**: Tests don't interfere with each other
### When Tests Don't Use the Client Fixture
Some tests, like `test_sheets_tools.py`, manually create clients:
```python
@pytest.fixture(scope="session")
async def test_spreadsheet_data():
"""Create a test spreadsheet once for all tests."""
# Manual client creation
client = await create_test_client(TEST_EMAIL)
try:
# Create spreadsheet (expensive operation)
result = await client.call_tool("create_spreadsheet", {...})
return result_data
finally:
await client.close() # Manual cleanup
```
#### Why Manual Client Creation?
Tests manually create clients when they need:
1. **Session-scoped resources** (`scope="session"`):
- Create expensive resources once (like spreadsheets)
- Share results across all tests
- Example: `test_spreadsheet_data` creates one spreadsheet for all Sheets tests
2. **Multiple clients**:
- Testing multi-user scenarios
- Comparing different authentication states
- Example: Testing file sharing between users
3. **Custom configuration**:
- Different server URLs
- Special timeout settings
- Testing error conditions
4. **Non-test fixtures**:
- Setup/teardown operations
- Resource creation that happens before tests run
- Data preparation
#### Fixture Scope Comparison
| Scope | Lifetime | Use Case | Example |
|-------|----------|----------|---------|
| `function` | One per test | Standard tests | `client` fixture (most tests) |
| `class` | One per test class | Shared setup for class | N/A in current framework |
| `module` | One per test file | Expensive file-level setup | N/A in current framework |
| `session` | One per test run | Very expensive setup | `test_spreadsheet_data` fixture |
### Pattern Comparison Examples
#### โ
Standard Pattern (Using client fixture)
```python
@pytest.mark.service("gmail")
class TestGmailOperations:
"""Tests using automatic client fixture."""
@pytest.mark.asyncio
async def test_send_email(self, client): # Client auto-injected
"""Test sending email."""
result = await client.call_tool("send_gmail_message", {
"user_google_email": TEST_EMAIL,
"subject": "Test",
"body": "Hello"
})
assert result is not None
```
**Advantages:**
- Clean, minimal code
- Automatic cleanup
- Standard pattern across tests
- New client per test (isolation)
#### โ
Session-Scoped Fixture Pattern (Manual client)
```python
@pytest.fixture(scope="session")
async def shared_spreadsheet():
"""Create one spreadsheet for all tests in session."""
client = await create_test_client(TEST_EMAIL) # Manual creation
try:
result = await client.call_tool("create_spreadsheet", {
"user_google_email": TEST_EMAIL,
"title": "Shared Test Spreadsheet"
})
spreadsheet_id = extract_id_from_result(result)
return {"id": spreadsheet_id}
finally:
await client.close() # Manual cleanup
class TestSheetsOperations:
"""Tests using shared spreadsheet."""
@pytest.mark.asyncio
async def test_read_data(self, client, shared_spreadsheet):
"""Test reading from shared spreadsheet."""
result = await client.call_tool("read_sheet_values", {
"user_google_email": TEST_EMAIL,
"spreadsheet_id": shared_spreadsheet["id"],
"range_name": "A1:D10"
})
assert result is not None
```
**Advantages:**
- Expensive setup runs once
- All tests share resource
- Faster test suite execution
- Useful for integration tests
#### โ Anti-Pattern (Unnecessary manual creation)
```python
@pytest.mark.asyncio
async def test_gmail_tools_available(self): # Missing client parameter
"""DON'T DO THIS - Unnecessarily manual."""
# Manually creating client when fixture would work
client = await create_test_client(TEST_EMAIL)
try:
tools = await client.list_tools()
assert len(tools) > 0
finally:
await client.close()
```
**Why it's wrong:**
- More boilerplate code
- Easy to forget cleanup
- Doesn't leverage pytest's capabilities
- Harder to maintain
### Decision Tree: When to Use Which Pattern?
```
Do you need one resource shared across ALL tests in the file?
โโ YES โ Use session-scoped fixture with manual client
โ Example: test_spreadsheet_data in test_sheets_tools.py
โ
โโ NO โ Do you need to test with MULTIPLE clients simultaneously?
โโ YES โ Manually create additional clients in test
โ Example: Testing file sharing between users
โ
โโ NO โ Use the standard `client` fixture
Example: 95% of tests in the framework
```
### Real-World Example Breakdown
Let's examine `test_sheets_tools.py` in detail:
```python
# SESSION-SCOPED: Expensive spreadsheet creation
@pytest.fixture(scope="session")
async def test_spreadsheet_data():
"""Create ONE spreadsheet for ALL tests."""
client = await create_test_client(TEST_EMAIL) # Manual: need session scope
try:
result = await client.call_tool("create_spreadsheet", {
"user_google_email": TEST_EMAIL,
"title": "MCP Test Spreadsheet"
})
spreadsheet_id = extract_id(result)
sheet_id = get_sheet_id(result)
return {"spreadsheet_id": spreadsheet_id, "sheet_id": sheet_id}
finally:
await client.close()
# FUNCTION-SCOPED: Individual test
@pytest.mark.asyncio
async def test_format_sheet_range(self, client, test_spreadsheet_data):
"""Test using BOTH fixtures."""
# client: Auto-injected function-scoped fixture (fresh client)
# test_spreadsheet_data: Session-scoped fixture (shared data)
result = await client.call_tool("format_sheet_range", {
"user_google_email": TEST_EMAIL,
"spreadsheet_id": test_spreadsheet_data["spreadsheet_id"], # Shared resource
"sheet_id": test_spreadsheet_data["sheet_id"],
"bold": True
})
assert result is not None
```
**Why this pattern?**
1. Create expensive spreadsheet once (`session` scope)
2. Each test gets fresh client (`function` scope)
3. Tests share the spreadsheet but don't interfere with each other
4. Fast execution: 1 spreadsheet creation instead of N
## ๐ Quick Start
### 1. Run Existing Tests
```bash
# Run all client tests (uses standardized framework)
uv run pytest tests/client/ -v
# Run tests for specific service
uv run pytest tests/client/ -k "gmail" -v
# Run with specific markers
uv run pytest tests/client/ -m "auth_required" -v
uv run pytest tests/client/ -m "service('gmail')" -v
```
### 2. Create New Test File
Copy the template and customize:
```bash
# Copy template
cp tests/client/test_template.py tests/client/test_drive_operations.py
# Edit the new file:
# 1. Replace 'template' with your service name
# 2. Replace 'TemplateService' with your service class name
# 3. Add your specific tool names and parameters
# 4. Customize test logic for your service
```
### 3. Basic Test Structure
```python
"""Test Drive operations using standardized framework."""
import pytest
from .test_helpers import ToolTestRunner, TestResponseValidator
@pytest.mark.service("drive")
class TestDriveOperations:
@pytest.mark.asyncio
async def test_drive_search(self, client):
"""Test Drive file search functionality."""
from .base_test_config import TEST_EMAIL
runner = ToolTestRunner(client, TEST_EMAIL)
# Test the tool with auth patterns
results = await runner.test_auth_patterns("search_drive_files", {
"query": "type:pdf"
})
# Validate results
assert results["backward_compatible"], "Should maintain backward compatibility"
@pytest.mark.asyncio
async def test_get_document_with_real_id(self, client, real_drive_document_id):
"""Test getting document content with real document ID."""
from .base_test_config import TEST_EMAIL
result = await client.call_tool("get_doc_content", {
"user_google_email": TEST_EMAIL,
"document_id": real_drive_document_id # Real ID from service://drive/items
})
# This tests with actual user data when available
assert result is not None
```
## ๐ง Framework Features
### Automatic Protocol Detection
The framework automatically detects HTTP vs HTTPS and provides fallback:
```python
# From base_test_config.py
async def create_test_client(test_email: str = TEST_EMAIL):
"""Create a client with automatic protocol detection and fallback."""
# Tries HTTPS first if detected, then HTTP fallback
# Handles SSL bypass for testing
# Provides detailed error diagnostics
```
### Smart Response Validation
Built-in validation for different response types:
```python
# From test_helpers.py
validator = TestResponseValidator()
# Check if response indicates proper auth handling
validator.is_valid_auth_response(content)
# Check if operation was successful
validator.is_success_response(content)
# Validate service-specific responses
validator.validate_service_response(content, "gmail")
```
### Real Resource ID Integration
The framework now fetches real IDs from the service resource system for more realistic testing:
```python
# From resource_helpers.py
from .resource_helpers import ResourceIDFetcher
async def get_real_ids_for_testing(client):
"""Fetch real IDs from service resources."""
fetcher = ResourceIDFetcher(client)
# Get real IDs from various services
gmail_message_id = await fetcher.get_gmail_message_id() # from service://gmail/messages
drive_document_id = await fetcher.get_drive_document_id() # from service://drive/items
calendar_event_id = await fetcher.get_calendar_event_id() # from service://calendar/events
return gmail_message_id, drive_document_id, calendar_event_id
```
#### Available Real ID Fixtures
The framework provides pytest fixtures that automatically fetch real IDs:
- `real_gmail_message_id` - Real Gmail message ID from service://gmail/messages
- `real_gmail_filter_id` - Real Gmail filter ID from service://gmail/filters
- `real_drive_document_id` - Real Drive document ID from service://drive/items
- `real_drive_folder_id` - Real Drive folder ID from service://drive/items
- `real_calendar_event_id` - Real Calendar event ID from service://calendar/events
- `real_photos_album_id` - Real Photos album ID from service://photos/albums
- `real_forms_form_id` - Real Forms form ID from service://forms/forms
- `real_chat_space_id` - Real Chat space ID from service://chat/spaces
#### Usage Pattern Comparison
```python
# โ OLD: Using fake IDs (less realistic)
async def test_gmail_reply_old(self, client):
result = await client.call_tool("reply_to_gmail_message", {
"user_google_email": TEST_EMAIL,
"message_id": "fake_message_id_123", # Always fails with "not found"
"body": "Test reply"
})
# โ
NEW: Using real IDs (more realistic)
async def test_gmail_reply_new(self, client, real_gmail_message_id):
result = await client.call_tool("reply_to_gmail_message", {
"user_google_email": TEST_EMAIL,
"message_id": real_gmail_message_id, # Uses actual user message
"body": "Test reply"
})
# Tests against real data - can succeed if authenticated or fail with proper auth errors
```
### Standardized Test Patterns
All tests follow the same authentication pattern testing:
```python
runner = ToolTestRunner(client, TEST_EMAIL)
# Test both auth patterns automatically
auth_results = await runner.test_auth_patterns("tool_name", params)
# Results include:
# - explicit_email: Test with user_google_email parameter
# - middleware_injection: Test without parameter (middleware handles it)
# - backward_compatible: Whether explicit pattern works
# - middleware_supported: Whether middleware injection works
```
## ๐ Test Categories & Markers
### Available Markers
- `@pytest.mark.service("service_name")` - Group tests by Google service
- `@pytest.mark.auth_required` - Tests requiring authentication
- `@pytest.mark.integration` - Integration tests needing server
- `@pytest.mark.slow` - Slow-running tests
- `@pytest.mark.smoke` - Basic smoke tests
### Example Usage
```python
@pytest.mark.service("gmail")
@pytest.mark.auth_required
async def test_gmail_send_email(self, client):
"""Test Gmail sending with authentication required."""
# Test implementation
```
## ๐ ๏ธ Common Patterns
### 1. Test Tool Availability
```python
@pytest.mark.asyncio
async def test_tools_available(self, client):
"""Test that expected tools are available."""
expected_tools = ["tool1", "tool2", "tool3"]
tools = await client.list_tools()
tool_names = [tool.name for tool in tools]
for tool_name in expected_tools:
assert tool_name in tool_names, f"Tool {tool_name} should be available"
```
### 2. Test Authentication Patterns
```python
@pytest.mark.asyncio
async def test_auth_patterns(self, client):
"""Test both explicit and middleware authentication patterns."""
from .base_test_config import TEST_EMAIL
runner = ToolTestRunner(client, TEST_EMAIL)
results = await runner.test_auth_patterns("your_tool", {"param": "value"})
# Both patterns should work or give valid auth responses
assert results["backward_compatible"], "Explicit email should work"
assert results["middleware_supported"] or results["middleware_injection"]["param_required_at_client"], \
"Middleware should work or require param at client level"
```
### 3. Test Service-Specific Functionality
```python
@pytest.mark.asyncio
async def test_service_functionality(self, client):
"""Test service-specific functionality."""
runner = ToolTestRunner(client, TEST_EMAIL)
result = await runner.test_tool_with_explicit_email("service_tool", {
"service_param": "test_value"
})
if result["success"]:
# Validate service-specific response format
is_valid = TestResponseValidator.validate_service_response(
result["content"], "service_name"
)
assert is_valid, "Should get valid service response"
```
## ๐ Debugging
### Test Configuration Debugging
The framework automatically prints configuration on startup:
```
๐ง Test Configuration:
SERVER_HOST: localhost
SERVER_PORT: 8002
DETECTED_PROTOCOL: https
FINAL_PROTOCOL: https
SERVER_URL: https://localhost:8002/mcp/
TEST_EMAIL: test@example.com
```
### Connection Diagnostics
If connection fails, detailed diagnostics are provided:
```
โ Failed to connect to server on both HTTP and HTTPS
Attempted URLs:
- http://localhost:8002/mcp/
- https://localhost:8002/mcp/
Troubleshooting:
1. Is the server running?
2. Check SSL configuration
3. Verify environment variables
```
### Verbose Test Output
Use verbose mode for detailed test information:
```bash
uv run pytest tests/client/test_gmail_tools.py -v -s
```
## ๐ Environment Variables
### Required
- `TEST_EMAIL_ADDRESS`: Email address for testing (default: test_example@gmail.com)
- `SERVER_HOST`: Server hostname (default: localhost)
- `SERVER_PORT`: Server port (default: 8002)
### Optional Service Testing
- `GOOGLE_SLIDE_PRESENTATION_ID`: Test presentation ID for Slides tests (avoids creation calls)
- `TEST_CHAT_WEBHOOK`: Chat webhook URL for integration tests
- `PHOTO_TEST_EMAIL_ADDRESS`: Alternative email for Photos tests
### Optional Server Configuration
- `MCP_PROTOCOL`: Force protocol (http/https)
- `MCP_SERVER_URL`: Override complete server URL
- `ENABLE_HTTPS`: Enable HTTPS mode
- `SSL_CERT_FILE`: SSL certificate file path
- `SSL_KEY_FILE`: SSL key file path
## ๐จ Customization
### Adding New Service Support
1. **Add service patterns** to `test_helpers.py`:
```python
# In get_common_test_tools()
service_tools = {
"your_service": ["tool1", "tool2", "tool3"]
}
# In create_service_test_params()
service_params = {
"your_service": {
"tool1": {"param": "value"},
"tool2": {"param2": "value2"}
}
}
```
2. **Add service validation** to `TestResponseValidator`:
```python
# In validate_service_response()
service_patterns = {
"your_service": ["keyword1", "keyword2", "keyword3"]
}
```
### Creating Custom Test Classes
Follow this pattern for consistency:
```python
@pytest.mark.service("your_service")
class TestYourServiceTools:
"""Tests for Your Service tools."""
@pytest.mark.asyncio
async def test_service_availability(self, client):
"""Test service tools are available."""
# Implementation
@pytest.mark.asyncio
async def test_service_auth_patterns(self, client):
"""Test authentication patterns."""
# Implementation
@pytest.mark.asyncio
@pytest.mark.auth_required
async def test_service_functionality(self, client):
"""Test authenticated functionality."""
# Implementation
```
## ๐งช Best Practices
1. **Use the standard fixtures**: Always use the `client` fixture from `conftest.py`
2. **Follow naming conventions**: `test_<service>_<functionality>.py`
3. **Use appropriate markers**: Mark tests with service and requirement markers
4. **Test both auth patterns**: Always test explicit email and middleware injection
5. **Handle auth errors gracefully**: Auth errors are valid responses during testing
6. **Use helper classes**: Leverage `ToolTestRunner` and `TestResponseValidator`
7. **Provide clear assertions**: Include meaningful error messages in assertions
8. **Document test purpose**: Include clear docstrings explaining what each test validates
9. **Use real resource IDs**: Prefer real ID fixtures over hardcoded fake IDs for realistic testing
10. **Handle fallback IDs**: Real ID fixtures provide fallbacks when actual resources aren't available
11. **Choose correct fixture scope**: Use `function` scope (standard `client` fixture) unless you need shared session resources
## ๐ Practical Examples with Real Resources
### Gmail Operations with Real Message IDs
```python
@pytest.mark.service("gmail")
class TestGmailWithRealData:
@pytest.mark.asyncio
async def test_reply_to_real_message(self, client, real_gmail_message_id):
"""Test replying to a real Gmail message."""
from .base_test_config import TEST_EMAIL
result = await client.call_tool("reply_to_gmail_message", {
"user_google_email": TEST_EMAIL,
"message_id": real_gmail_message_id,
"body": "Test reply to real message",
"reply_mode": "sender_only"
})
# With real ID, this can succeed or give proper auth errors
assert result is not None
```
### Drive Operations with Real Document IDs
```python
@pytest.mark.service("drive")
class TestDriveWithRealData:
@pytest.mark.asyncio
async def test_get_real_document_content(self, client, real_drive_document_id):
"""Test getting content from a real Drive document."""
from .base_test_config import TEST_EMAIL
result = await client.call_tool("get_doc_content", {
"user_google_email": TEST_EMAIL,
"document_id": real_drive_document_id
})
# Tests against actual user's document
assert result is not None
content = result.content[0].text if result.content else str(result)
# Should get document content or proper auth error
valid_responses = ["document content", "text", "authentication", "not found"]
assert any(keyword in content.lower() for keyword in valid_responses)
```
### Calendar Operations with Real Event IDs
```python
@pytest.mark.service("calendar")
class TestCalendarWithRealData:
@pytest.mark.asyncio
async def test_get_real_event_details(self, client, real_calendar_event_id):
"""Test getting details of a real calendar event."""
from .base_test_config import TEST_EMAIL
result = await client.call_tool("get_event", {
"user_google_email": TEST_EMAIL,
"event_id": real_calendar_event_id
})
# Tests against actual user's calendar event
assert result is not None
content = result.content[0].text if result.content else str(result)
# Should get event details or proper errors
valid_responses = ["event", "calendar", "summary", "authentication", "not found"]
assert any(keyword in content.lower() for keyword in valid_responses)
```
### Manual Resource ID Fetching
```python
@pytest.mark.asyncio
async def test_custom_resource_fetching(self, client):
"""Example of manually fetching specific resource IDs."""
from .resource_helpers import ResourceIDFetcher
fetcher = ResourceIDFetcher(client)
# Fetch specific types of resources
gmail_filters = await fetcher.get_first_id_from_service("gmail", "filters")
drive_folders = await fetcher.get_first_id_from_service("drive", "items")
calendar_events = await fetcher.get_first_id_from_service("calendar", "events")
print(f"Real Gmail filter ID: {gmail_filters}")
print(f"Real Drive item ID: {drive_folders}")
print(f"Real Calendar event ID: {calendar_events}")
# Use these IDs in your tests
if gmail_filters:
result = await client.call_tool("get_gmail_filter", {
"user_google_email": TEST_EMAIL,
"filter_id": gmail_filters
})
assert result is not None
```
## โ
Recent Framework Improvements
### Real Resource ID Integration (Latest Update)
- **Resource Helper System**: Added `resource_helpers.py` with `ResourceIDFetcher` class for getting real IDs from `service://` resources
- **Real ID Fixtures**: Added pytest fixtures (`real_gmail_message_id`, `real_drive_document_id`, etc.) that fetch actual IDs from the service resource system
- **Updated Test Files**: Modified 8 major test files to use real IDs instead of hardcoded fake ones:
- `test_gmail_reply_improvements.py` - Now uses real Gmail message IDs
- `test_gmail_forward_functionality.py` - Uses real message IDs for forwarding tests
- `test_mcp_client.py` - Uses real Drive document/folder IDs and Forms IDs
- `test_calendar_tools.py` - Uses real Calendar event IDs
- `test_enhanced_gmail_filters.py` - Uses real Gmail filter IDs
- `test_list_tools.py` - Uses real Forms and Photos IDs
- **Fallback Support**: All fixtures provide fallback fake IDs if real resources aren't available
- **Improved Test Realism**: Tests now validate against actual user data when available, making them more representative of real-world usage
### Test Suite Reliability (Previous Update)
- **Fixed CallToolResult API Changes**: Updated all tests from `result[0].text` to `result.content[0].text` pattern
- **Resolved Async Coroutine Issues**: Fixed fixture patterns that caused "cannot reuse already awaited coroutine" errors
- **Corrected Tool Name Mismatches**: Updated deprecated tool names (e.g., `get_messages` โ `list_messages`)
- **Enhanced Response Validation**: Added support for successful API response patterns alongside error handling
- **Environment Configuration**: Added support for `GOOGLE_SLIDE_PRESENTATION_ID` to avoid hardcoded test resources
### Test Results Achievement
- **Before All Fixes**: 35 failed, 20 passed, 16 skipped
- **After Resource Integration**: Tests now use real data when available, significantly improving test quality
- **Improvement**: 100% test reliability for all critical functionality + realistic testing with real resources
This framework makes it easy to create comprehensive, consistent tests for all Google Workspace services while maintaining the excellent patterns established in the original authentication improvement tests.