Testing¶
VDL has comprehensive test coverage: unit, contract, integration, and smoke tests.
Test Layout¶
tests/
conftest.py # Global fixtures (HOME isolation)
README.md
unit/ # Per-module unit tests (~40 files)
test_client.py # VDLClient behavior
test_cli.py # CLI commands and flags
test_runtime.py # Wiring tiers
test_models.py # Dataclass validation
... (and more)
contract/ # Interface compliance
test_playlist_contract.py # PlaylistResolver protocol
integration/ # Real SQLite, real filesystem
test_sqlite_state.py # StateStore persistence
test_playlist_partial_failure.py # Batch failure isolation
smoke/ # Live network tests (marked slow)
test_install.sh # Installer validation
support/ # Test utilities
fake_downloader.py # FakeDownloader mock
fake_playlist_resolver.py # FakePlaylistResolver mock
fake_state_store.py # InMemoryStateStore
Running Tests¶
# All tests (except slow/smoke)
PYTHONPATH=src uv run pytest -q
# With slow tests
PYTHONPATH=src uv run pytest -q
# Specific test file
PYTHONPATH=src uv run pytest tests/unit/test_client.py -v
# Specific test function
PYTHONPATH=src uv run pytest tests/unit/test_client.py::TestDownload::test_completed -v
# Run only contract tests
PYTHONPATH=src uv run pytest tests/contract/ -v
HOME Isolation¶
The autouse fixture in conftest.py redirects HOME to a temporary directory:
# tests/conftest.py
@pytest.fixture(autouse=True)
def _isolate_home(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
Benefits:
- Tests never touch real ~/.config/vdl/ or ~/.local/state/vdl/
- Tests are isolated from each other
- Temporary directories are auto-cleaned
Fake Implementations¶
FakeDownloader¶
Configurable per-URL results:
from tests.support.fake_downloader import FakeDownloader
from vdl.models import DownloadStatus, Quality
downloader = FakeDownloader(
results={
"https://example.com/video1": DownloadResult(
id="test1",
source_url="https://example.com/video1",
status=DownloadStatus.COMPLETED,
quality=Quality.P720,
output_path="/path/to/video1.mp4",
error=None,
skip_reason=None,
metadata={}
)
}
)
result = await downloader.download(request, on_progress=None)
FakePlaylistResolver¶
from tests.support.fake_playlist_resolver import FakePlaylistResolver
resolver = FakePlaylistResolver(
playlists={
"https://example.com/playlist": PlaylistInfo(
url="https://example.com/playlist",
title="Test Playlist",
items=[
PlaylistItem(url="...", id="1", title="Item 1", index=1, duration_seconds=180, selected=True, metadata={}),
PlaylistItem(url="...", id="2", title="Item 2", index=2, duration_seconds=240, selected=True, metadata={}),
],
metadata={}
)
}
)
playlist = await resolver.resolve("https://example.com/playlist")
assert len(playlist.items) == 2
InMemoryStateStore¶
from tests.support.fake_state_store import InMemoryStateStore
state = InMemoryStateStore()
client = VDLClient(state_store=state)
result = await client.download("URL")
assert len(state.list_recent()) == 1
Async Tests¶
VDL uses pytest-asyncio. Tests are defined with async def:
import pytest
@pytest.mark.asyncio
async def test_download():
client = VDLClient()
result = await client.download("https://example.com/video")
assert result.status == DownloadStatus.COMPLETED
If asyncio_mode = "auto" in pyproject.toml, the @pytest.mark.asyncio decorator is optional on async test functions.
Contract Tests¶
Contract tests validate that fake implementations behave identically to real ones:
# tests/contract/test_playlist_contract.py
class TestPlaylistItemsToRequests:
async def test_all_selected_by_default(self):
playlist = PlaylistInfo(
url="...",
title="...",
items=[PlaylistItem(...) for _ in range(3)],
metadata={}
)
requests = playlist_items_to_requests(playlist, selected_indexes=None, ...)
assert len(requests) == 3
assert all(r.selected for r in requests)
These tests pass with both fake and real implementations, ensuring compatibility.
Unit Test Structure¶
Example from test_client.py:
class TestDownload:
@pytest.mark.asyncio
async def test_completed(self, client):
result = await client.download("https://example.com/video")
assert result.status == DownloadStatus.COMPLETED
assert result.output_path is not None
@pytest.mark.asyncio
async def test_skipped(self, client, state):
# First download
await client.download("URL")
# Second download (collision)
result = await client.download("URL", if_exists=IfExists.SKIP)
assert result.status == DownloadStatus.SKIPPED
@pytest.mark.asyncio
async def test_failed_no_downloader(self):
client = VDLClient(downloader=None)
with pytest.raises(VDLError):
await client.download("URL")
Integration Tests¶
Real SQLite and filesystem:
# tests/integration/test_sqlite_state.py
class TestSQLiteStateStore:
@pytest.mark.asyncio
async def test_record_and_retrieve(self, tmp_path):
db_path = tmp_path / "test.db"
state = SQLiteStateStore(db_path=db_path)
await state.record_completed(
id="test1",
result=DownloadResult(...)
)
record = await state.get_status("test1")
assert record.status == DownloadStatus.COMPLETED
Adding a New Test¶
- Create
tests/unit/test_<module>.pyor add to existing file - Use descriptive class and function names
- Inject fixtures (
client,state,downloader, etc.) - Test both success and failure paths
- Run:
PYTHONPATH=src uv run pytest tests/unit/test_<module>.py -v
Example:
# tests/unit/test_my_feature.py
import pytest
from vdl import VDLClient
from vdl.models import DownloadStatus
class TestMyFeature:
@pytest.mark.asyncio
async def test_basic_behavior(self):
client = VDLClient()
result = await client.download("https://example.com/video")
assert result.status == DownloadStatus.COMPLETED
@pytest.mark.asyncio
async def test_error_handling(self):
client = VDLClient(downloader=None)
with pytest.raises(VDLError):
await client.download("URL")
CI Test Jobs¶
GitHub/GitLab CI runs:
- vdl-test — full test suite (required for release)
- vdl-test-ytdlp-latest — with latest stable yt-dlp (advisory)
- vdl-test-ytdlp-nightly — with nightly yt-dlp (optional)
See .gitlab-ci.yml for the pipeline definition.
See Also¶
- Architecture — design patterns
- Agent Guide — testing principles and wiring modes