Skip to main content
Glama

LoreKeeper MCP

by frap129
testing.md21.2 kB
# Testing Guide This comprehensive guide covers testing practices, patterns, and tools used in the LoreKeeper MCP project. ## Table of Contents - [Testing Philosophy](#testing-philosophy) - [Test Structure](#test-structure) - [Testing Tools](#testing-tools) - [Writing Tests](#writing-tests) - [Test Patterns](#test-patterns) - [Async Testing](#async-testing) - [Mocking and Fixtures](#mocking-and-fixtures) - [Test Coverage](#test-coverage) - [Running Tests](#running-tests) - [CI/CD Integration](#cicd-integration) ## Testing Philosophy ### Principles 1. **Test-Driven Development (TDD)**: Write tests before or alongside code 2. **Comprehensive Coverage**: Test all public APIs and error conditions 3. **Fast Feedback**: Keep tests fast and reliable 4. **Clear Intent**: Tests should document expected behavior 5. **Isolation**: Tests should not depend on each other ### Test Pyramid ``` E2E Tests (Few) ───────────────── Integration Tests (Some) ───────────────────────── Unit Tests (Many) ``` - **Unit Tests**: Test individual functions and classes in isolation - **Integration Tests**: Test component interactions - **End-to-End Tests**: Test complete workflows (minimal) ## Test Structure ### Directory Layout ``` tests/ ├── conftest.py # Global fixtures and configuration ├── test_config.py # Configuration tests ├── test_server.py # Server tests ├── test_project_structure.py # Project structure validation └── test_cache/ # Cache layer tests ├── __init__.py └── test_db.py # Database operations tests ``` ### File Naming - Test files: `test_*.py` - Test classes: `Test*` - Test methods: `test_*` ### Test Organization ```python # Group related tests in classes class TestCacheOperations: """Test cache read/write operations.""" async def test_set_and_get_cached(self): """Test that cached data can be retrieved.""" pass async def test_get_expired_returns_none(self): """Test that expired cache returns None.""" pass class TestCacheTTL: """Test cache TTL functionality.""" async def test_ttl_enforcement(self): """Test that TTL is properly enforced.""" pass ``` ## Testing Tools ### Core Dependencies ```toml [project.optional-dependencies] dev = [ "pytest>=8.0.0", # Test framework "pytest-asyncio>=0.23.0", # Async test support "pytest-cov>=4.0.0", # Coverage reporting "respx>=0.21.0", # HTTP mocking ] ``` ### Tool Configuration #### pytest.ini (in pyproject.toml) ```toml [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] pythonpath = ["src"] addopts = [ "--strict-markers", "--strict-config", "--verbose", ] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as integration tests", "unit: marks tests as unit tests", ] ``` ## Writing Tests ### Basic Test Structure ```python import pytest from lorekeeper_mcp.cache.db import get_cached, set_cached class TestCacheOperations: """Test cache read/write operations.""" async def test_set_and_get_cached(self, temp_db): """Test that cached data can be retrieved.""" # Arrange key = "test:key" data = {"test": "data", "number": 42} # Act await set_cached(key, data, "test", 3600) result = await get_cached(key) # Assert assert result == data assert result["test"] == "data" assert result["number"] == 42 async def test_get_nonexistent_returns_none(self, temp_db): """Test that getting non-existent key returns None.""" # Act result = await get_cached("nonexistent:key") # Assert assert result is None ``` ### Parameterized Tests Use parameterization for testing multiple scenarios: ```python @pytest.mark.parametrize("level,expected_count", [ (0, 15), # Cantrips (1, 30), # 1st level spells (3, 25), # 3rd level spells (9, 5), # 9th level spells ]) async def test_spells_by_level(level, expected_count): """Test spell filtering by level.""" results = await lookup_spell(level=level) assert len(results) <= expected_count # Verify all returned spells have correct level for spell in results: assert spell["level"] == level @pytest.mark.parametrize("content_type,ttl", [ ("spell", 7 * 24 * 3600), ("monster", 7 * 24 * 3600), ("rule", 14 * 24 * 3600), ("error", 300), ]) def test_ttl_configuration(content_type, ttl): """Test TTL values for different content types.""" from lorekeeper_mcp.cache.db import get_ttl_for_content_type assert get_ttl_for_content_type(content_type) == ttl ``` ### Exception Testing Test both success and failure scenarios: ```python import pytest from lorekeeper_mcp.config import Settings class TestConfiguration: """Test configuration management.""" def test_default_configuration(self): """Test that default configuration is valid.""" settings = Settings() assert settings.db_path == Path("./data/cache.db") assert settings.cache_ttl_days == 7 assert settings.error_cache_ttl_seconds == 300 assert settings.log_level == "INFO" assert settings.debug is False def test_environment_override(self, monkeypatch): """Test that environment variables override defaults.""" monkeypatch.setenv("CACHE_TTL_DAYS", "14") monkeypatch.setenv("DEBUG", "true") settings = Settings() assert settings.cache_ttl_days == 14 assert settings.debug is True def test_invalid_configuration(self, monkeypatch): """Test that invalid configuration raises errors.""" monkeypatch.setenv("CACHE_TTL_DAYS", "invalid") with pytest.raises(ValidationError): Settings() ``` ## Test Patterns ### AAA Pattern (Arrange, Act, Assert) ```python async def test_cache_expiration(self, temp_db): """Test that cache entries expire correctly.""" # Arrange key = "test:expiration" data = {"test": "data"} short_ttl = 1 # 1 second # Act await set_cached(key, data, "test", short_ttl) # Should be available immediately result = await get_cached(key) assert result == data # Wait for expiration await asyncio.sleep(1.1) # Assert result = await get_cached(key) assert result is None ``` ### Builder Pattern for Test Data ```python class SpellBuilder: """Builder for creating test spell data.""" def __init__(self): self.spell = { "name": "Test Spell", "level": 1, "school": "evocation", "casting_time": "1 action", "range": "120 feet", "components": ["V", "S", "M"], "duration": "Instantaneous", "description": "A test spell for testing.", } def with_name(self, name: str): self.spell["name"] = name return self def with_level(self, level: int): self.spell["level"] = level return self def with_school(self, school: str): self.spell["school"] = school return self def build(self): return self.spell.copy() # Usage in tests async def test_spell_filtering_by_school(self): """Test spell filtering by magic school.""" # Arrange evocation_spell = SpellBuilder().with_name("Fireball").with_school("evocation").build() illusion_spell = SpellBuilder().with_name("Mirror Image").with_school("illusion").build() # Mock API responses with respx.mock: respx.get("https://api.open5e.com/v2/spells/").mock( return_value=httpx.Response(200, json={"results": [evocation_spell, illusion_spell]}) ) # Act results = await lookup_spell(school="evocation") # Assert assert len(results) == 1 assert results[0]["school"] == "evocation" assert results[0]["name"] == "Fireball" ``` ### Custom Assertions Create reusable assertion helpers: ```python class CacheAssertions: """Custom assertions for cache testing.""" @staticmethod async def assert_cached(key: str, expected_data: dict): """Assert that data is cached correctly.""" cached_data = await get_cached(key) assert cached_data == expected_data, f"Cached data mismatch for key: {key}" @staticmethod async def assert_not_cached(key: str): """Assert that data is not cached.""" cached_data = await get_cached(key) assert cached_data is None, f"Unexpected cached data for key: {key}" @staticmethod async def assert_cache_expires(key: str, ttl_seconds: int): """Assert that cache entry expires after TTL.""" # Set cache await set_cached(key, {"test": "data"}, "test", ttl_seconds) # Should be available await CacheAssertions.assert_cached(key, {"test": "data"}) # Wait for expiration await asyncio.sleep(ttl_seconds + 0.1) # Should be expired await CacheAssertions.assert_not_cached(key) # Usage in tests async def test_cache_ttl_functionality(self, temp_db): """Test cache TTL functionality.""" await CacheAssertions.assert_cache_expires("test:ttl", 2) ``` ## Async Testing ### Async Test Functions All async tests must use `async def` and be properly decorated: ```python import pytest @pytest.mark.asyncio async def test_async_cache_operation(self): """Test async cache operation.""" result = await get_cached("test:key") assert result is None ``` ### Async Context Managers Test async context managers properly: ```python async def test_database_connection_context_manager(self): """Test database connection context manager.""" db_path = ":memory:" async with aiosqlite.connect(db_path) as db: # Database is open within context await db.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)") await db.execute("INSERT INTO test (id) VALUES (1)") await db.commit() cursor = await db.execute("SELECT COUNT(*) FROM test") count = (await cursor.fetchone())[0] assert count == 1 # Database is closed outside context with pytest.raises(sqlite3.ProgrammingError): await db.execute("SELECT 1") ``` ### Async Fixtures Create async fixtures for test setup: ```python @pytest.fixture async def temp_database(): """Provide a temporary database for testing.""" # Create temporary database db_path = tempfile.mktemp(suffix=".db") # Initialize database await init_db_with_path(db_path) yield db_path # Cleanup os.unlink(db_path) @pytest.fixture async def populated_cache(temp_database): """Provide a cache with test data.""" test_data = [ ("spell:fireball", {"name": "Fireball", "level": 3}, "spell", 3600), ("monster:goblin", {"name": "Goblin", "cr": 0.25}, "monster", 3600), ] for key, data, content_type, ttl in test_data: await set_cached(key, data, content_type, ttl) return test_data ``` ## Mocking and Fixtures ### HTTP Mocking with respx Mock external API calls: ```python import respx import httpx @pytest.fixture def mock_open5e_api(): """Mock Open5e API responses.""" with respx.mock: # Mock spell search respx.get("https://api.open5e.com/v2/spells/").mock( return_value=httpx.Response( 200, json={ "results": [ { "name": "Fireball", "level": 3, "school": "evocation", "casting_time": "1 action", "range": "150 feet", "components": ["V", "S", "M"], "duration": "Instantaneous", "description": "A brilliant streak of flame...", } ] } ) ) # Mock monster search respx.get("https://api.open5e.com/v1/monsters/").mock( return_value=httpx.Response( 200, json={ "results": [ { "name": "Goblin", "cr": "0.25", "type": "humanoid", "size": "Small", "hp": 7, "ac": 15, } ] } ) ) yield async def test_spell_lookup_with_mock_api(mock_open5e_api): """Test spell lookup with mocked API.""" results = await lookup_spell(name="fireball") assert len(results) == 1 assert results[0]["name"] == "Fireball" assert results[0]["level"] == 3 # Verify API was called assert respx.calls.last.request.url == "https://api.open5e.com/v2/spells/?name=fireball" ``` ### Database Fixtures Create reusable database fixtures: ```python @pytest.fixture async def temp_db(): """Provide a temporary database for testing.""" # Create temporary database file db_fd, db_path = tempfile.mkstemp(suffix=".db") os.close(db_fd) # Override settings for test original_db_path = settings.db_path settings.db_path = Path(db_path) # Initialize database await init_db() yield db_path # Cleanup settings.db_path = original_db_path os.unlink(db_path) @pytest.fixture async def cache_with_data(temp_db): """Provide a cache with pre-populated test data.""" test_entries = [ ("spell:test", {"name": "Test Spell"}, "spell", 3600, "open5e"), ("monster:test", {"name": "Test Monster"}, "monster", 3600, "open5e"), ] for key, data, content_type, ttl, source in test_entries: await set_cached(key, data, content_type, ttl, source) return test_entries ``` ### Configuration Fixtures Test different configuration scenarios: ```python @pytest.fixture def test_config(): """Provide test configuration.""" return Settings( db_path=Path(":memory:"), cache_ttl_days=1, error_cache_ttl_seconds=60, log_level="DEBUG", debug=True, ) @pytest.fixture def mock_env_vars(monkeypatch): """Mock environment variables for testing.""" monkeypatch.setenv("CACHE_TTL_DAYS", "30") monkeypatch.setenv("DEBUG", "true") monkeypatch.setenv("LOG_LEVEL", "DEBUG") ``` ## Test Coverage ### Coverage Requirements - **Overall Coverage**: >90% - **Branch Coverage**: >85% - **Public APIs**: 100% coverage - **Error Paths**: All error conditions tested ### Coverage Configuration ```toml [tool.coverage.run] source = ["src/lorekeeper_mcp"] omit = [ "*/tests/*", "*/test_*", "*/__pycache__/*", ] [tool.coverage.report] exclude_lines = [ "pragma: no cover", "def __repr__", "raise AssertionError", "raise NotImplementedError", "if __name__ == .__main__.:", "if TYPE_CHECKING:", ] show_missing = true precision = 2 [tool.coverage.html] directory = "htmlcov" ``` ### Running Coverage ```bash # Run tests with coverage uv run pytest --cov=lorekeeper_mcp --cov-report=html # Generate coverage report uv run pytest --cov=lorekeeper_mcp --cov-report=term-missing # Coverage for specific module uv run pytest --cov=lorekeeper_mcp.cache tests/test_cache/ # Minimum coverage threshold uv run pytest --cov=lorekeeper_mcp --cov-fail-under=90 ``` ### Coverage Exclusions Justified exclusions: ```python def __repr__(self) -> str: # pragma: no cover """String representation for debugging.""" return f"{self.__class__.__name__}({self.id})" if TYPE_CHECKING: # pragma: no cover # Type checking imports from typing import Optional if __name__ == "__main__": # pragma: no cover # CLI entry point main() ``` ## Running Tests ### Basic Commands ```bash # Run all tests uv run pytest # Run with verbose output uv run pytest -v # Run specific test file uv run pytest tests/test_cache/test_db.py # Run specific test class uv run pytest tests/test_cache/test_db.py::TestCacheOperations # Run specific test method uv run pytest tests/test_cache/test_db.py::TestCacheOperations::test_set_and_get_cached ``` ### Test Selection ```bash # Run only unit tests uv run pytest -m unit # Run only integration tests uv run pytest -m integration # Skip slow tests uv run pytest -m "not slow" # Run tests by keyword uv run pytest -k "cache" # Run failed tests only uv run pytest --lf # Run tests in parallel (if pytest-xdist installed) uv run pytest -n auto ``` ### Debugging Tests ```bash # Stop on first failure uv run pytest -x # Enter debugger on failure uv run pytest --pdb # Show local variables on failure uv run pytest -l # Run with maximum verbosity uv run pytest -vv -s # Print test collection uv run pytest --collect-only ``` ### Performance Testing ```bash # Run tests with timing uv run pytest --durations=10 # Profile slow tests uv run pytest --profile # Run with memory profiling (if pytest-memray installed) uv run pytest --memray ``` ## CI/CD Integration ### GitHub Actions Workflow ```yaml name: Tests on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install uv uses: astral-sh/setup-uv@v2 with: version: "latest" - name: Install dependencies run: | uv sync --dev - name: Run linting run: | uv run ruff check src/ tests/ uv run mypy src/ - name: Run tests run: | uv run pytest --cov=lorekeeper_mcp --cov-report=xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: file: ./coverage.xml flags: unittests name: codecov-umbrella ``` ### Pre-commit Integration ```yaml # .pre-commit-config.yaml repos: - repo: local hooks: - id: pytest name: pytest entry: uv run pytest language: system pass_filenames: false always_run: true args: [--cov=lorekeeper_mcp, --cov-fail-under=90] ``` This comprehensive testing guide ensures high-quality, reliable code for the LoreKeeper MCP project. --- ## Live API Testing ### Overview Live tests validate LoreKeeper MCP tools against real Open5e and D&D 5e APIs. These tests are marked with `@pytest.mark.live` and are skipped by default. ### Running Live Tests **Run all live tests:** ```bash uv run pytest -m live -v ``` **Run live tests for specific tool:** ```bash uv run pytest -m live -k spell -v ``` **Skip live tests (default for unit testing):** ```bash uv run pytest -m "not live" ``` ### Requirements - Internet connection - Working Open5e API (https://api.open5e.com) - Working D&D 5e API (https://www.dnd5eapi.co) ### Performance Expectations - **Uncached queries**: < 3 seconds - **Cached queries**: < 50 ms - **Rate limiting**: 100ms minimum delay between API calls ### Test Coverage Live tests validate: - ✅ Basic queries (name lookup) - ✅ Filtering (level, CR, type, school, etc.) - ✅ Cache behavior (hit/miss, performance) - ✅ Error handling (invalid params, empty results) - ✅ Response schema validation - ✅ Cross-cutting concerns (cache isolation, performance) ### Troubleshooting **Tests fail with network errors:** - Check internet connection - Verify API endpoints are accessible - Check for API outages **Tests fail with rate limiting:** - Increase delay in `rate_limiter` fixture (tests/conftest.py) - Run tests with `--maxfail=1` to stop on first failure **Cache tests fail:** - Ensure `clear_cache` fixture is used - Check cache database is writable - Verify TTL settings in cache configuration **Performance tests fail:** - Network latency may vary - adjust thresholds if needed - Ensure no other processes are using database - Try running tests individually ### When to Run Live Tests - Before releases - After API client changes - When investigating cache issues - After modifying API endpoints - Periodically (weekly/monthly) to catch API changes ### Adding New Live Tests 1. Mark test with `@pytest.mark.live` 2. Mark slow tests with `@pytest.mark.slow` 3. Use `rate_limiter` fixture to prevent throttling 4. Use `clear_cache` fixture to ensure clean state 5. Follow existing patterns in `tests/test_tools/test_live_mcp.py` Example: ```python @pytest.mark.live @pytest.mark.asyncio async def test_new_feature(self, rate_limiter, clear_cache): """Test description.""" await rate_limiter("open5e") # Test code here ```

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/frap129/lorekeeper-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server