Testing DVP CMS Plugins

Intermediate 45 minutes

Guide to testing DVP CMS plugins with pytest.

Why Test?

Benefits:

Requirement: 80% test coverage minimum

Test Structure

Directory Layout

my-plugin/
├── plugin.py
├── plugin.json
├── __init__.py
└── tests/
    ├── __init__.py
    ├── test_plugin.py        # Unit tests
    ├── test_integration.py   # Integration tests
    ├── conftest.py          # Shared fixtures
    └── test_data/           # Test data files
        └── sample.json

Test File Naming

Test Function Naming

def test_<what>_<condition>_<expected>():
    """Test that <what> does <expected> when <condition>."""

Examples:

def test_hook_adds_field_to_content():
def test_validation_raises_error_on_missing_title():
def test_plugin_disabled_skips_processing():

Unit Tests

Basic Structure

import pytest
from my_plugin import MyPlugin


class TestMyPlugin:
    """Unit tests for MyPlugin."""

    def test_plugin_metadata(self):
        """Test plugin has correct metadata."""
        plugin = MyPlugin()

        assert plugin.name == "my-plugin"
        assert plugin.version == "1.0.0"
        assert plugin.description != ""

    def test_initialization(self):
        """Test plugin initializes correctly."""
        plugin = MyPlugin(config={'key': 'value'})

        assert plugin.config['key'] == 'value'
        assert not plugin.enabled  # Not enabled by default

Testing Hook Implementations

def test_before_content_create_adds_timestamp(self):
    """Test hook adds timestamp to content."""
    plugin = MyPlugin()

    content = {
        'title': 'Test Article',
        'body': 'Test content'
    }

    result = plugin.dvp_before_content_create(content)

    # Verify timestamp added
    assert 'created_at' in result
    assert isinstance(result['created_at'], str)

    # Verify original fields preserved
    assert result['title'] == 'Test Article'
    assert result['body'] == 'Test content'

Testing Error Conditions

def test_validation_raises_on_missing_title(self):
    """Test validation fails when title missing."""
    plugin = MyPlugin()

    content = {'body': 'Content without title'}

    with pytest.raises(ValueError, match="Title is required"):
        plugin.dvp_before_content_create(content)

Parametrized Tests

Test multiple inputs efficiently:

@pytest.mark.parametrize("input,expected", [
    ("hello world", "Hello World"),
    ("HELLO WORLD", "Hello World"),
    ("HeLLo WoRLd", "Hello World"),
])
def test_title_case_conversion(input, expected):
    """Test title case conversion with various inputs."""
    plugin = MyPlugin()
    result = plugin._convert_to_title_case(input)
    assert result == expected

Integration Tests

Testing with Plugin Registry

import pytest
from dvp_cms.plugins import PluginRegistry
from my_plugin import MyPlugin


class TestMyPluginIntegration:
    """Integration tests for MyPlugin."""

    def test_plugin_registration(self):
        """Test plugin can be registered and enabled."""
        registry = PluginRegistry()
        plugin = MyPlugin()

        registry.register(plugin)
        assert "my-plugin" in registry.list_registered()

        registry.enable("my-plugin")
        assert registry.is_enabled("my-plugin")

    def test_plugin_appears_in_hook_list(self):
        """Test plugin appears in hook execution list."""
        registry = PluginRegistry()
        plugin = MyPlugin()

        registry.register(plugin)
        registry.enable("my-plugin")

        plugins = registry.get_plugins_for_hook("dvp_before_content_create")

        assert len(plugins) == 1
        assert plugins[0].name == "my-plugin"

Testing Hook Chain Execution

import asyncio
from dvp_cms.plugins import call_hook_chain


@pytest.mark.asyncio
async def test_hook_chain_execution():
    """Test plugin in complete hook chain."""
    registry = PluginRegistry()
    plugin = MyPlugin()

    registry.register(plugin)
    registry.enable("my-plugin")

    # Get plugins for hook
    plugins = registry.get_plugins_for_hook("dvp_before_content_create")

    # Test content
    content = {'title': 'Test', 'body': 'Content'}

    # Execute hook chain
    result = await call_hook_chain(
        plugins,
        'dvp_before_content_create',
        content
    )

    # Verify plugin modified content
    assert 'processed_by' in result
    assert result['processed_by'] == 'my-plugin'

Async Testing

Basic Async Test

import pytest


@pytest.mark.asyncio
async def test_async_hook_execution():
    """Test async hook execution."""
    plugin = MyPlugin()

    result = await plugin.async_process_content({'title': 'Test'})

    assert result is not None

Testing Async with Timeouts

@pytest.mark.asyncio
@pytest.mark.timeout(5)  # Requires pytest-timeout
async def test_async_operation_completes_within_timeout():
    """Test async operation completes in reasonable time."""
    plugin = MyPlugin()

    result = await plugin.slow_async_operation()

    assert result is not None

Testing Concurrent Operations

@pytest.mark.asyncio
async def test_concurrent_content_processing():
    """Test plugin handles concurrent requests."""
    plugin = MyPlugin()

    contents = [
        {'title': f'Article {i}', 'body': 'Content'}
        for i in range(10)
    ]

    # Process concurrently
    results = await asyncio.gather(
        *[plugin.dvp_before_content_create(c) for c in contents]
    )

    # All should succeed
    assert len(results) == 10
    for result in results:
        assert 'processed' in result

Mocking

Mocking External APIs

from unittest.mock import Mock, patch, MagicMock


def test_external_api_called_correctly():
    """Test plugin calls external API with correct parameters."""
    plugin = MyPlugin(api_key="test-key")

    with patch('my_plugin.requests.post') as mock_post:
        mock_post.return_value.json.return_value = {'status': 'success'}

        result = plugin._fetch_enrichment({'title': 'Test'})

        # Verify API called
        mock_post.assert_called_once()

        # Verify correct URL
        call_args = mock_post.call_args
        assert 'https://api.example.com' in call_args[0][0]

        # Verify API key in headers
        assert call_args[1]['headers']['Authorization'] == 'Bearer test-key'

Mocking Database Queries

def test_database_query_executes():
    """Test plugin executes database query correctly."""
    plugin = MyPlugin()

    with patch('my_plugin.db.query') as mock_query:
        mock_query.return_value.filter.return_value.first.return_value = {
            'id': 1,
            'data': 'test'
        }

        result = plugin._fetch_from_database('key')

        mock_query.assert_called_once()
        assert result['id'] == 1

Mocking Plugin Logger

def test_plugin_logs_correctly(caplog):
    """Test plugin logs at appropriate levels."""
    import logging

    plugin = MyPlugin()

    with caplog.at_level(logging.INFO):
        plugin.initialize()

    # Check log message
    assert "initialized" in caplog.text.lower()

Fixtures

Plugin Fixture

# conftest.py
import pytest
from my_plugin import MyPlugin


@pytest.fixture
def plugin():
    """Create plugin instance for testing."""
    return MyPlugin(config={'test': True})


@pytest.fixture
def enabled_plugin(plugin):
    """Create enabled plugin instance."""
    plugin.initialize()
    yield plugin
    plugin.shutdown()

Usage:

def test_with_plugin_fixture(plugin):
    """Test using plugin fixture."""
    assert plugin.name == "my-plugin"


def test_with_enabled_plugin(enabled_plugin):
    """Test using enabled plugin fixture."""
    assert enabled_plugin._initialized_at is not None

Test Data Fixture

@pytest.fixture
def sample_content():
    """Provide sample content for testing."""
    return {
        'title': 'Test Article',
        'body': 'This is test content.',
        'author': 'Test Author'
    }


def test_content_processing(plugin, sample_content):
    """Test plugin processes sample content."""
    result = plugin.dvp_before_content_create(sample_content)
    assert 'processed' in result

Coverage

Measuring Coverage

# Run tests with coverage
pytest --cov=my_plugin tests/

# Generate detailed report
pytest --cov=my_plugin --cov-report=html tests/

# Open HTML report
open htmlcov/index.html

Coverage Requirements

# pytest.ini or pyproject.toml
[tool.pytest.ini_options]
addopts = "--cov=my_plugin --cov-report=term-missing --cov-fail-under=80"

What to Cover

Must cover:

Can skip:

CI/CD Integration

GitHub Actions

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2

    - name: Set up Python
      uses: actions/setup-python@v2
      with:
        python-version: '3.9'

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install pytest pytest-cov pytest-asyncio
        pip install -e .

    - name: Run tests
      run: |
        pytest --cov=my_plugin --cov-report=xml

    - name: Upload coverage
      uses: codecov/codecov-action@v2

Best Practices

1. Test One Thing at a Time

Good: Each test focuses on a single behavior

def test_hook_modifies_content():
    """Test hook adds processed flag."""
    plugin = MyPlugin()
    content = plugin.dvp_before_content_create({'title': 'Test'})
    assert content['processed'] is True

def test_hook_increments_counter():
    """Test hook increments event counter."""
    plugin = MyPlugin()
    plugin.dvp_before_content_create({'title': 'Test'})
    assert plugin.events == 1

2. Use Descriptive Assertions

assert 'timestamp' in result, "Timestamp should be added to content"
assert isinstance(result['timestamp'], str), "Timestamp should be string"

3. Clean Up Resources

@pytest.fixture
def plugin_with_temp_file(tmp_path):
    """Create plugin with temporary file."""
    temp_file = tmp_path / "test.json"

    plugin = MyPlugin(data_file=temp_file)
    plugin.initialize()

    yield plugin

    # Cleanup
    plugin.shutdown()
    if temp_file.exists():
        temp_file.unlink()

Resources