refactor(tests): add auto-discovery test system for TTS plugins

- Create tests/plugins/ with auto-discovery fixtures and generic tests
- Add conftest.py with plugin_ids, loaded_plugin, host_context fixtures
- Add test_all_plugins.py with 3 test classes:
  - TestAllPluginsManifest: validates manifest structure
  - TestAllPluginsEngine: validates engine lifecycle contract
  - TestAllPluginsCapabilities: validates capability implementation
- Update docs/testing.md with auto-discovery documentation
- Plugin-specific tests remain in tests/test_*_plugin.py for integration

New plugins in plugins/ are now automatically tested without manual test creation.
This commit is contained in:
Artem Akymenko
2026-07-12 16:20:30 +03:00
parent f151a1ae0d
commit c85ea9d64f
4 changed files with 498 additions and 32 deletions
+11
View File
@@ -0,0 +1,11 @@
"""Auto-discovery tests for TTS plugins.
This package contains generic tests that automatically run for every plugin
in the plugins/ directory. Tests verify:
- Manifest structure
- Engine creation and dispose contract
- Capability implementation
Plugin-specific tests remain in tests/test_<plugin>_plugin.py for
integration with real dependencies (e.g., KPipeline for Kokoro).
"""
+169
View File
@@ -0,0 +1,169 @@
"""Fixtures for auto-discovery plugin tests.
This module provides shared fixtures for testing all plugins:
- plugin_ids: List of all plugin IDs from plugins/ directory
- plugin_dir: Path to a specific plugin directory (parametrized)
- loaded_plugin: Loaded plugin data (manifest, model_requirements, create_engine)
- host_context: Test HostContext with fake HTTP client
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import pytest
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.loader import load_plugin_from_dir
def _discover_plugin_ids() -> list[str]:
"""Discover all plugin IDs from the plugins/ directory.
Returns:
List of plugin IDs (directory names with __init__.py).
"""
plugins_dir = Path(__file__).parent.parent.parent / "plugins"
plugin_ids = []
if plugins_dir.exists():
for item in sorted(plugins_dir.iterdir()):
if item.is_dir() and not item.name.startswith("."):
init_file = item / "__init__.py"
if init_file.exists():
plugin_ids.append(item.name)
return plugin_ids
@pytest.fixture(scope="module")
def plugins_dir() -> Path:
"""Return the path to the plugins directory."""
return Path(__file__).parent.parent.parent / "plugins"
@pytest.fixture(scope="module")
def plugin_ids(plugins_dir: Path) -> list[str]:
"""Return a list of all plugin IDs from the plugins/ directory.
This fixture discovers all subdirectories in plugins/ that contain
an __init__.py file (i.e., valid plugin directories).
Returns:
List of plugin IDs (directory names).
"""
return _discover_plugin_ids()
@pytest.fixture(params=_discover_plugin_ids())
def plugin_id(request: pytest.FixtureRequest) -> str:
"""Parametrized fixture returning each plugin ID.
This fixture is parametrized to run tests for each plugin.
"""
return request.param
@pytest.fixture
def plugin_dir(plugins_dir: Path, plugin_id: str) -> Path:
"""Return the path to a specific plugin directory.
Args:
plugins_dir: Base plugins directory.
plugin_id: The plugin ID (directory name).
Returns:
Path to the plugin directory.
"""
return plugins_dir / plugin_id
@pytest.fixture
def loaded_plugin(plugin_dir: Path):
"""Load a plugin and return its load result.
This fixture loads the plugin using the loader, providing access to:
- manifest: PluginManifest
- model_requirements: tuple[ModelManifest, ...]
- create_engine: Callable
- module: The loaded module
Args:
plugin_dir: Path to the plugin directory.
Returns:
PluginLoadResult from load_plugin_from_dir.
Raises:
pytest.skip: If plugin fails to load (should not happen for valid plugins).
"""
from abogen.tts_plugin.loader import PluginLoadResult
result = load_plugin_from_dir(plugin_dir)
if not result.success:
pytest.fail(
f"Plugin {plugin_dir.name} failed to load: "
f"{result.error.errors if result.error else 'Unknown error'}"
)
return result
@pytest.fixture
def host_context(tmp_path: Path) -> HostContext:
"""Create a test HostContext with fake HTTP client.
Args:
tmp_path: Pytest tmp_path fixture for config directory.
Returns:
HostContext with fake HTTP client and test logger.
"""
class FakeHttpClient:
"""Fake HTTP client for testing."""
def get(self, url: str, **kwargs: object) -> object:
"""Fake GET request."""
return None
def post(self, url: str, **kwargs: object) -> object:
"""Fake POST request."""
return None
return HostContext(
config_dir=tmp_path,
logger=logging.getLogger("test"),
http_client=FakeHttpClient(),
)
@pytest.fixture
def engine_config() -> Any:
"""Return a default EngineConfig for testing.
Returns:
EngineConfig with device="cpu" for testing.
"""
from abogen.tts_plugin.types import EngineConfig
return EngineConfig(device="cpu")
@pytest.fixture
def create_engine(loaded_plugin, host_context, engine_config):
"""Create an engine instance from a loaded plugin.
This fixture creates an engine and ensures proper cleanup via dispose.
Args:
loaded_plugin: Loaded plugin result.
host_context: Test HostContext.
engine_config: EngineConfig for engine creation.
Yields:
Engine instance.
"""
from abogen.tts_plugin.engine import Engine
engine = loaded_plugin.create_engine(host_context, None, engine_config)
assert isinstance(engine, Engine)
yield engine
engine.dispose()
+235
View File
@@ -0,0 +1,235 @@
"""Auto-discovery tests for all TTS plugins.
These tests automatically run for every plugin in the plugins/ directory.
Tests are grouped into three categories:
1. TestAllPluginsManifest - Validates manifest structure
- Required fields (id, name, version, api_version, etc.)
- API version format (semver MAJOR.MINOR)
- Voices field (optional)
2. TestAllPluginsEngine - Validates engine lifecycle
- create_engine returns valid Engine
- dispose is idempotent
- dispose → createSession raises EngineError
3. TestAllPluginsCapabilities - Validates capability implementation
- Declared capabilities are implemented
- voice_list → VoiceLister interface
"""
from __future__ import annotations
import re
import pytest
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig, VoiceSelection, AudioFormat, ParameterValues, SynthesisRequest
class TestAllPluginsManifest:
"""Test that all plugins have valid manifest structure."""
def test_manifest_has_required_fields(self, loaded_plugin):
"""Verify manifest has all required fields."""
manifest = loaded_plugin.manifest
assert manifest is not None
# Required fields
assert manifest.id, "Plugin id must not be empty"
assert manifest.name, "Plugin name must not be empty"
assert manifest.version, "Plugin version must not be empty"
assert manifest.api_version, "Plugin api_version must not be empty"
assert manifest.description, "Plugin description must not be empty"
assert manifest.author, "Plugin author must not be empty"
def test_manifest_id_matches_directory(self, loaded_plugin, plugin_dir):
"""Verify manifest id matches the plugin directory name."""
manifest = loaded_plugin.manifest
assert manifest.id == plugin_dir.name, \
f"Manifest id '{manifest.id}' must match directory name '{plugin_dir.name}'"
def test_api_version_format(self, loaded_plugin):
"""Verify api_version follows semver format (MAJOR.MINOR)."""
manifest = loaded_plugin.manifest
api_version = manifest.api_version
# Must match MAJOR.MINOR format
pattern = r"^\d+\.\d+$"
assert re.match(pattern, api_version), \
f"api_version '{api_version}' must be in format MAJOR.MINOR (e.g., 1.0)"
def test_api_version_compatibility(self, loaded_plugin):
"""Verify api_version major version matches host API version."""
from abogen.tts_plugin.loader import HOST_API_VERSION
manifest = loaded_plugin.manifest
plugin_ver = manifest.api_version
host_ver = HOST_API_VERSION
# Extract major versions
plugin_major = int(plugin_ver.split(".")[0])
host_major = int(host_ver.split(".")[0])
assert plugin_major == host_major, \
f"API version major mismatch: plugin={plugin_major}, host={host_major}"
def test_voices_field_is_optional(self, loaded_plugin):
"""Verify voices field is optional (can be None or tuple)."""
manifest = loaded_plugin.manifest
voices = manifest.voices
# voices can be None (not declared) or tuple (empty or with voices)
assert voices is None or isinstance(voices, tuple), \
f"voices must be None or tuple, got {type(voices).__name__}"
def test_capabilities_is_tuple(self, loaded_plugin):
"""Verify capabilities is a tuple."""
manifest = loaded_plugin.manifest
assert isinstance(manifest.capabilities, tuple), \
f"capabilities must be a tuple, got {type(manifest.capabilities).__name__}"
def test_engine_manifest_exists(self, loaded_plugin):
"""Verify engine manifest exists and has required fields."""
manifest = loaded_plugin.manifest
engine_manifest = manifest.engine
assert engine_manifest is not None
assert isinstance(engine_manifest.voiceSources, tuple)
assert isinstance(engine_manifest.parameters, tuple)
assert isinstance(engine_manifest.audioFormats, tuple)
class TestAllPluginsEngine:
"""Test that all plugins satisfy the Engine contract."""
def test_create_engine_returns_engine(self, loaded_plugin, host_context, engine_config):
"""Verify create_engine returns an Engine instance."""
engine = loaded_plugin.create_engine(host_context, None, engine_config)
assert isinstance(engine, Engine), \
f"create_engine must return Engine instance, got {type(engine).__name__}"
engine.dispose()
def test_dispose_is_idempotent(self, loaded_plugin, host_context, engine_config):
"""Verify dispose can be called multiple times without error."""
engine = loaded_plugin.create_engine(host_context, None, engine_config)
# First dispose
engine.dispose()
# Second dispose should not raise
engine.dispose()
def test_create_session_after_dispose_raises(self, loaded_plugin, host_context, engine_config):
"""Verify createSession raises EngineError after dispose."""
engine = loaded_plugin.create_engine(host_context, None, engine_config)
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
def test_dispose_after_dispose_raises(self, loaded_plugin, host_context, engine_config):
"""Verify dispose after dispose does not raise (idempotent)."""
engine = loaded_plugin.create_engine(host_context, None, engine_config)
engine.dispose()
# Should not raise
engine.dispose()
def test_create_session_returns_valid_session(self, loaded_plugin, host_context, engine_config):
"""Verify createSession returns an EngineSession instance."""
engine = loaded_plugin.create_engine(host_context, None, engine_config)
session = engine.createSession()
assert isinstance(session, EngineSession), \
f"createSession must return EngineSession instance, got {type(session).__name__}"
session.dispose()
engine.dispose()
def test_session_dispose_is_idempotent(self, loaded_plugin, host_context, engine_config):
"""Verify session dispose can be called multiple times."""
engine = loaded_plugin.create_engine(host_context, None, engine_config)
session = engine.createSession()
# First dispose
session.dispose()
# Second dispose should not raise
session.dispose()
engine.dispose()
def test_session_synthesize_after_dispose_raises(self, loaded_plugin, host_context, engine_config):
"""Verify session.synthesize raises EngineError after dispose."""
engine = loaded_plugin.create_engine(host_context, None, engine_config)
session = engine.createSession()
session.dispose()
# Create a minimal request (won't actually synthesize, just test error)
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="test", key="test"),
parameters=ParameterValues(values={}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(EngineError):
session.synthesize(request)
engine.dispose()
class TestAllPluginsCapabilities:
"""Test that all plugins implement their declared capabilities."""
def test_voice_list_capability(self, loaded_plugin, host_context, engine_config):
"""Verify plugins with 'voice_list' capability implement VoiceLister."""
manifest = loaded_plugin.manifest
if "voice_list" not in manifest.capabilities:
pytest.skip("Plugin does not declare 'voice_list' capability")
engine = loaded_plugin.create_engine(host_context, None, engine_config)
# Check if listVoices method exists
assert hasattr(engine, "listVoices"), \
"Plugin with 'voice_list' capability must have listVoices method"
# listVoices should be callable
assert callable(engine.listVoices), "listVoices must be callable"
# listVoices should return a list/tuple of voices
# Get first voice source from manifest
if manifest.engine.voiceSources:
source_id = manifest.engine.voiceSources[0].id
voices = engine.listVoices(source_id)
assert isinstance(voices, (list, tuple)), \
f"listVoices must return list or tuple, got {type(voices).__name__}"
# Each voice should have id, name, tags
for voice in voices:
assert hasattr(voice, "id"), "Voice must have 'id' attribute"
assert hasattr(voice, "name"), "Voice must have 'name' attribute"
assert hasattr(voice, "tags"), "Voice must have 'tags' attribute"
assert isinstance(voice.tags, tuple), "Voice tags must be a tuple"
engine.dispose()
def test_known_capabilities_are_valid(self, loaded_plugin):
"""Verify all declared capabilities are known."""
manifest = loaded_plugin.manifest
known_capabilities = frozenset({
"voice_list",
"preview",
"voice_clone",
"voice_blend",
"streaming",
"cancel",
})
for cap in manifest.capabilities:
assert cap in known_capabilities, \
f"Unknown capability: '{cap}'. Known capabilities: {known_capabilities}"