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/
├── 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
- 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
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:
- 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@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
- 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