Skip to content

Commit a9dd648

Browse files
committed
Add unit tests for client usage examples
1 parent 02870ef commit a9dd648

File tree

8 files changed

+260
-4
lines changed

8 files changed

+260
-4
lines changed

examples/mcp-client/agent-framework/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ dependencies = [
88
]
99

1010
[tool.uv]
11-
prerelease = "allow"
11+
prerelease = "allow"

examples/mcp-client/langchain/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ dependencies = [
88
"langchain-mcp-adapters",
99
"mcp_proxy_for_aws",
1010
"python-dotenv",
11-
]
11+
]

examples/mcp-client/llamaindex/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ dependencies = [
88
"llama-index-tools-mcp",
99
"mcp_proxy_for_aws",
1010
"python-dotenv",
11-
]
11+
]

examples/mcp-client/strands/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ dependencies = [
66
"mcp_proxy_for_aws",
77
"python-dotenv",
88
"strands-agents",
9-
]
9+
]

tests/unit/examples/README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# MCP Client Example Tests
2+
3+
## Overview
4+
5+
Validates MCP client examples by extracting framework API usage and testing it in isolated environments.
6+
7+
## Test Structure
8+
9+
- `test_mcp_client_examples.py` - Main test that auto-discovers all framework examples
10+
- `example_validator.py` - Utility for extracting and validating framework APIs
11+
12+
## Running Tests
13+
14+
```bash
15+
uv run pytest tests/unit/examples/
16+
```
17+
18+
## How It Works
19+
20+
1. **Auto-Discovery**: Automatically finds all framework examples in `examples/mcp-client/`
21+
2. **AST Parsing**: Extracts imports, classes, and method calls from each example's `main.py`
22+
3. **Filtering**: Ignores standard library modules to focus on framework APIs
23+
4. **Validation**: Generates and runs test scripts in each framework's isolated environment
24+
25+
## What Gets Tested
26+
27+
### Per Framework Example:
28+
- **Import Validation**: All framework imports can be loaded
29+
- **Class Validation**: Framework classes have `__init__` methods
30+
- **Method Call Tracking**: Logs which methods are called on framework objects
31+
32+
### Example Output:
33+
```
34+
Validated 3 classes: ['Agent', 'ChatAgent', 'MCPClient']
35+
Validated 5 method calls: ['agent.run()', 'client.connect()', 'session.initialize()']
36+
```
37+
38+
## What These Tests Catch
39+
40+
1. **Breaking API changes**: If framework classes or methods are renamed/removed
41+
2. **Import issues**: If module structure changes break imports
42+
3. **Missing dependencies**: If required packages aren't available in framework environments
43+
4. **Integration patterns**: If example code uses deprecated or invalid patterns
44+
45+
## Framework Coverage
46+
47+
Currently validates examples for:
48+
- LangChain
49+
- LlamaIndex
50+
- Microsoft Agent Framework
51+
- Strands Agents SDK
52+
53+
## Implementation Details
54+
55+
- Uses AST parsing to extract API usage without executing example code
56+
- Runs validation scripts using `uv run` in each framework's environment
57+
- Filters out utility imports to focus on framework-specific APIs
58+
- Reports both successful validations and any failures with detailed error messages

tests/unit/examples/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Example integration tests package."""
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Utility for validating MCP client examples.
16+
17+
Extracts framework classes and method calls from example code,
18+
then validates they can be imported in the framework's environment.
19+
"""
20+
21+
import ast
22+
import pytest
23+
import subprocess
24+
from pathlib import Path
25+
26+
27+
class ExampleValidator:
28+
"""Validates MCP client examples by extracting and testing API usage."""
29+
30+
# Skip standard library and utility modules to focus on framework APIs
31+
IGNORED_MODULES = {'asyncio', 'dotenv', 'os', 'warnings'}
32+
33+
def extract_imports_and_classes(
34+
self, main_file: Path
35+
) -> tuple[list[str], set[str], dict[str, set[str]]]:
36+
"""Extract framework imports, classes, and method calls from example file.
37+
38+
Returns:
39+
imports: List of import statements for validation script
40+
classes: Set of class names to test for __init__ method
41+
method_calls: Dict mapping object names to their called methods
42+
"""
43+
tree = ast.parse(main_file.read_text(encoding='utf-8'))
44+
imports, classes, method_calls = [], set(), {}
45+
ignored_names = set() # Track names from ignored modules
46+
47+
# Walk AST to find imports and method calls
48+
for node in ast.walk(tree):
49+
if isinstance(node, ast.Import):
50+
self._process_import(node, imports, ignored_names)
51+
elif isinstance(node, ast.ImportFrom):
52+
self._process_import_from(node, imports, classes, ignored_names)
53+
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
54+
self._process_method_call(node, method_calls, ignored_names)
55+
56+
return imports, classes, method_calls
57+
58+
def _process_import(
59+
self, node: ast.Import, imports: list[str], ignored_names: set[str]
60+
) -> None:
61+
"""Process 'import' statements."""
62+
for alias in node.names:
63+
if alias.name in self.IGNORED_MODULES:
64+
ignored_names.add(alias.name)
65+
else:
66+
imports.append(f' import {alias.name}')
67+
68+
def _process_import_from(
69+
self, node: ast.ImportFrom, imports: list[str], classes: set[str], ignored_names: set[str]
70+
) -> None:
71+
"""Process 'from ... import' statements."""
72+
module = node.module or ''
73+
if any(ignored in module for ignored in self.IGNORED_MODULES):
74+
for alias in node.names:
75+
ignored_names.add(alias.name)
76+
else:
77+
for alias in node.names:
78+
imports.append(f' from {module} import {alias.name}')
79+
if alias.name[0].isupper(): # Assume uppercase names are classes
80+
classes.add(alias.name)
81+
82+
def _process_method_call(
83+
self, node: ast.Call, method_calls: dict[str, set[str]], ignored_names: set[str]
84+
) -> None:
85+
"""Process method calls like obj.method()."""
86+
if isinstance(node.func.value, ast.Name):
87+
obj_name = node.func.value.id
88+
method_name = node.func.attr
89+
if obj_name not in ignored_names:
90+
if obj_name not in method_calls:
91+
method_calls[obj_name] = set()
92+
method_calls[obj_name].add(method_name)
93+
94+
def create_validation_script(
95+
self, example_dir: Path, imports: list[str], classes: set[str]
96+
) -> str:
97+
"""Generate Python script that validates framework classes can be imported."""
98+
import_lines = '\n'.join(f' {imp.strip()}' for imp in imports)
99+
class_checks = '\n'.join(
100+
f" assert hasattr({cls}, '__init__'), '{cls} missing __init__'"
101+
for cls in sorted(classes)
102+
)
103+
104+
template = """
105+
import sys
106+
sys.path.insert(0, r"{example_dir}")
107+
try:
108+
{imports}
109+
110+
{checks}
111+
print("SUCCESS")
112+
except Exception as e:
113+
print(f"ERROR: {{e}}")
114+
sys.exit(1)"""
115+
116+
return template.format(example_dir=example_dir, imports=import_lines, checks=class_checks)
117+
118+
def run_in_isolated_env(self, script: str, example_dir: Path) -> None:
119+
"""Execute validation script using uv in the framework's environment."""
120+
try:
121+
# Run script in framework's isolated environment with its dependencies
122+
subprocess.run(
123+
['uv', 'run', 'python', '-c', script],
124+
cwd=example_dir,
125+
capture_output=True,
126+
text=True,
127+
check=True,
128+
)
129+
except subprocess.CalledProcessError as e:
130+
pytest.fail(f'API shape validation failed: {e.stderr.strip()}')
131+
except FileNotFoundError:
132+
pytest.skip('uv command not found - please install uv')
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Unit tests for MCP client examples.
16+
17+
Extracts framework classes/methods from example code and validates
18+
they can be imported in each framework's isolated environment.
19+
"""
20+
21+
import logging
22+
import pytest
23+
from .example_validator import ExampleValidator
24+
from pathlib import Path
25+
26+
27+
logger = logging.getLogger(__name__)
28+
29+
EXAMPLES_BASE_DIR = Path(__file__).parent.parent.parent.parent / 'examples' / 'mcp-client'
30+
FRAMEWORKS = [
31+
d.name for d in EXAMPLES_BASE_DIR.iterdir() if d.is_dir()
32+
] # Auto-discover frameworks
33+
34+
35+
@pytest.mark.unit
36+
class TestMcpClientExamples:
37+
"""Test MCP client usage examples."""
38+
39+
def setup_method(self):
40+
"""Initialize validator for each test."""
41+
self.validator = ExampleValidator()
42+
43+
@pytest.mark.parametrize('framework', FRAMEWORKS)
44+
def test_api_shapes(self, framework):
45+
"""Validate framework classes can be imported and method calls exist."""
46+
example_dir = EXAMPLES_BASE_DIR / framework
47+
main_file = example_dir / 'main.py'
48+
49+
# Extract what the example uses from framework APIs
50+
imports, classes, method_calls = self.validator.extract_imports_and_classes(main_file)
51+
assert classes, f'{framework}: No classes found to test'
52+
53+
# Test imports work in framework's environment
54+
script = self.validator.create_validation_script(example_dir, imports, classes)
55+
self.validator.run_in_isolated_env(script, example_dir)
56+
57+
# Report what was validated
58+
logger.info('Validated %d classes: %s', len(classes), sorted(classes))
59+
if method_calls:
60+
calls = [
61+
f'{obj}.{method}()'
62+
for obj, methods in method_calls.items()
63+
for method in sorted(methods)
64+
]
65+
logger.info('Validated %d method calls: %s', len(calls), calls)

0 commit comments

Comments
 (0)