Guide to testing DVP CMS plugins with pytest.
Why Test?
Benefits:
- Catch bugs before production
- Confidence when refactoring
- Documentation of expected behavior
- Faster development long-term
Requirement: 80% test coverage minimum
Test Structure
Directory Layout
my-plugin/
├── pyproject.toml
├── src/
│ └── my_plugin/
│ ├── __init__.py
│ └── plugin.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
- Prefix with
test_ - Match module names:
plugin.py→test_plugin.py - One test file per module
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
from uuid import UUID
import pytest
from dvp_cms.plugins.context import HookContext
from my_plugin import MyPlugin
TENANT_ID = UUID("11111111-1111-1111-1111-111111111111")
@pytest.fixture
def hook_context() -> HookContext:
return HookContext(
tenant_id=TENANT_ID,
tenant_slug="test-tenant",
tenant_plan="enterprise",
)
class TestMyPlugin:
"""Unit tests for MyPlugin."""
def test_plugin_metadata(self) -> None:
"""Test plugin has correct metadata."""
plugin = MyPlugin()
assert plugin.name == "my-plugin"
assert plugin.version == "1.0.0"
assert plugin.description != ""
Testing Hook Implementations
def test_before_content_create_adds_timestamp(
self, hook_context: HookContext
) -> None:
"""Test filter hook adds timestamp to content.
All hooks receive ctx as first parameter for consistency.
Filter hooks must return a value.
"""
plugin = MyPlugin()
content = {
'title': 'Test Article',
'body': 'Test content'
}
# All hooks: ctx first
result = plugin.dvp_before_content_create(hook_context, content)
# Filter hooks MUST return a value (never None)
assert result is not None
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, hook_context: HookContext
) -> None:
"""Test validation fails when title missing.
All hooks receive ctx as first parameter for consistency.
"""
plugin = MyPlugin()
content = {'body': 'Content without title'}
# All hooks: ctx first
with pytest.raises(ValueError, match="Title is required"):
plugin.dvp_before_content_create(hook_context, 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.registry import PluginRegistry
from my_plugin import MyPlugin
class TestMyPluginIntegration:
"""Integration tests for MyPlugin."""
def test_plugin_registration(self) -> None:
"""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) -> None:
"""Test plugin appears in hook execution list."""
registry = PluginRegistry()
plugin = MyPlugin()
registry.register(plugin)
registry.enable("my-plugin")
# Use actual hook name from hookspec.py
plugins = registry.get_plugins_for_hook("dvp_before_content_create")
assert len(plugins) == 1
assert plugins[0].name == "my-plugin"
Testing Hook Execution with Context
from uuid import UUID
from dvp_cms.plugins.context import HookContext
def test_hook_execution_with_context() -> None:
"""Test filter hook execution with tenant context.
All hooks receive ctx as first parameter for consistency.
"""
plugin = MyPlugin()
ctx = HookContext(
tenant_id=UUID("11111111-1111-1111-1111-111111111111"),
tenant_slug="test-tenant",
tenant_plan="enterprise",
)
content = {'title': 'Test', 'body': 'Content'}
# All hooks: ctx first
result = plugin.dvp_before_content_create(ctx, content)
# Filter hooks MUST return a value
assert result is not None
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
Context and Plugin Fixtures
# conftest.py
from uuid import UUID
import pytest
from dvp_cms.plugins.context import HookContext
from my_plugin import MyPlugin
TENANT_ID = UUID("11111111-1111-1111-1111-111111111111")
@pytest.fixture
def hook_context() -> HookContext:
"""Create a test hook context."""
return HookContext(
tenant_id=TENANT_ID,
tenant_slug="test-tenant",
tenant_plan="enterprise",
)
@pytest.fixture
def plugin() -> MyPlugin:
"""Create plugin instance for testing."""
return MyPlugin()
Usage:
def test_with_plugin_fixture(plugin: MyPlugin) -> None:
"""Test using plugin fixture."""
assert plugin.name == "my-plugin"
def test_with_context(
plugin: MyPlugin, hook_context: HookContext
) -> None:
"""Test filter hook using both fixtures.
All hooks receive ctx as first parameter for consistency.
"""
content = {'title': 'Test'}
# All hooks: ctx first
result = plugin.dvp_before_content_create(hook_context, content)
assert result is not None # Filter hooks must return a value
Test Data Fixture
@pytest.fixture
def sample_content() -> dict[str, str]:
"""Provide sample content for testing."""
return {
'title': 'Test Article',
'body': 'This is test content.',
'author': 'Test Author'
}
def test_content_processing(
plugin: MyPlugin, hook_context: HookContext, sample_content: dict
) -> None:
"""Test filter hook processes sample content.
All hooks receive ctx as first parameter for consistency.
"""
# All hooks: ctx first
result = plugin.dvp_before_content_create(hook_context, sample_content)
assert result is not None # Filter hooks must return
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:
- All public methods
- All hook implementations
- Error handling paths
- Edge cases
Can skip:
- Private methods (covered indirectly)
- Third-party code
- Deprecated code marked for removal
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@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
run: pip install uv
- name: Install dependencies
run: |
uv pip install --system pytest pytest-cov pytest-asyncio
uv pip install --system -e .
- name: Run tests
run: |
pytest --cov=my_plugin --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v4
Best Practices
1. Test One Thing at a Time
Good: Each test focuses on a single behavior
def test_filter_hook_modifies_content(hook_context: HookContext) -> None:
"""Test filter hook adds processed flag.
All hooks receive ctx as first parameter for consistency.
"""
plugin = MyPlugin()
# All hooks: ctx first
content = plugin.dvp_before_content_create(hook_context, {'title': 'Test'})
assert content is not None # Filter hooks must return value
assert content['processed'] is True
def test_filter_hook_increments_counter(hook_context: HookContext) -> None:
"""Test filter hook increments event counter.
All hooks receive ctx as first parameter for consistency.
"""
plugin = MyPlugin()
# All hooks: ctx first
plugin.dvp_before_content_create(hook_context, {'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
- pytest: https://docs.pytest.org/
- pytest-asyncio: https://github.com/pytest-dev/pytest-asyncio
- pytest-cov: https://pytest-cov.readthedocs.io/
- unittest.mock: https://docs.python.org/3/library/unittest.mock.html