Testing Guide¶
VBC uses pytest for testing. This guide covers writing and running tests.
Test Structure¶
tests/
├── unit/ # Unit tests (fast, isolated)
│ ├── test_config_models.py
│ ├── test_domain_models.py
│ ├── test_event_bus.py
│ ├── test_main.py
│ └── ...
└── integration/ # Integration tests (slower, real dependencies)
├── test_orchestrator.py
├── test_real_files_compression.py
└── ...
Running Tests¶
All Tests¶
Specific Test File¶
Specific Test Function¶
With Coverage¶
# Terminal report
uv run pytest --cov=vbc
# HTML report
uv run pytest --cov=vbc --cov-report=html
# Open htmlcov/index.html
Verbose Mode¶
Stop on First Failure¶
Writing Tests¶
Unit Test Example¶
# tests/unit/test_config_models.py
import pytest
from pathlib import Path
from pydantic import ValidationError
from vbc.config.models import GeneralConfig, AutoRotateConfig, AppConfig
from vbc.infrastructure.ffmpeg import extract_quality_value, replace_quality_value
def test_general_config_defaults():
"""Test GeneralConfig with default values."""
config = GeneralConfig()
assert config.threads == 1
assert config.gpu is True
assert config.copy_metadata is True
def test_encoder_defaults():
"""Test default encoder quality values."""
config = AppConfig(general=GeneralConfig())
assert extract_quality_value(config.gpu_encoder.common_args) == 45
assert extract_quality_value(config.cpu_encoder.common_args) == 32
def test_general_config_validation():
"""Test GeneralConfig validation rules."""
# Valid config
config = GeneralConfig(threads=8)
assert config.threads == 8
# Invalid: threads must be > 0
with pytest.raises(ValidationError) as exc_info:
GeneralConfig(threads=0)
assert "greater than 0" in str(exc_info.value)
# Invalid: min_compression_ratio must be 0.0-1.0
with pytest.raises(ValidationError) as exc_info:
GeneralConfig(min_compression_ratio=1.5)
assert "less than or equal to 1.0" in str(exc_info.value)
def test_autorotate_validation():
"""Test AutoRotateConfig angle validation."""
# Valid angles
config = AutoRotateConfig(patterns={
"pattern1": 0,
"pattern2": 90,
"pattern3": 180,
"pattern4": 270
})
assert config.patterns["pattern3"] == 180
# Invalid angle
with pytest.raises(ValidationError) as exc_info:
AutoRotateConfig(patterns={"pattern": 45})
assert "Invalid rotation angle" in str(exc_info.value)
Mock Example¶
# tests/unit/test_adapters.py
from unittest.mock import Mock, patch, MagicMock
from pathlib import Path
from vbc.infrastructure.ffprobe import FFprobeAdapter
def test_ffprobe_adapter():
"""Test FFprobeAdapter with mocked subprocess."""
adapter = FFprobeAdapter()
# Mock subprocess.run
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = """codec_name=h264
width=1920
height=1080
avg_frame_rate=60/1
"""
with patch('subprocess.run', return_value=mock_result):
info = adapter.get_stream_info(Path("test.mp4"))
assert info['codec'] == 'h264'
assert info['width'] == 1920
assert info['height'] == 1080
assert info['fps'] == 60.0
Event Bus Testing¶
# tests/unit/test_events.py
from vbc.infrastructure.event_bus import EventBus
from vbc.domain.events import JobStarted, JobCompleted
from vbc.domain.models import CompressionJob, VideoFile, JobStatus
from pathlib import Path
def test_event_bus_subscription():
"""Test EventBus publish/subscribe."""
bus = EventBus()
received_events = []
def handler(event: JobStarted):
received_events.append(event)
bus.subscribe(JobStarted, handler)
# Publish event
job = CompressionJob(
source_file=VideoFile(path=Path("test.mp4"), size_bytes=1000)
)
event = JobStarted(job=job)
bus.publish(event)
assert len(received_events) == 1
assert received_events[0].job.source_file.path == Path("test.mp4")
def test_event_bus_multiple_subscribers():
"""Test multiple subscribers to same event."""
bus = EventBus()
calls = []
def handler1(event: JobCompleted):
calls.append(('handler1', event))
def handler2(event: JobCompleted):
calls.append(('handler2', event))
bus.subscribe(JobCompleted, handler1)
bus.subscribe(JobCompleted, handler2)
job = CompressionJob(
source_file=VideoFile(path=Path("test.mp4"), size_bytes=1000)
)
bus.publish(JobCompleted(job=job))
assert len(calls) == 2
assert calls[0][0] == 'handler1'
assert calls[1][0] == 'handler2'
Integration Test Example¶
# tests/integration/test_orchestrator.py
import pytest
from pathlib import Path
from vbc.config.models import AppConfig, GeneralConfig
from vbc.infrastructure.event_bus import EventBus
from vbc.infrastructure.file_scanner import FileScanner
from vbc.infrastructure.exif_tool import ExifToolAdapter
from vbc.infrastructure.ffprobe import FFprobeAdapter
from vbc.infrastructure.ffmpeg import FFmpegAdapter
from vbc.pipeline.orchestrator import Orchestrator
@pytest.fixture
def test_dir(tmp_path):
"""Create temporary test directory with sample video."""
test_video = tmp_path / "test.mp4"
# Create minimal valid MP4 (you'd use a real sample in practice)
test_video.write_bytes(b"fake video data")
return tmp_path
@pytest.mark.integration
def test_orchestrator_discovery(test_dir):
"""Test Orchestrator discovery phase."""
config = AppConfig(general=GeneralConfig())
config.general.extensions = [".mp4"]
config.general.min_size_bytes = 0
bus = EventBus()
scanner = FileScanner(
extensions=config.general.extensions,
min_size_bytes=config.general.min_size_bytes
)
orchestrator = Orchestrator(
config=config,
event_bus=bus,
file_scanner=scanner,
exif_adapter=Mock(),
ffprobe_adapter=Mock(),
ffmpeg_adapter=Mock()
)
files, stats = orchestrator._perform_discovery(test_dir)
assert len(files) == 1
assert files[0].path.name == "test.mp4"
assert stats['files_found'] >= 1
Test Fixtures¶
Shared Fixtures¶
# tests/conftest.py
import pytest
from pathlib import Path
from vbc.config.models import AppConfig, GeneralConfig
from vbc.infrastructure.event_bus import EventBus
@pytest.fixture
def event_bus():
"""Create fresh EventBus for each test."""
return EventBus()
@pytest.fixture
def default_config():
"""Create default AppConfig."""
return AppConfig(general=GeneralConfig())
@pytest.fixture
def sample_video_path():
"""Path to sample test video."""
return Path(__file__).parent / "fixtures" / "sample.mp4"
Using Fixtures¶
def test_with_fixtures(event_bus, default_config):
"""Test using fixtures."""
assert default_config.general.threads == 1
assert isinstance(event_bus, EventBus)
Mocking External Dependencies¶
Mock FFmpeg¶
from unittest.mock import patch, Mock
def test_ffmpeg_compression():
"""Test FFmpegAdapter with mocked subprocess."""
with patch('subprocess.Popen') as mock_popen:
mock_process = Mock()
mock_process.returncode = 0
mock_process.stdout = []
mock_popen.return_value = mock_process
# Test compression
adapter = FFmpegAdapter(event_bus=EventBus())
# ... test logic
Mock ExifTool¶
def test_exiftool_extraction():
"""Test ExifToolAdapter with mocked exiftool."""
with patch('exiftool.ExifTool') as mock_et:
mock_instance = Mock()
mock_instance.execute_json.return_value = [{
'QuickTime:ImageWidth': 1920,
'QuickTime:ImageHeight': 1080,
'EXIF:Model': 'ILCE-7RM5'
}]
mock_et.return_value = mock_instance
# Test extraction
adapter = ExifToolAdapter()
# ... test logic
Test Markers¶
Use markers to categorize tests:
import pytest
@pytest.mark.unit
def test_fast_unit():
"""Fast unit test."""
pass
@pytest.mark.integration
def test_slow_integration():
"""Slow integration test."""
pass
@pytest.mark.slow
def test_very_slow():
"""Very slow test (real video encoding)."""
pass
Run specific markers:
Parametrized Tests¶
Test multiple scenarios:
import pytest
from vbc.config.models import AppConfig, GeneralConfig
from vbc.infrastructure.ffmpeg import extract_quality_value, replace_quality_value
@pytest.mark.parametrize("quality,expected_quality", [
(35, "high"),
(45, "medium"),
(55, "low"),
])
def test_quality_mapping(quality, expected_quality):
"""Test quality-to-label mapping."""
config = AppConfig(general=GeneralConfig())
args = replace_quality_value(config.gpu_encoder.common_args, quality)
assert extract_quality_value(args) == quality
# Assert quality label based on value
Test Coverage Goals¶
Target coverage levels:
| Module | Target | Current |
|---|---|---|
config/ |
95%+ | TBD |
domain/ |
95%+ | TBD |
infrastructure/ |
80%+ | TBD |
pipeline/ |
90%+ | TBD |
ui/ |
70%+ | TBD |
Run coverage report:
Automation (Optional)¶
Recommended local checks before push:
Debugging Tests¶
Print Debugging¶
def test_with_debug():
"""Test with debug output."""
result = some_function()
print(f"Result: {result}") # Visible with pytest -s
assert result == expected
Run with output:
PDB Debugging¶
def test_with_pdb():
"""Test with debugger."""
import pdb; pdb.set_trace() # Breakpoint
result = some_function()
assert result == expected
Run with debugger:
Best Practices¶
- One assertion per test (when possible)
- Use descriptive test names (test_what_when_then)
- Arrange-Act-Assert pattern
- Mock external dependencies (filesystem, network, processes)
- Use fixtures for shared setup
- Parametrize for multiple scenarios
- Test edge cases (empty lists, None values, errors)
- Test error paths (not just happy path)
Example Test Suite¶
# tests/unit/test_file_scanner.py
import pytest
from pathlib import Path
from vbc.infrastructure.file_scanner import FileScanner
@pytest.fixture
def scanner():
return FileScanner(
extensions=[".mp4", ".mov"],
min_size_bytes=1024
)
def test_scanner_finds_matching_files(tmp_path, scanner):
"""Test scanner finds files with matching extensions."""
# Arrange
video1 = tmp_path / "video1.mp4"
video2 = tmp_path / "video2.mov"
video3 = tmp_path / "video3.avi" # Wrong extension
video1.write_bytes(b"x" * 2000)
video2.write_bytes(b"x" * 2000)
video3.write_bytes(b"x" * 2000)
# Act
files = list(scanner.scan(tmp_path))
# Assert
assert len(files) == 2
assert any(f.path.name == "video1.mp4" for f in files)
assert any(f.path.name == "video2.mov" for f in files)
assert not any(f.path.name == "video3.avi" for f in files)
def test_scanner_filters_small_files(tmp_path, scanner):
"""Test scanner filters files below minimum size."""
# Arrange
small = tmp_path / "small.mp4"
large = tmp_path / "large.mp4"
small.write_bytes(b"x" * 500) # Below 1024 bytes
large.write_bytes(b"x" * 2000) # Above 1024 bytes
# Act
files = list(scanner.scan(tmp_path))
# Assert
assert len(files) == 1
assert files[0].path.name == "large.mp4"
def test_scanner_skips_output_directories(tmp_path, scanner):
"""Test scanner skips directories ending in _out."""
# Arrange
(tmp_path / "normal").mkdir()
(tmp_path / "normal" / "video.mp4").write_bytes(b"x" * 2000)
(tmp_path / "videos_out").mkdir()
(tmp_path / "videos_out" / "output.mp4").write_bytes(b"x" * 2000)
# Act
files = list(scanner.scan(tmp_path))
# Assert
assert len(files) == 1
assert files[0].path.parent.name == "normal"
Next Steps¶
- Contributing - Contribution guidelines