Testing Guide

This guide covers testing strategies, patterns, and best practices for JamfMCP.

Testing Overview

JamfMCP uses pytest for all testing with the following goals in mind:

  • Unit Tests: Individual components

  • Integration Tests: Component interactions

  • Async Tests: Async function testing

  • Mock Testing: External service mocking

Running Tests

Basic Commands

# Run all tests
make test

# Run with coverage
make test-cov

# Generate HTML coverage report
make test-cov-html

# Run specific test file
uv run pytest tests/test_health_analyzer.py

# Run specific test
uv run pytest tests/test_health_analyzer.py::test_generate_scorecard

# Run with verbose output
uv run pytest -v

# Run with print statements
uv run pytest -s

Test Structure

Project Test Layout

tests/
├── __init__.py              # Makes tests a package
├── conftest.py             # Shared fixtures and configuration
├── fixtures/               # Test data fixtures
│   ├── __init__.py
│   ├── api_responses.py    # Mock API responses
│   ├── computer_data.py    # Sample computer data
│   └── sofa_data.py        # SOFA feed samples
├── test_auth.py            # Authentication tests
├── test_health_analyzer.py # Health analyzer tests
├── test_mcp_tools.py       # MCP tool tests
└── test_sofa.py            # SOFA integration tests

Test File Structure

"""Test module for feature name."""

import pytest
from unittest.mock import AsyncMock, MagicMock

from jamfmcp.module import FeatureClass


class TestFeatureName:
    """Test suite for FeatureName."""

    @pytest.fixture
    def feature_instance(self):
        """Create feature instance for testing."""
        return FeatureClass()

    def test_basic_functionality(self, feature_instance):
        """Test basic feature functionality."""
        result = feature_instance.do_something()
        assert result == expected_value

    @pytest.mark.asyncio
    async def test_async_functionality(self, feature_instance):
        """Test async feature functionality."""
        result = await feature_instance.async_method()
        assert result["status"] == "success"

Fixtures

Built-in Fixtures

JamfMCP provides common fixtures in conftest.py:

@pytest.fixture
def sample_computer_inventory():
    """Provide sample computer inventory data."""
    return {
        "general": {
            "id": 123,
            "name": "Test Computer",
            "serial_number": "ABC123"
        },
        "hardware": {
            "make": "Apple",
            "model": "MacBook Pro"
        }
    }

@pytest.fixture
def mock_jamf_api(mocker):
    """Mock JamfApi for testing."""
    return mocker.patch('jamfmcp.server.jamf_api')

@pytest.fixture
def mock_sofa_feed():
    """Provide mock SOFA feed data."""
    return SOFAFeed(
        update_hash="test123",
        os_versions={"Sonoma 14": OSVersionInfo(...)}
    )

Using Fixtures

def test_with_fixtures(sample_computer_inventory, mock_jamf_api):
    """Test using multiple fixtures."""
    # Fixtures are automatically injected
    mock_jamf_api.get_computer_inventory.return_value = sample_computer_inventory

    result = some_function()
    assert result["general"]["name"] == "Test Computer"

Custom Fixtures

@pytest.fixture(scope="function")  # Default scope
def custom_data():
    """Provide custom test data."""
    data = {"key": "value"}
    yield data  # Provide to test
    # Cleanup if needed

@pytest.fixture(scope="module")
def expensive_resource():
    """Create expensive resource once per module."""
    resource = create_expensive_resource()
    yield resource
    resource.cleanup()

Async Testing

Testing Async Functions

import pytest

@pytest.mark.asyncio
async def test_async_function():
    """Test an async function."""
    result = await async_function()
    assert result == expected

@pytest.mark.asyncio
async def test_multiple_async_calls():
    """Test multiple async operations."""
    results = await asyncio.gather(
        async_function1(),
        async_function2()
    )
    assert len(results) == 2

Async Fixtures

@pytest.fixture
async def async_client():
    """Provide async client."""
    client = AsyncClient()
    yield client
    await client.close()

@pytest.mark.asyncio
async def test_with_async_fixture(async_client):
    """Test using async fixture."""
    result = await async_client.get_data()
    assert result is not None

Mocking

Mocking External Services

from unittest.mock import AsyncMock, patch

@pytest.mark.asyncio
async def test_with_mock_api(mocker):
    """Test with mocked API calls."""
    # Mock the API client
    mock_api = mocker.patch('jamfmcp.api.JamfApi')
    mock_api.get_computer_inventory = AsyncMock(return_value={
        "general": {"name": "Test Mac"}
    })

    # Test the function
    result = await function_under_test()

    # Verify mock was called
    mock_api.get_computer_inventory.assert_called_once_with(serial="ABC123")

Mock Patterns

# Mock return value
mock.return_value = {"data": "value"}

# Mock async return
mock.return_value = AsyncMock(return_value={"data": "value"})

# Mock side effect
mock.side_effect = [result1, result2, Exception("Error")]

# Mock property
type(mock).property_name = PropertyMock(return_value="value")

# Partial mock
with patch.object(instance, 'method', return_value="mocked"):
    result = instance.method()

Continuous Integration

GitHub Actions

Tests run automatically on:

  • Pull requests

  • Push to main branch

  • Nightly scheduled runs

Pre-commit Hooks

# Install hooks
make pre-commit

# Run manually
make pre-commit-run

Debugging Tests

Using pdb

def test_with_debugger():
    """Test with debugger."""
    import pdb; pdb.set_trace()
    result = complex_function()
    assert result is not None

Verbose Output

# Show print statements
pytest -s

# Show test names as they run
pytest -v

# Show local variables on failure
pytest -l

Test Isolation

# Run single test
pytest tests/test_file.py::test_name

# Run tests matching pattern
pytest -k "test_health"

# Run marked tests
pytest -m "slow"