Testing
VoiceGateway has 200+ tests with over 70% code coverage. This guide covers running tests, writing new ones, and using the shared fixtures.
Testing
VoiceGateway has 200+ tests with over 70% code coverage. This guide covers running tests, writing new ones, and using the shared fixtures.
Running tests
# Run all tests
pytest
# Run a specific file
pytest src/voicegateway/tests/core/test_config.py
# Run a specific test by name
pytest src/voicegateway/tests/core/test_config.py::test_load_example_config
# Run with coverage
pytest --cov
# Run with coverage and show missing lines
pytest --cov --cov-report=term-missing
# Run verbose (see each test name)
pytest -v
# Stop at first failure
pytest -xpytest configuration
VoiceGateway uses asyncio_mode = "auto" in pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]This means:
- No
@pytest.mark.asyncioneeded -- async test functions are detected automatically - All tests run in the same event loop policy -- no loop conflicts
- Test files live in the
src/voicegateway/tests/directory
Shared fixtures
The src/voicegateway/tests/conftest.py file provides four key fixtures:
_test_env (autouse)
Runs automatically for every test. Sets fake API keys so provider constructors do not fail:
@pytest.fixture(autouse=True)
def _test_env(monkeypatch):
for key in [
"OPENAI_API_KEY",
"DEEPGRAM_API_KEY",
"CARTESIA_API_KEY",
"ANTHROPIC_API_KEY",
"GROQ_API_KEY",
"ELEVENLABS_API_KEY",
"ASSEMBLYAI_API_KEY",
]:
monkeypatch.setenv(key, "test-key-value")You never need to call this fixture explicitly -- it is autouse.
example_config_path
Writes the bundled starter config (sourced from src/voicegateway/data/voicegw.example.yaml) to a tmp file and returns its path. Use this to test config loading against the shipped example:
def test_load_example(example_config_path):
config = GatewayConfig.load(example_config_path)
assert config.providerstemp_config
Writes a minimal voicegw.yaml to a temporary directory and returns its path. The config includes OpenAI and Deepgram providers, one STT model, one LLM model, two projects (test-project and blocked-project), and cost tracking enabled:
def test_gateway_init(temp_config):
gw = Gateway(config_path=temp_config)
assert gw is not Noneseeded_storage
Creates a SQLiteStorage instance pre-loaded with three sample RequestRecord entries:
| Record | Modality | Model | Project | Cost |
|---|---|---|---|---|
| 1 | stt | deepgram/nova-3 | test-project | $0.0043 |
| 2 | llm | openai/gpt-4o-mini | test-project | $0.015 |
| 3 | llm | openai/gpt-4o-mini | default | $0.008 |
async def test_query_costs(seeded_storage):
costs = await seeded_storage.get_costs(project="test-project")
assert len(costs) == 2Writing tests
Test file naming
- Test files:
src/voicegateway/tests/test_<module>.py - Test functions:
test_<what_it_tests> - Test classes (grouping related tests):
TestClassName
Async tests
Write async tests as regular async def functions. The asyncio_mode = "auto" setting handles the rest:
async def test_health_check():
provider = OpenAIProvider({"api_key": "test-key"})
# Mock the HTTP call
result = await provider.health_check()
assert result is TrueMocking providers
Providers make HTTP calls to external APIs. Always mock these in tests:
from unittest.mock import AsyncMock, patch
async def test_stt_fallback():
with patch(
"voicegateway.providers.deepgram_provider.DeepgramProvider.health_check",
new_callable=AsyncMock,
return_value=False,
):
# Deepgram is "down", fallback should kick in
...Mocking the config
Use temp_config for tests that need a Gateway instance, or construct configs directly:
def test_router_resolution(temp_config):
config = GatewayConfig.load(temp_config)
router = Router(config)
provider, model = router.resolve("openai/gpt-4o-mini", "llm")
assert model == "gpt-4o-mini"Testing cost calculations
from decimal import Decimal
from voicegateway.pricing import catalog
def test_deepgram_nova3_pricing():
"""One minute of Deepgram Nova-3 STT prices at $0.0043 via the catalog."""
cost = catalog.calculate_cost("stt", "deepgram/nova-3", audio_seconds=60)
assert cost == Decimal("0.0043")Testing middleware
Middleware wraps provider calls. Test the wrapping behavior:
async def test_budget_enforcer_blocks():
"""Budget enforcer should raise when project exceeds daily budget."""
enforcer = BudgetEnforcer(storage=seeded_storage, config=config)
with pytest.raises(BudgetExceededError):
await enforcer.check("blocked-project")Mock patterns
monkeypatch.setenv for environment variables
def test_custom_db_path(monkeypatch, tmp_path):
db_path = str(tmp_path / "custom.db")
monkeypatch.setenv("VOICEGW_DB_PATH", db_path)
gw = Gateway(config_path=temp_config)
assert gw._storage is not Nonetmp_path for temporary files
pytest's built-in tmp_path fixture provides a temporary directory unique to each test:
async def test_sqlite_storage(tmp_path):
storage = SQLiteStorage(str(tmp_path / "test.db"))
await storage.log_request(record)patch for external HTTP calls
from unittest.mock import patch, MagicMock
def test_provider_creation():
with patch("voicegateway.providers.openai_provider.openai") as mock_openai:
provider = OpenAIProvider({"api_key": "test"})
llm = provider.create_llm("gpt-4o-mini")
assert llm is not NoneCoverage expectations
- New features must include tests
- Bug fixes should include a regression test
- Target: maintain above 70% overall coverage
- Critical paths (Gateway, Router, CostTracker, BudgetEnforcer) should be above 90%
Check coverage for specific modules:
pytest --cov=voicegateway.core --cov-report=term-missing
pytest --cov=voicegateway.middleware --cov-report=term-missing