-
-
Notifications
You must be signed in to change notification settings - Fork 450
Testing Docker in Docker Mode
Complete guide to running CWA tests inside Docker containers (Docker-in-Docker scenarios).
- The Problem
- The Solution
- Quick Usage
- How It Works
- VolumeHelper API
- Architecture Details
- Test Results
- Limitations
- Troubleshooting
When running CWA integration tests inside a Docker container (like a dev container), traditional bind mounts fail.
┌─────────────────────────────────────┐
│ Dev Container (where tests run) │
│ │
│ Test creates bind mount: │
│ /tmp/pytest-123/ingest → │
│ test container:/cwa-book-ingest │
│ │
│ ❌ Problem: Docker daemon runs on │
│ the HOST, not in dev container │
│ /tmp/pytest-123 doesn't exist │
│ on host filesystem! │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Host Machine (Docker daemon here) │
│ │
│ Docker daemon looks for: │
│ /tmp/pytest-123/ingest │
│ │
│ ❌ Path doesn't exist on host! │
│ ✅ Path only exists in dev │
│ container's filesystem │
└─────────────────────────────────────┘
Result: Test container sees empty directories, tests fail.
Use Docker volumes instead of bind mounts. Docker manages volumes on the host, making them accessible to all containers.
┌─────────────────────────────────────┐
│ Dev Container (where tests run) │
│ │
│ Test creates Docker volume: │
│ docker volume create cwa_test_xxx │
│ │
│ ✅ Volume managed by Docker daemon │
│ on the host │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Host Machine (Docker daemon here) │
│ │
│ Docker daemon creates volume: │
│ /var/lib/docker/volumes/cwa_test_xxx│
│ │
│ ✅ Volume exists on host! │
│ ✅ Can mount into any container │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Test Container │
│ │
│ Volume mounted: │
│ cwa_test_xxx → /cwa-book-ingest │
│ │
│ ✅ Files are accessible! │
└─────────────────────────────────────┘
./run_tests.shThe script auto-detects if you're in a container and uses the right mode.
In a dev container, you'll see:
Environment: docker
Default mode: dind (Docker volume mode)
# Set environment variable
export USE_DOCKER_VOLUMES=true
# Run tests
pytest tests/integration/ -vOr as a one-liner:
USE_DOCKER_VOLUMES=true pytest tests/integration/ -vAdd to your shell profile (.bashrc, .zshrc, etc.):
# ~/.bashrc or ~/.zshrc
export USE_DOCKER_VOLUMES=trueThen tests always use volume mode:
pytest tests/integration/ -vtests/conftest.py checks for the environment variable:
USE_DOCKER_VOLUMES = os.getenv('USE_DOCKER_VOLUMES', 'false').lower() == 'true'
if USE_DOCKER_VOLUMES:
# Import Docker volume fixtures
from tests.conftest_volumes import *When USE_DOCKER_VOLUMES=true, fixtures from conftest_volumes.py override the default ones:
| Fixture | Bind Mount Mode | Docker Volume Mode |
|---|---|---|
test_volumes |
Returns Path objects | Returns VolumeHelper objects |
cwa_container |
Uses bind mounts | Uses volume mounts |
ingest_folder |
Path to temp dir | VolumeHelper for volume |
library_folder |
Path to temp dir | VolumeHelper for volume |
Provides file operations using docker cp:
# Copy file into volume
ingest_folder.copy_to(local_file_path)
# Copy file from volume
library_folder.copy_from("metadata.db", local_dest)
# Check file existence
if ingest_folder.file_exists("book.epub"):
...
# List files
files = library_folder.list_files("*.epub")Container readiness detection via log polling:
# Old way: Blind 60-second wait
time.sleep(60)
# New way: Poll logs until ready (~12 seconds)
while not container_ready:
logs = container.logs().decode('utf-8')
if 'Starting Calibre Web' in logs:
break
time.sleep(1)Result: Tests start ~48 seconds faster!
The VolumeHelper class provides a Path-like interface for Docker volumes.
# Copy files to volume
volume.copy_to(src_path, dest_name=None)
volume.copy_to("/local/book.epub") # Copies to root of volume
volume.copy_to("/local/book.epub", "renamed.epub") # Rename on copy
# Copy files from volume
volume.copy_from(src_name, dest_path)
volume.copy_from("metadata.db", "/tmp/metadata.db")
# Check existence
volume.file_exists(filename) → bool
volume.is_dir(path) → bool# Create subdirectories
subfolder = volume / "subfolder"
subfolder.mkdir()
# List files
files = volume.list_files() # All files
files = volume.list_files("*.epub") # Pattern match
# Iterate directory
for item in volume.iterdir():
print(item.name)
# Glob patterns
epub_files = volume.glob("**/*.epub")# Path concatenation
subfolder = volume / "subfolder"
file_path = subfolder / "file.epub"
# VolumePath objects
vol_path = volume / "book.epub"
vol_path.exists() → bool
vol_path.is_dir() → bool
vol_path.name → "book.epub"
vol_path.write_text("content")# Extract database to local temp file
db_path = volume.read_to_temp("metadata.db")
conn = sqlite3.connect(db_path)
# Use database...
# File automatically cleaned upAvailable in test code:
# Universal copy (works in both modes)
from tests.conftest import volume_copy
volume_copy(src, dest) # Auto-detects mode
# Database access (works in both modes)
from tests.conftest import get_db_path
db_path = get_db_path(folder_fixture, "cwa.db")
conn = sqlite3.connect(db_path)# tests/conftest.py - Main fixtures (bind mount mode)
@pytest.fixture
def test_volumes(tmp_path):
"""Standard mode: temp directories"""
ingest = tmp_path / "ingest"
library = tmp_path / "library"
return ingest, library
# tests/conftest_volumes.py - Volume mode
@pytest.fixture
def test_volumes_dind():
"""DinD mode: Docker volumes"""
ingest_vol = VolumeHelper("cwa_test_ingest_xxx")
library_vol = VolumeHelper("cwa_test_library_xxx")
yield ingest_vol, library_vol
# Cleanup volumes
ingest_vol.cleanup()
library_vol.cleanup()# tests/conftest.py
USE_DOCKER_VOLUMES = os.getenv('USE_DOCKER_VOLUMES', 'false').lower() == 'true'
if USE_DOCKER_VOLUMES:
# Override fixtures with volume versions
from tests.conftest_volumes import (
test_volumes as test_volumes_dind,
cwa_container as cwa_container_dind,
# ... other overrides
)
# Re-export with original names
test_volumes = test_volumes_dind
cwa_container = cwa_container_dind# Helper function for universal copy
def volume_copy(src, dest):
"""Copy file in either mode"""
if USE_DOCKER_VOLUMES:
# dest is VolumeHelper
if isinstance(src, (str, Path)):
dest.copy_to(src)
else:
# src is VolumeHelper
src.copy_from(dest)
else:
# Standard shutil copy
shutil.copy2(src, dest)USE_DOCKER_VOLUMES=true pytest tests/integration/ -vOutput:
tests/integration/test_ingest_pipeline.py::test_ingest_epub_already_target_format PASSED
tests/integration/test_ingest_pipeline.py::test_ingest_empty_file PASSED
tests/integration/test_ingest_pipeline.py::test_ingest_corrupted_file PASSED
tests/integration/test_ingest_pipeline.py::test_txt_to_epub_conversion PASSED
tests/integration/test_ingest_pipeline.py::test_filename_truncation_at_150_chars PASSED
tests/integration/test_ingest_pipeline.py::test_book_appears_in_metadata_db PASSED
tests/integration/test_ingest_pipeline.py::test_ingest_multiple_files PASSED
tests/integration/test_ingest_pipeline.py::test_imported_files_backed_up PASSED
tests/integration/test_ingest_pipeline.py::test_ingest_international_filename PASSED
tests/integration/test_ingest_pipeline.py::test_mobi_to_epub_conversion PASSED
tests/integration/test_ingest_pipeline.py::test_conversion_failure_moves_to_failed_folder PASSED
tests/integration/test_ingest_pipeline.py::test_lock_released_after_processing PASSED
tests/integration/test_ingest_pipeline.py::test_directory_import_processes_all_files PASSED
tests/integration/test_ingest_pipeline.py::test_empty_folder_cleanup_after_processing PASSED
tests/integration/test_ingest_pipeline.py::test_ignored_formats_not_deleted PASSED
tests/integration/test_ingest_pipeline.py::test_processing_survives_multiple_files PASSED
tests/integration/test_ingest_pipeline.py::test_zero_byte_file_doesnt_crash_ingest PASSED
tests/integration/test_ingest_pipeline.py::test_cwa_db_tracks_import SKIPPED (requires config volume)
tests/integration/test_ingest_pipeline.py::test_user_drops_book_and_it_appears_in_library PASSED
tests/integration/test_ingest_pipeline.py::test_mixed_format_batch_import PASSED
19 passed, 1 skipped in 187.45s
✅ 100% of runnable tests passing!
pytest tests/integration/ -vOutput:
20 passed in 165.32s
✅ All tests passing
| Metric | Bind Mount | Docker Volume | Difference |
|---|---|---|---|
| Container startup | Variable (30-60s) | Consistent (~12s) | ✅ Faster (log polling) |
| File processing | 2-5 seconds | 2-5 seconds | Same |
| Full test suite | ~3 minutes | ~3 minutes | Same |
| Tests passing | 20/20 | 19/20 | 1 skip (expected) |
Conclusion: Docker volume mode has equivalent performance!
Test: test_cwa_db_tracks_import
Reason: Requires access to /config/cwa.db which is not mounted in test containers.
Workaround: Would need to add config volume to test setup:
container.with_volume_mapping(config_vol, "/config")Status: ⏭️ Intentionally skipped - not worth the complexity for 1 test.
Complex multi-level glob patterns are simplified in volume mode:
# Bind mount mode (works)
files = list(Path("/calibre-library").glob("**/metadata.db"))
# Volume mode (simplified)
files = [f for f in volume.list_files() if f.endswith("metadata.db")]Impact: Minimal - tests adjusted to work in both modes.
docker cp operations add slight overhead:
- Copy 1 MB file: +10-20ms
- Copy 100 MB file: +200-500ms
Impact: Negligible for tests (files are small, <10 MB).
Can't use standard Python file operations directly:
# ❌ Doesn't work in volume mode
with open(volume / "file.txt", "r") as f:
content = f.read()
# ✅ Use VolumeHelper methods
content = volume.read_to_temp("file.txt")
with open(content, "r") as f:
data = f.read()Impact: Minimal - tests use helper functions that work in both modes.
Cause: Volume wasn't created or was already deleted.
Fix: Check volume exists:
docker volume ls | grep cwa_testIf missing, test cleanup may have failed. Remove stale volumes:
docker volume prune -fCause: Volume mount failed.
Fix: Check container logs:
docker logs temp-cwa-test-suiteRecreate container with correct volume mounts.
Cause: File wasn't copied or copy failed silently.
Fix: List volume contents:
files = volume.list_files()
print(files)Check docker cp command succeeded:
docker cp file.epub volume_container:/cwa-book-ingest/
echo $? # Should be 0Cause: Multiple processes accessing SQLite in volume.
Fix: Use get_db_path() helper which extracts DB to temp file:
db_path = get_db_path(folder, "cwa.db")
conn = sqlite3.connect(db_path) # Works on local file, no lock conflictsFirst run? Docker needs to create volumes and download images.
Subsequent runs should be ~3-4 minutes for integration tests.
Still slow?
- Check Docker Desktop resources (CPU/RAM)
- Use SSD instead of HDD
- Close other containers
- Check network speed (volume creation requires Docker API calls)
Check it's set:
echo $USE_DOCKER_VOLUMES # Should show: trueSet it:
export USE_DOCKER_VOLUMES=true
pytest tests/integration/ -vOr use one-liner:
USE_DOCKER_VOLUMES=true pytest tests/integration/ -vCause: CI uses bind mount mode by default.
Fix: CI should NOT use USE_DOCKER_VOLUMES=true (runs on host, not in container).
If CI is in a container (e.g., GitHub Actions with container job):
# .github/workflows/tests.yml
- name: Run tests
env:
USE_DOCKER_VOLUMES: true # Only if running in container job
run: pytest tests/integration/ -vOverride volume names for debugging:
@pytest.fixture
def test_volumes_dind():
ingest_vol = VolumeHelper("my-custom-ingest-vol")
library_vol = VolumeHelper("my-custom-library-vol")
# ...Then inspect volumes:
docker volume inspect my-custom-ingest-vol
docker run --rm -v my-custom-ingest-vol:/data alpine ls -la /dataExplore volume contents:
# Create temp container with volume mounted
docker run --rm -it -v cwa_test_ingest_xxx:/data alpine sh
# Inside container
cd /data
ls -la
cat book.epub
exitEnable debug logging in VolumeHelper:
class VolumeHelper:
def __init__(self, volume_name, verbose=True):
self.verbose = verbose
# ...
def copy_to(self, src, dest=None):
if self.verbose:
print(f"DEBUG: Copying {src} to volume {self.volume_name}")
# ...For debugging, keep volumes after test failure:
@pytest.fixture
def test_volumes_dind():
ingest_vol = VolumeHelper("cwa_test_ingest_xxx")
library_vol = VolumeHelper("cwa_test_library_xxx")
yield ingest_vol, library_vol
# Only cleanup on success
if not hasattr(pytest, 'failed'):
ingest_vol.cleanup()
library_vol.cleanup()Then inspect volumes after failed tests.
Core files:
-
tests/conftest.py- Main fixtures + mode detection -
tests/conftest_volumes.py- Docker volume implementation -
tests/integration/test_ingest_pipeline.py- Tests using both modes
Helper functions:
-
volume_copy(src, dest)- Universal copy function -
get_db_path(folder, db_name)- Database access abstraction
Documentation:
-
DIND_MODE_COMPLETE.md- Implementation summary -
HYBRID_DOCKER_IMPLEMENTATION.md- Technical details - This guide - Usage documentation
- Testing Quick Start - Get started with tests
- Running Tests - All execution modes
- Writing Tests - Contribute tests
- Implementation Status - Progress tracking
Questions?
- Discord: https://discord.gg/EjgSeek94R
- GitHub Issues: Tag with
testinglabel
Docker-in-Docker testing works perfectly! 🎉
100% of runnable tests passing with equivalent performance to bind mount mode.