Integration Testing Guide¶
This document provides a comprehensive guide to integration testing ZooUI during development, covering end-to-end pipeline testing, thread safety validation, component interaction testing, and full GUI integration verification. Integration tests validate that components work correctly together as a complete system.
Overview¶
The ZooUI integration test suite is designed to:
Validate complete workflows from input to output
Test component interactions and interfaces
Verify thread safety and concurrent access patterns
Ensure cache-provider coordination works correctly
Test the full application with real user interactions
Catch integration bugs that unit tests might miss
Test Framework: pytest with custom fixtures
Test Types: - Pipeline Tests: End-to-end tiling workflows - Concurrency Tests: Thread safety and race conditions - Integration Tests: Component interaction validation - GUI Tests: Full application visual verification
Key Difference from Unit Tests: Integration tests use real dependencies (actual images, real disk I/O, threading) rather than mocks, validating the complete system behavior.
Test Suite Structure¶
Directory Organization¶
test/integrationtest/
├── test_tiling_pipeline.py # End-to-end tiling tests
├── test_converter_pipeline.py # Converter to tiler pipeline tests
├── test_concurrent_access.py # Thread safety tests
├── test_cache_provider_integration.py # Cache-provider interaction
├── test_tilemanager_integration.py # TileManager coordination
├── guiintegration/ # Visual GUI testing (manual)
│ ├── main.py # Test orchestrator + CLI
│ ├── conf.py # Timing constants
│ ├── logger.py # Test logger
│ ├── utilities/ # Shared utilities
│ │ ├── qt_simulation.py
│ │ ├── scene_helpers.py
│ │ ├── image_creation.py
│ │ └── temp_dirs.py
│ ├── test/ # 43 test modules (1 per step)
│ │ ├── load_test_scene.py
│ │ ├── new_scene.py
│ │ └── ... (43 modules total)
│ └── GUI_INTEGRATION.md # GUI test documentation
└── logs/ # Test execution logs
└── zooui.log
Test Categories¶
1. Tiling Pipeline Tests (test_tiling_pipeline.py)
Complete image-to-tiles workflow
Pyramid structure verification
Tile retrieval and caching
Multiple image handling
Error handling in pipeline
2. Converter Pipeline Tests (test_converter_pipeline.py)
VipsConverter image format conversion (PNG, JPG, TIFF to PPM)
PDFConverter PDF rasterization
Converter to Tiler pipeline integration
Progress tracking during conversion
Error handling for invalid/corrupted files
Process-based parallel conversion via
converterrunnerOutput format validation
3. Concurrent Access Tests (test_concurrent_access.py)
Thread-safe tile requests
Concurrent tiling operations
Cache thread safety
Provider queue concurrency
Race condition prevention
4. Cache-Provider Integration (test_cache_provider_integration.py)
Provider-cache interaction
Cache hit prevention of redundant loads
Eviction and reload behavior
Multi-provider cache sharing
Request queue processing
5. TileManager Coordination (test_tilemanager_integration.py)
Request routing (static vs dynamic)
Tile synthesis and fallback
Metadata access coordination
Negative tile level handling
Dual-cache management
6. GUI Integration (guiintegration/)
Full application workflow
All user interactions (43 test steps)
Visual verification by human
Individual step execution (
--only,--only-step)Menu operations, dialogs, mouse and keyboard input
Verbose/debug logging control (
-v,-d)
Running Integration Tests¶
Basic Usage¶
Run all integration tests:
cd test/integrationtest
pytest
Run specific test file:
pytest test_tiling_pipeline.py
Run with verbose output:
pytest -v
Run specific test class:
pytest test_tiling_pipeline.py::TestTilingPipelineEndToEnd
Run specific test:
pytest test_tiling_pipeline.py::TestTilingPipelineEndToEnd::test_tile_image_creates_pyramid_structure
Run converter pipeline tests:
# All converter tests (27 tests)
pytest test_converter_pipeline.py -v
Integration Test Options¶
Show detailed output:
pytest -v -s
Stop on first failure:
pytest -x
Run with coverage:
pytest --cov=zooui --cov-report=term
Parallel execution (use with caution):
# Integration tests may have shared state
# Only use if tests are truly independent
pytest -n auto
Logging:
Integration tests produce detailed logs. View them with:
tail -f ~/.local/state/zooui/logs/zooui.log
GUI Integration Tests¶
GUI tests run separately and require visual verification:
Run all GUI tests:
python test/integrationtest/guiintegration/main.py
Run with verbose logging (-v) or debug logging (-d):
python test/integrationtest/guiintegration/main.py -v
python test/integrationtest/guiintegration/main.py -d
Run a single test step:
python test/integrationtest/guiintegration/main.py --only new_tab
python test/integrationtest/guiintegration/main.py --only-step 16
Start from specific step:
python test/integrationtest/guiintegration/main.py --start-step 30
List available test steps:
python test/integrationtest/guiintegration/main.py --list-steps
Logging flags:
Flag |
Console output |
File output |
|---|---|---|
(none) |
WARNING+ |
INFO+ |
|
INFO+ |
DEBUG+ |
|
DEBUG+ |
DEBUG+ |
Common Fixtures¶
Test fixtures provide isolated environments and test resources.
temp_tilestore Fixture¶
Provides isolated temporary tile storage:
@pytest.fixture
def temp_tilestore(tmp_path):
"""
Fixture: Isolated Tile Storage
Provides a temporary directory for tile storage,
ensuring test isolation.
Yields:
str: Path to temporary tilestore directory.
"""
from zooui.tilesystem.tilestore import tilestore as ts_module
original_tile_dir = ts_module.tile_dir
temp_dir = str(tmp_path / "tilestore")
os.makedirs(temp_dir, exist_ok=True)
# Patch tilestore directory
ts_module.tile_dir = temp_dir
tilestore.tile_dir = temp_dir
yield temp_dir
# Restore and cleanup
ts_module.tile_dir = original_tile_dir
tilestore.tile_dir = original_tile_dir
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
Why This Matters:
Prevents tests from polluting system tilestore
Ensures each test starts with clean slate
Allows parallel test execution
Automatic cleanup after tests
sample_images Fixture¶
Creates test images of various sizes:
@pytest.fixture
def sample_images(tmp_path):
"""
Fixture: Sample Test Images
Creates test images of various sizes for testing
different tiling scenarios.
Yields:
dict: Mapping of (width, height) to file path.
"""
images = {}
test_cases = [
(256, 256, 'red'), # Single tile
(512, 512, 'green'), # 2x2 grid
(500, 300, 'blue'), # Non-power-of-two
(100, 100, 'yellow'), # Smaller than tile size
(1024, 1024, 'purple'), # Larger pyramid
]
for width, height, color in test_cases:
img = Image.new('RGB', (width, height), color=color)
# Add gradient pattern for visual verification
for y in range(height):
for x in range(min(10, width)):
img.putpixel((x, y), (x * 25, y % 256, 128))
path = tmp_path / f"test_{width}x{height}.png"
img.save(path)
images[(width, height)] = str(path)
yield images
# Cleanup
for path in images.values():
if os.path.exists(path):
os.remove(path)
Test Images Provided:
256x256: Exactly one tile (boundary test)
512x512: 2x2 tile grid (standard pyramid)
500x300: Non-power-of-two dimensions
100x100: Smaller than tile size
1024x1024: Multi-level pyramid (3 levels)
initialized_tilemanager Fixture¶
Initializes TileManager with test settings:
@pytest.fixture
def initialized_tilemanager(temp_tilestore):
"""
Fixture: Initialized TileManager
Sets up TileManager with test-appropriate settings
and ensures cleanup after test.
Yields:
None (TileManager is module with global state)
"""
tilemanager.init(total_cache_size=100, auto_cleanup=False)
yield
tilemanager.purge()
tilemanager.shutdown()
Configuration:
Cache size: 100 tiles (small for testing)
Auto-cleanup: Disabled (manual control)
Providers: Static and all registered dynamic providers
cache Fixture¶
Provides fresh TileCache instance:
@pytest.fixture
def cache():
"""
Fixture: Fresh TileCache
Provides isolated TileCache with reasonable defaults.
Yields:
TileCache: A fresh cache instance.
"""
return TileCache(maxsize=100, maxage=3600)
Tiling Pipeline Tests¶
End-to-End Workflow Testing¶
These tests validate the complete tiling pipeline from image input to tile retrieval.
Test Pattern:
class TestTilingPipelineEndToEnd:
"""
Feature: Complete Tiling Pipeline
The tiling system converts large images into pyramidal
tile structures for efficient zooming and panning.
"""
def test_tile_image_creates_pyramid_structure(
self, temp_tilestore, sample_images):
"""
Scenario: Tiling creates complete tile pyramid
Given a 512x512 pixel image
When the Tiler processes the image
Then tiles are created at multiple pyramid levels
And all tiles are stored in TileStore
"""
image_path = sample_images[(512, 512)]
media_id = "test_512x512"
# When: Process the image
tiler = ConcreteTiler(image_path, media_id=media_id)
tiler.run()
# Then: Verify completion
assert tiler.error is None
assert tiler.progress == 1.0
# And: Verify pyramid structure
# Level 0: 1 tile (overview)
assert os.path.exists(
tilestore.get_tile_path((media_id, 0, 0, 0), filext='jpg'))
# Level 1: 2x2 = 4 tiles (full resolution)
for row in range(2):
for col in range(2):
tile_path = tilestore.get_tile_path(
(media_id, 1, row, col), filext='jpg')
assert os.path.exists(tile_path)
# And: Verify metadata
assert tilestore.tiled(media_id)
assert tilestore.get_metadata(media_id, 'width') == 512
assert tilestore.get_metadata(media_id, 'maxtilelevel') == 1
Key Validation Points:
Tiler Completion: Error-free with 100% progress
Pyramid Structure: Correct number of tiles at each level
File Creation: All tile files exist on disk
Metadata Storage: Width, height, tilesize, maxtilelevel recorded
Tile Retrieval Integration¶
Validate tile retrieval after tiling:
class TestTileRetrievalIntegration:
def test_retrieve_tiles_after_tiling(
self, temp_tilestore, sample_images):
"""
Scenario: Retrieve tiles from TileStore after tiling
Given an image that has been tiled
When tiles are requested from TileStore
Then tile files can be read as valid images
"""
# Given: Tile the image
tiler = ConcreteTiler(image_path, media_id=media_id)
tiler.run()
# When: Retrieve tile path
tile_path = tilestore.get_tile_path(
(media_id, 1, 0, 0), filext='jpg')
# Then: Tile is readable
assert os.path.exists(tile_path)
tile_image = Image.open(tile_path)
assert tile_image.size[0] <= 256
assert tile_image.size[1] <= 256
Verifies:
Tile files are valid image format
Tiles have correct dimensions
TileStore paths are accurate
Cache Integration Testing¶
Validate tile caching behavior:
class TestTileCacheIntegration:
def test_cache_stores_and_retrieves_tiles(
self, temp_tilestore, sample_images):
"""
Scenario: Cache stores and retrieves tiles
Given a TileCache instance
When a tile is added
Then the same tile can be retrieved by ID
"""
cache = TileCache(maxsize=100)
# Create test tile
test_image = Image.new('RGB', (256, 256), color='red')
tile = Tile(test_image)
tile_id = ('test_media', 0, 0, 0)
# Store in cache
cache[tile_id] = tile
# Retrieve from cache
retrieved = cache[tile_id]
assert retrieved is tile
def test_cache_lru_eviction(
self, temp_tilestore, sample_images):
"""
Scenario: LRU eviction removes least recently used
Given cache with limited size
When cache exceeds capacity
Then LRU tiles are evicted
"""
cache = TileCache(maxsize=2)
# Add 3 tiles (using level=1 for mortal tiles)
cache[('m1', 1, 0, 0)] = Tile(Image.new('RGB', (256, 256)))
cache[('m2', 1, 0, 0)] = Tile(Image.new('RGB', (256, 256)))
cache[('m3', 1, 0, 0)] = Tile(Image.new('RGB', (256, 256)))
# First tile should be evicted
assert ('m1', 1, 0, 0) not in cache
assert ('m2', 1, 0, 0) in cache
assert ('m3', 1, 0, 0) in cache
Converter Pipeline Tests¶
The converter pipeline tests (test_converter_pipeline.py) validate the complete workflow
from image/PDF conversion through to tiling. These tests use a ConcreteTiler implementation
that reads PPM image data using PIL.
ConcreteTiler Implementation¶
The test file includes a ConcreteTiler class that implements the abstract _scanchunk
method for reading image rows:
class ConcreteTiler(Tiler):
"""
A concrete implementation of Tiler for testing the converter pipeline.
Implements the _scanchunk method using PIL to read PPM image data.
"""
def __init__(self, infile, media_id=None, filext='jpg', tilesize=256):
super().__init__(infile, media_id, filext, tilesize)
self._image = Image.open(infile).convert('RGB')
self._width, self._height = self._image.size
self._bytes_per_pixel = 3
self._current_row = 0
def _scanchunk(self):
"""Read the next scanline from the image."""
if self._current_row >= self._height:
return b''
# Use crop to get entire row at once (much faster than getpixel)
row_img = self._image.crop(
(0, self._current_row, self._width, self._current_row + 1))
row_data = row_img.tobytes()
self._current_row += 1
return row_data
Performance Note: The _scanchunk implementation uses crop().tobytes() rather than
pixel-by-pixel reading with getpixel(). This is critical for performance - reading a
2048x1536 image pixel-by-pixel would require ~3 million getpixel() calls and cause
tests to hang.
VipsConverter Tests¶
Test VipsConverter image format conversion:
class TestVipsConverterBasicOperations:
"""
Feature: VipsConverter Basic Operations
VipsConverter converts various image formats to PPM for tiling.
"""
def test_convert_png_to_ppm(self, sample_images, tmp_path):
"""
Scenario: Convert PNG to PPM format
Given a PNG image file
When VipsConverter processes the file
Then a valid PPM file is created
And the output has correct dimensions
"""
infile = sample_images['png']
outfile = str(tmp_path / "output.ppm")
converter = VipsConverter(infile, outfile)
converter.run()
assert converter.error is None
assert converter.progress == 1.0
assert os.path.exists(outfile)
Test Classes:
TestVipsConverterBasicOperations: PNG, JPG, TIFF conversionTestVipsConverterErrorHandling: Invalid file handlingTestPDFConverterBasicOperations: PDF rasterizationTestPDFConverterErrorHandling: PDF error cases
Converter to Tiler Pipeline¶
Test the complete convert-then-tile workflow:
class TestConverterToTilerPipeline:
"""
Feature: Converter to Tiler Pipeline
Images must be converted to PPM format before tiling.
This tests the complete conversion-to-tiling workflow.
"""
def test_convert_then_tile_png(
self, sample_images, tmp_path, temp_tilestore,
initialized_tilemanager):
"""
Scenario: Convert PNG then tile
Given a PNG image
When converted to PPM and then tiled
Then tiles are created successfully
And tile pyramid is accessible
"""
infile = sample_images['png']
ppm_file = str(tmp_path / "converted.ppm")
media_id = "pipeline_test_png"
# Convert
converter = VipsConverter(infile, ppm_file)
converter.run()
assert converter.error is None
# Tile
tiler = ConcreteTiler(ppm_file, media_id=media_id, tilesize=256)
tiler.run()
assert tiler.error is None
# Verify
assert tilestore.tiled(media_id)
Process-Based Parallel Conversion¶
The converter pipeline uses process-based parallelism via converterrunner to enable
multiple conversions to run concurrently without threading conflicts.
Why Process-Based?
pyvips uses its own internal thread pool for image operations. When multiple
VipsConverter instances run in threads alongside TileManager threads, conflicts
can occur. By running conversions in separate processes:
Each pyvips instance runs in its own memory space
No interference with TileManager’s TileProvider threads
True parallel execution (not limited by Python’s GIL)
Process-Based Test Pattern:
def test_multiple_converters_run_concurrently_via_processes(
self, sample_images, tmp_path):
"""Test parallel conversion using processes."""
from zooui.converters import converterrunner
from concurrent.futures import wait
futures = []
outfiles = []
for i in range(3):
infile = sample_images['png']
outfile = str(tmp_path / f"output_{i}.ppm")
outfiles.append(outfile)
future = converterrunner.submit_vips_conversion(infile, outfile)
futures.append(future)
# Wait for all to complete with timeout
done, not_done = wait(futures, timeout=60)
assert len(not_done) == 0, f"{len(not_done)} conversions timed out"
# Verify all completed successfully
for i, future in enumerate(futures):
error = future.result()
assert error is None, f"Conversion {i} failed: {error}"
Thread-Based Test (Single Converter):
Individual converters can still run in threads for simple use cases:
def test_converter_runs_in_thread(self, sample_images, tmp_path):
"""Test converter runs correctly in a separate thread."""
import threading
converter = VipsConverter(infile, outfile)
result = {'done': False}
def run_conversion():
converter.run()
result['done'] = True
thread = threading.Thread(target=run_conversion)
thread.start()
thread.join(timeout=30)
assert result['done'], "Conversion thread timed out"
assert converter.progress == 1.0
Converter Progress Tracking¶
Test progress reporting during conversion:
class TestConverterProgressTracking:
def test_progress_starts_at_zero(self, sample_images, tmp_path):
"""Progress starts at 0.0 before run()."""
converter = VipsConverter(sample_images['png'],
str(tmp_path / "out.ppm"))
assert converter.progress == 0.0
def test_progress_reaches_one_after_completion(
self, sample_images, tmp_path):
"""Progress reaches 1.0 after successful conversion."""
converter = VipsConverter(sample_images['png'],
str(tmp_path / "out.ppm"))
converter.run()
assert converter.progress == 1.0
Concurrent Access Tests¶
Thread Safety Validation¶
Concurrent tests validate thread-safe operations:
class TestConcurrentTileRequests:
"""
Feature: Concurrent Tile Request Handling
Multiple threads may request tiles simultaneously.
System must handle without race conditions.
"""
def test_same_tile_requested_by_multiple_threads(self, cache):
"""
Scenario: Same tile requested simultaneously
Given multiple threads requesting same tile
When requests arrive concurrently
Then tile is loaded only once
And all threads receive the same tile
"""
provider = MockTileProvider(cache, load_delay=0.1)
provider.start()
tile_id = ('media', 1, 0, 0)
num_threads = 10
def request_tile():
provider.request(tile_id)
time.sleep(0.2)
if tile_id in cache:
results.append(cache[tile_id])
threads = [threading.Thread(target=request_tile)
for _ in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
# Tile loaded only once (or very few times due to race)
assert provider.load_call_count <= 2
assert tile_id in cache
Validates:
No duplicate tile loads
All threads eventually succeed
Cache consistency under concurrent access
Concurrent Tiling Operations¶
Test concurrent image tiling:
class TestConcurrentTilingOperations:
def test_concurrent_tiling_different_images(
self, temp_tilestore, sample_images, initialized_tilemanager):
"""
Scenario: Multiple images tiled concurrently
Given several images to tile
When tiling operations run in parallel
Then each image is tiled correctly
And no cross-contamination occurs
"""
def tile_image(image_path, index):
media_id = f"concurrent_media_{index}"
tiler = ConcreteTiler(image_path, media_id=media_id)
tiler.run()
results[media_id] = {
'error': tiler.error,
'progress': tiler.progress
}
threads = []
for i, path in enumerate(sample_images[:3]):
t = threading.Thread(target=tile_image, args=(path, i))
threads.append(t)
t.start()
for t in threads:
t.join()
# All should complete successfully
for media_id, result in results.items():
assert result['error'] is None
assert result['progress'] == 1.0
assert tilestore.tiled(media_id)
Cache Thread Safety¶
Test cache operations under concurrent load:
class TestCacheThreadSafety:
def test_concurrent_read_write_mix(self, cache):
"""
Scenario: Concurrent reads and writes
Given ongoing read and write operations
When executed concurrently
Then no corruption or crashes occur
"""
errors = []
stop_flag = threading.Event()
def writer():
i = 0
while not stop_flag.is_set():
tile_id = ('media', 1, 0, i % 50)
try:
cache[tile_id] = Tile(Image.new('RGB', (256, 256)))
except Exception as e:
errors.append(('write', e))
i += 1
time.sleep(0.001)
def reader():
while not stop_flag.is_set():
for i in range(50):
tile_id = ('media', 1, 0, i)
try:
if tile_id in cache:
_ = cache[tile_id]
except KeyError:
pass # Expected - evicted
except Exception as e:
errors.append(('read', e))
time.sleep(0.001)
writers = [threading.Thread(target=writer) for _ in range(3)]
readers = [threading.Thread(target=reader) for _ in range(5)]
for t in writers + readers:
t.start()
time.sleep(1) # Run for 1 second
stop_flag.set()
for t in writers + readers:
t.join()
assert len(errors) == 0
Stress Test Patterns:
High concurrency (many threads)
Mixed operations (reads and writes)
Long running (seconds of continuous operation)
Verification of no errors/crashes
Provider-Cache Integration¶
Provider-Cache Interaction¶
Test how providers and cache work together:
class TestProviderCacheBasicInteraction:
def test_provider_stores_loaded_tile_in_cache(self, cache):
"""
Scenario: Provider stores tile in cache
Given a TileProvider with empty cache
When provider loads a tile
Then tile is stored in cache
"""
provider = MockTileProvider(cache)
provider.start()
tile_id = ('test_media', 1, 0, 0)
# Configure specific image
expected_image = Image.new('RGB', (256, 256), color='red')
provider.set_image(tile_id, expected_image)
# Request tile
provider.request(tile_id)
time.sleep(0.2)
# Verify caching
assert tile_id in cache
assert provider.load_call_count == 1
def test_cache_hit_prevents_provider_reload(self, cache):
"""
Scenario: Cache hit prevents reload
Given tile already in cache
When same tile requested again
Then provider is not called
"""
provider = MockTileProvider(cache)
provider.start()
tile_id = ('test_media', 1, 0, 0)
# Pre-populate cache
cache[tile_id] = Tile(Image.new('RGB', (256, 256), color='blue'))
# Request tile
provider.request(tile_id)
time.sleep(0.1)
# Provider not called
assert provider.load_call_count == 0
Dynamic Provider Testing¶
Test dynamically generated tiles:
class TestDynamicProviderCacheInteraction:
def test_dynamic_provider_caches_generated_tiles(
self, cache, temp_tilestore):
"""
Scenario: Dynamic provider caches generated tiles
Given DynamicTileProvider with empty cache
When tile is requested for generation
Then tile is generated and cached
"""
provider = MockDynamicProvider(cache)
provider.start()
tile_id = ('mock:dynamic', 5, 10, 20)
# Request generation
provider.request(tile_id)
time.sleep(0.2)
# Verify generation and caching
assert provider.generate_call_count == 1
assert tile_id in cache
def test_dynamic_provider_skips_cached_tiles(
self, cache, temp_tilestore):
"""
Scenario: Dynamic provider doesn't regenerate cached
Given generated tile already in cache
When same coordinates requested
Then generation is skipped
"""
provider = MockDynamicProvider(cache)
provider.start()
tile_id = ('mock:dynamic', 5, 10, 20)
# Pre-cache
cache[tile_id] = Tile(Image.new('RGB', (256, 256), color='white'))
# Request same tile
provider.request(tile_id)
time.sleep(0.1)
# No generation
assert provider.generate_call_count == 0
Cache Eviction Behavior¶
Test provider behavior during cache eviction:
class TestCacheEvictionProviderBehavior:
def test_evicted_tile_triggers_provider_reload(self, small_cache):
"""
Scenario: Evicted tile is reloaded on next request
Given cache with limited capacity
And tile that was loaded then evicted
When evicted tile requested again
Then provider reloads the tile
"""
provider = MockTileProvider(small_cache)
provider.start()
# Fill cache (using level > 0 for mortal tiles)
for i in range(5):
provider.request(('media', 1, 0, i))
time.sleep(0.3)
# Add more to trigger eviction
for i in range(5, 8):
provider.request(('media', 1, 0, i))
time.sleep(0.3)
# First tile may be evicted
first_tile_id = ('media', 1, 0, 0)
# Clear tracking
provider.load_call_count = 0
# Re-request
provider.request(first_tile_id)
time.sleep(0.2)
# Should be in cache (reloaded or never evicted)
assert first_tile_id in small_cache
TileManager Integration¶
Request Routing¶
Test TileManager’s routing logic:
class TestTileRequestRouting:
def test_static_media_routed_to_static_provider(
self, temp_tilestore, tiled_media, initialized_tilemanager):
"""
Scenario: Static media goes to static provider
Given tiled static media
When tile requested via load_tile
Then request routed to StaticTileProvider
"""
tile_id = (tiled_media, 0, 0, 0)
# Request tile
tilemanager.load_tile(tile_id)
time.sleep(0.3)
# Should be loadable
tile = tilemanager.get_tile(tile_id)
assert tile is not None
assert isinstance(tile, Tile)
def test_dynamic_media_routed_to_dynamic_provider(
self, temp_tilestore, initialized_tilemanager):
"""
Scenario: Dynamic media goes to dynamic provider
Given initialized TileManager
When tile requested for dynamic media
Then request routed to DynamicTileProvider
"""
tile_id = ("dynamic:fern", 5, 0, 0)
# Request tile
tilemanager.load_tile(tile_id)
time.sleep(0.5)
# Should be generated
try:
tile = tilemanager.get_tile(tile_id)
assert tile is not None
except TileNotLoaded:
pass # Still loading - OK
Get Tile Behavior¶
Test get_tile and get_tile_robust:
class TestGetTileBehavior:
def test_get_tile_raises_media_not_tiled(
self, temp_tilestore, initialized_tilemanager):
"""
Scenario: get_tile raises MediaNotTiled
Given media not tiled
When get_tile called
Then MediaNotTiled raised
"""
tile_id = ("never_tiled.jpg", 0, 0, 0)
with pytest.raises(MediaNotTiled):
tilemanager.get_tile(tile_id)
def test_get_tile_raises_tile_not_loaded(
self, temp_tilestore, tiled_media, initialized_tilemanager):
"""
Scenario: get_tile raises TileNotLoaded
Given tiled media with uncached tile
When get_tile called
Then TileNotLoaded raised
"""
tile_id = (tiled_media, 1, 1, 1)
with pytest.raises(TileNotLoaded):
tilemanager.get_tile(tile_id)
class TestGetTileRobust:
def test_get_tile_robust_synthesizes_missing_tile(
self, temp_tilestore, tiled_media, initialized_tilemanager):
"""
Scenario: get_tile_robust synthesizes from parent
Given tile not cached
And parent tiles available
When get_tile_robust called
Then tile synthesized via cut_tile
"""
# Ensure level 0 cached
tilemanager.load_tile((tiled_media, 0, 0, 0))
time.sleep(0.3)
# Request higher level
tile = tilemanager.get_tile_robust((tiled_media, 1, 0, 0))
assert tile is not None
Negative Tile Levels¶
Test zoomed-out view handling:
class TestNegativeTileLevels:
def test_negative_level_produces_smaller_tile(
self, temp_tilestore, tiled_media, initialized_tilemanager):
"""
Scenario: Negative levels produce smaller tiles
Given base tile at level 0
When negative levels requested
Then each level is half the size
"""
# Cache level 0
tilemanager.load_tile((tiled_media, 0, 0, 0))
time.sleep(0.3)
base_tile = tilemanager.get_tile((tiled_media, 0, 0, 0))
base_size = base_tile.size
# Level -1: 50%
tile_neg1, _ = tilemanager.cut_tile((tiled_media, -1, 0, 0))
assert tile_neg1.size[0] == base_size[0] // 2
assert tile_neg1.size[1] == base_size[1] // 2
# Level -2: 25%
tile_neg2, _ = tilemanager.cut_tile((tiled_media, -2, 0, 0))
assert tile_neg2.size[0] == base_size[0] // 4
assert tile_neg2.size[1] == base_size[1] // 4
GUI Integration Testing¶
Visual Verification Testing¶
GUI tests validate the complete application through visual verification. Run from the project root directory:
# Run all 43 tests (quiet — WARNING+ on console)
python test/integrationtest/guiintegration/main.py
# Run with verbose logging (INFO+ on console)
python test/integrationtest/guiintegration/main.py -v
# Run with debug logging (DEBUG+ on console)
python test/integrationtest/guiintegration/main.py -d
# Run a single test by module name
python test/integrationtest/guiintegration/main.py --only new_tab
# Run a single test by step number
python test/integrationtest/guiintegration/main.py --only-step 16
# Start from specific step
python test/integrationtest/guiintegration/main.py --start-step 30
# List all available steps
python test/integrationtest/guiintegration/main.py --list-steps
Test Steps Covered:
Setup:
1. Load Test Scene (Media Dir + String)
File Menu:
2. New Scene
3. Open Home Scene
4. Reload Test Scene
5. Save Screenshot
6. Save Scene
7. Open Saved Scene
8. Open New String Dialog
9. Open Local Media
14. Open new SVG
15. Import Scene
16. New Tab
17. Close Tab
View Menu:
10. Set Framerate
11. Adjust Sensitivity
12. Fullscreen Toggle
13. Adjust Sensitivity Dialog
18. Render Order Toggle
Settings Menu:
19. Autosave Settings
22. Zoom Settings
Actions Menu:
23. Copy SVG
24. Paste SVG
Help Menu:
20. About Dialog
21. About Qt Dialog
SVG:
25. Full Elongation Test
26. SVG - Reload Test Scene
Mouse Interactions:
30. Left Click Select
31. Click and Drag
32. Scroll Wheel Zoom
33. Multi-Selection Persistence
35. Control+Click Rectangle Drawing and Move
36. Shift+Click No Selection Change
45. Right-Click String Modification Dialog
46. Right-Click Image Modification Dialog
47. Right-Click SVG Modification Dialog
Keyboard Interactions:
34. Alt Fine Zoom Control
37. Ctrl+C/V Copy Paste
40. Escape Deselect
41. Page Up/Down Zoom
42. Arrow Keys Move
43. Space Center
44. Delete Media
Complete Workflow:
90. Full Application Workflow
99. File Menu - Quit
Test Resources:
The GUI test creates temporary resources:
/tmp/pytest-of-{user}/pytest-{n}/zooui_gui_test0/
├── media_directory/
│ ├── 01_red_stripes.png
│ ├── 02_green_gradient.png
│ ├── 03_blue_checkerboard.png
│ ├── 04_yellow_solid.png
│ ├── 05_purple_diagonal.png
│ └── 06_cyan_circles.png
└── save_output/
├── test_screenshot.png
├── test_scene.pzs
└── workflow_complete.pzs
Timing Configuration:
Timing constants are in guiintegration/conf.py:
SHORT_DELAY_MS = 2000 # Short pauses (2 seconds)
DEFAULT_DELAY_MS = 150 # Standard delay between actions (0.15s)
LONG_DELAY_MS = 200 # Long delay for loading/rendering (0.2s)
IMAGE_LOAD_DELAY_MS = 500 # Extra time for images to fully load (0.5s)
ZOOM_STEP_DELAY_MS = 50 # Delay between zoom steps (0.05s)
MOVE_STEP_DELAY_MS = 30 # Delay between movement steps (0.03s)
Package Structure:
test/integrationtest/guiintegration/
├── main.py # Orchestrator: GUITestContext, GUIIntegrationTest
├── conf.py # Timing constants
├── logger.py # GUITestLogger
├── utilities/
│ ├── qt_simulation.py # wait, trigger_action, simulate_*
│ ├── scene_helpers.py # load_media_dir, add_test_string, add_svg
│ ├── image_creation.py # create_ppm_image, create_png_image
│ └── temp_dirs.py # get_pytest_style_temp_dir
└── test/ # 43 modules (one per test step)
├── load_test_scene.py
├── new_scene.py
├── ... (43 modules total)
└── quit.py
Writing New Integration Tests¶
Test Structure Guidelines¶
1. Use Appropriate Fixtures
class TestNewIntegration:
def test_feature(self, temp_tilestore, sample_images,
initialized_tilemanager):
"""Use fixtures to isolate test environment."""
2. Test Complete Workflows
def test_complete_workflow(self, ...):
"""
Scenario: Complete user workflow
Given initial state
When user performs sequence of actions
Then final state is correct
And all intermediate states were valid
"""
# Setup
initial_state = create_initial_state()
# Workflow step 1
perform_action_1()
verify_state_1()
# Workflow step 2
perform_action_2()
verify_state_2()
# Final verification
verify_final_state()
3. Use Real Dependencies
# Good - uses real image and real tiler
def test_real_tiling(self, sample_images):
image_path = sample_images[(512, 512)]
tiler = ConcreteTiler(image_path, media_id="test")
tiler.run()
assert tiler.error is None
# Avoid - mocking defeats integration testing purpose
@patch('zooui.tilesystem.tiler.Tiler')
def test_with_mock(self, mock_tiler):
# This is a unit test, not integration test
pass
4. Verify File System State
def test_tiles_created_on_disk(self, temp_tilestore):
"""Verify actual files created."""
# Perform operation
tiler.run()
# Check files exist
tile_path = tilestore.get_tile_path(tile_id, filext='jpg')
assert os.path.exists(tile_path)
# Verify file contents
img = Image.open(tile_path)
assert img.size == (256, 256)
5. Test Thread Safety
def test_concurrent_operation(self):
"""Test thread safety with real threads."""
results = []
errors = []
def worker():
try:
result = perform_operation()
results.append(result)
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=worker) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0
assert len(results) == 10
6. Allow Appropriate Timing
def test_async_operation(self):
"""Allow time for async operations."""
# Trigger async operation
provider.request(tile_id)
# Wait for completion
time.sleep(0.3)
# Verify result
assert tile_id in cache
7. Cleanup Properly
def test_with_cleanup(self, tmp_path):
"""Ensure resources cleaned up."""
temp_file = tmp_path / "test.dat"
try:
# Test operations
create_file(temp_file)
process_file(temp_file)
finally:
# Cleanup (fixtures handle most cleanup)
if temp_file.exists():
temp_file.unlink()
Best Practices¶
Test Design¶
Do:
Test complete workflows end-to-end
Use real dependencies (images, files, threads)
Verify file system state
Test concurrent scenarios
Allow appropriate timing for async operations
Isolate tests with fixtures
Verify both success and failure paths
Always call
tilemanager.shutdown()in fixture teardown aftertilemanager.init()Patch
QApplication.instance()to returnNonein unit tests that validateThreadPoolExecutorbehaviour in the tiling pipeline
Don’t:
Mock core components (defeats integration testing)
Assume instant operations (use time.sleep appropriately)
Share state between tests (use fixtures)
Skip cleanup (use try/finally or fixtures)
Test trivial integrations (use unit tests)
Create
QApplicationin a session-scoped fixture (it outlives its test and breaks non-GUI tests that useQImageoperations in threads)
Timing Considerations¶
Async Operations:
# Provider operations are async
provider.request(tile_id)
time.sleep(0.2) # Allow processing
# Tiling operations take time
tiler.run() # Blocks until complete
assert tiler.progress == 1.0
Thread Operations:
# Start threads
for t in threads:
t.start()
# Wait for completion
for t in threads:
t.join() # Blocks until thread finishes
# Or use timeout
for t in threads:
t.join(timeout=5.0)
assert not t.is_alive()
Resource Management¶
Temporary Directories:
# Fixtures handle most cleanup
def test_with_temp_dir(self, temp_tilestore, tmp_path):
# temp_tilestore: Isolated tilestore
# tmp_path: Pytest temporary directory
# Both auto-cleaned after test
File Handles:
# Use context managers
with open(file_path, 'rb') as f:
data = f.read()
# Or ensure cleanup
f = open(file_path, 'rb')
try:
data = f.read()
finally:
f.close()
Thread Safety:
# Use thread-safe data structures
results = queue.Queue()
errors = queue.Queue()
def worker():
try:
results.put(do_work())
except Exception as e:
errors.put(e)
Common Patterns¶
Testing Pipelines¶
def test_complete_pipeline(self, fixtures):
"""Test end-to-end pipeline."""
# Stage 1: Input
input_data = create_input()
assert validate_input(input_data)
# Stage 2: Processing
processed = process(input_data)
assert validate_processed(processed)
# Stage 3: Storage
store(processed)
assert exists_in_store(processed)
# Stage 4: Retrieval
retrieved = retrieve(processed.id)
assert retrieved == processed
Testing Concurrency¶
def test_thread_safety(self):
"""Test concurrent access."""
shared_state = SharedState()
errors = []
def concurrent_operation():
try:
for _ in range(100):
shared_state.operation()
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=concurrent_operation)
for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0
assert shared_state.is_consistent()
Testing Caching¶
def test_cache_behavior(self, cache):
"""Test caching with real operations."""
# Miss - initial load
assert tile_id not in cache
tile = load_tile(tile_id)
cache[tile_id] = tile
# Hit - from cache
assert tile_id in cache
cached_tile = cache[tile_id]
assert cached_tile is tile
# Eviction - LRU
fill_cache_to_capacity(cache)
assert tile_id not in cache # Evicted
Troubleshooting¶
Common Issues¶
Tests Fail Intermittently:
# Increase timing allowances
time.sleep(0.5) # Instead of 0.1
# Or add retries
for _ in range(3):
try:
result = operation()
break
except TileNotLoaded:
time.sleep(0.1)
Resource Leaks:
# Use fixtures for cleanup
@pytest.fixture
def resource():
r = create_resource()
yield r
r.cleanup()
File System Issues:
# Ensure temp directory writable
ls -la /tmp
# Check disk space
df -h
Threading Issues:
# Add thread debugging
import threading
print(f"Active threads: {threading.active_count()}")
print(f"Thread list: {threading.enumerate()}")
Segfaults with QApplication and QImage Operations:
Segfaults in the tiling pipeline that appear only when integration tests
are run together (but never in isolation) are almost always caused by an
orphaned QApplication instance created by a prior test.
Root Cause:
QApplicationis a process-wide singleton. Once any test creates one (e.g. aqappfixture, aQZUIwidget, or anyQDialogsubclass), it persists for all subsequent tests in the pytest session.The
Tilerinternally uses aThreadPoolExecutorto parallelise tile creation. Worker threads constructTileobjects, which internally callImageQt.ImageQt()— a Qt operation that createsQImageobjects.When a
QApplicationis alive, Qt enforces that manyQImageoperations happen on the main thread. Executing them on worker threads causes segmentation faults (SIGSEGV) with no Python traceback.A similar segfault can occur in
Tile.save()when callingQImage.save()after aQApplicationhas been created. ZooUI mitigates this by convertingQImage→ PILImageand saving via Pillow (seezooui/tilesystem/tile.py), but theThreadPoolExecutorissue in the Tiler remains the primary risk.
Symptoms:
Segfault at
Tile.save()→QImage.save()or insideTiler.run()→__tiles.Tests pass in isolation but crash when the full suite is run.
Fatal Python error: Segmentation faultwith backtrace showingshiboken6.Shiboken,PySide6.QtCore, orPySide6.QtGuiamong the extension modules.Worker threads listed in the backtrace (
ThreadPoolExecutorworkers,TileProviderthreads,TileCacheperiodic cleaners).
Prevention Checklist:
Never use session-scoped QApplication fixtures. Use
scope="function"so the fixture’s lifecycle is limited. Even though the singleton survives function scope, using the correct scope signals intent and prevents accidental dependencies.Place QApplication tests last in the test order. Pytest runs files alphabetically. Name GUI-dependent test files so they sort after pure-data tests (e.g.
test_home_point_*aftertest_converter_*). This limits the number of non-GUI tests that run with a liveQApplication.Use PIL for tile I/O whenever possible. The
Tile.save()method now convertsQImage→ PILImageand writes via Pillow rather than callingQImage.save(). Any new tile I/O code should follow this pattern.Patch QApplication away in thread-pool tests. Unit tests that validate
ThreadPoolExecutorbehaviour in the Tiler must ensure noQApplicationis present:@pytest.fixture(autouse=True) def _no_qapp(self): with patch("PySide6.QtWidgets.QApplication.instance", return_value=None): yield
Always shut down TileManager in fixture teardown. Every fixture that calls
tilemanager.init()MUST calltilemanager.shutdown()in its teardown. This stops backgroundTileProviderandTileCachethreads before the next test:@pytest.fixture def initialized_tilemanager(temp_tilestore): tilemanager.init(total_cache_size=100, auto_cleanup=False) yield tilemanager.purge() tilemanager.shutdown()
Register atexit thread cleanup. The
tilemanager.init()function now registers anatexithandler that calls_shutdown_threads(). This ensures background threads are joined before the interpreter tears down Qt singletons, preventing exit-time segfaults even when tests forget to shut down.
Performance Tips¶
Parallel Test Execution:
# Only if tests are independent
pytest -n auto
Selective Test Running:
# Run specific category
pytest test_tiling_pipeline.py
# Skip slow tests
pytest -m "not slow"
Reduce Resource Usage:
# Use smaller test images
img = Image.new('RGB', (256, 256)) # Not (4096, 4096)
# Limit cache size
cache = TileCache(maxsize=10) # Not 1000
Quick Reference¶
Running Tests¶
# All integration tests
pytest test/integrationtest
# Specific file
pytest test_tiling_pipeline.py
# Verbose
pytest -v -s
# With coverage
pytest --cov=zooui
# GUI tests (manual)
python test/integrationtest/guiintegration/main.py
Essential Patterns¶
# Pipeline test
def test_pipeline(self, fixtures):
input -> process -> verify -> store -> retrieve
# Concurrency test
def test_concurrent(self):
spawn threads -> run operations -> join -> verify
# Cache test
def test_cache(self, cache):
miss -> load -> hit -> evict -> verify
# File system test
def test_files(self, temp_tilestore):
create -> verify exists -> read -> verify content
Resources¶
Documentation¶
Pytest: https://docs.pytest.org/
PIL/Pillow: https://pillow.readthedocs.io/
ZooUI-Specific¶
Unit Testing Guide - Unit testing guide
Tiling System - Understanding tile system
Object System - Understanding object architecture
Window System - Understanding UI components
Example Test Files¶
Reference these for patterns:
test/integrationtest/test_tiling_pipeline.py- Complete pipeline testingtest/integrationtest/test_converter_pipeline.py- Converter and tiling pipelinetest/integrationtest/test_concurrent_access.py- Thread safety patternstest/integrationtest/test_cache_provider_integration.py- Component interactiontest/integrationtest/test_tilemanager_integration.py- Coordination testingtest/integrationtest/guiintegration/- Visual verification
Conclusion¶
Integration tests validate that ZooUI components work correctly together as a complete system. When adding new features:
Write integration tests for complete workflows
Test with real dependencies (images, files, threads)
Verify file system state
Test concurrent scenarios
Use appropriate fixtures for isolation
Allow proper timing for async operations
Verify both success and failure paths
Clean up resources properly
Well-designed integration tests catch bugs that unit tests miss, validate system behavior, and ensure components interact correctly.