CI/CD and Testing for Media Pipelines
Within the Pipeline Automation & Batch Processing layer, this is the discipline that keeps a fleet of transcode workers from silently corrupting a back catalogue. Media code is uniquely hostile to testing: the units of work are large binary files, the core transform is an external process (FFmpeg) whose output shifts between builds, and “correct” is rarely bit-exact. A refactor that reorders two filters, a base-image bump that swaps the AAC encoder, or a stray -movflags change can pass every Python assertion you own and still ship audio that measures 2 LUFS hot or video that will not seek on Safari. The job of CI/CD here is to make those regressions fail a build instead of a listener’s playback, and to get a validated worker into production without a big-bang deploy that reprocesses ten thousand episodes wrong.
This page lays out the full pipeline: fast unit tests on tiny fixtures, real containerized FFmpeg integration tests, golden-file and contract gates that catch output drift, task-level tests for Celery and Airflow, GPU-less CI strategy, and a worker rollout that fails safe. It sits alongside dockerizing media processing containers, which builds the image this pipeline tests and ships.
Prerequisites & Environment
The single most important rule is that the FFmpeg your tests run against must be the exact FFmpeg your workers run in production. Loudness, x264 presets, and container muxing all vary between builds, so an unpinned apt-get install ffmpeg in CI will produce golden files that never match a differently-built worker.
- Python: 3.10+ (the examples use structural pattern matching and
tomllibfor config). - pytest: 8.x, with
pytest-xdistfor parallel test sharding. The official pytest documentation is the reference for fixtures, markers, and parametrization used throughout. - FFmpeg: pinned to a single build — the same one baked into the worker image. Verify the exact version and configuration in CI with
ffmpeg -versionandffmpeg -buildconf, and fail the job if either drifts from a recorded hash. - Python libraries (pinned):
# pytest==8.2.2 # test runner, fixtures, parametrization
# pytest-xdist==3.6.1 # -n auto sharding for slow FFmpeg cases
# numpy==1.26.4 # PSNR/SSIM and PCM comparison math
# soundfile==0.12.1 # decode audio to PCM for loudness/hash checks
# jsonschema==4.22.0 # validate ffprobe JSON against the output contract
- CI runners: ordinary CPU runners. Deliberately keep hardware-encode (
h264_nvenc,hevc_nvenc) out of the required test path so CI stays GPU-less and reproducible; test the softwarelibx264/libx265path and treat NVENC as a separate, hardware-gated job. - Environment variables the CI job reads:
CI_FFMPEG_BIN=/usr/local/bin/ffmpeg # the pinned binary, not the distro one
CI_FFMPEG_SHA=8f3a...c1 # expected sha256 of the ffmpeg binary
FIXTURE_DIR=tests/fixtures/media # tiny, version-controlled sample media
GOLDEN_DIR=tests/golden # reference outputs + their metadata
GOLDEN_UPDATE=0 # set to 1 only in a deliberate refresh job
PSNR_MIN=42.0 # video regression floor in dB
LUFS_TOLERANCE=0.5 # audio loudness drift ceiling in LUFS
Keep GOLDEN_UPDATE=0 everywhere except a dedicated, reviewed refresh job. A golden test that quietly regenerates its own reference on failure tests nothing.
Architecture & Queue Topology
The pipeline is a linear gauntlet of increasingly expensive stages, each of which must pass before the next runs. Cheap, fast checks (lint, unit) fail early and give sub-second feedback; the expensive stages (real FFmpeg, golden compare) only run once the cheap ones are green. The output of the whole thing is a single tested worker image that a deployment stage rolls out gradually.
The topology matters for cost and signal. Because stages 3 and 4 are the only ones that run FFmpeg, they are where minutes and flakiness accumulate — so they run last and guard the deploy directly. A failure there never publishes an image, which means the deploy stage can trust that any image reaching it has already cleared every contract and golden check.
Step-by-Step Implementation
Each step ends with a verification command you can run locally before pushing.
1. Build a pinned CI image with FFmpeg and pytest
Bake the exact FFmpeg build and the test dependencies into an image, and assert its identity at job start. This is the same base your worker uses, so tests and production share one binary.
# tests run in the SAME base as the worker — only test deps differ
FROM ghcr.io/yourorg/media-worker-base:ffmpeg-7.0.1
COPY requirements-test.txt .
RUN pip install --no-cache-dir -r requirements-test.txt
# tests/conftest.py — fail fast if the binary is not the pinned one
# python 3.10+
import hashlib, os, pathlib, subprocess, pytest
@pytest.fixture(scope="session", autouse=True)
def assert_pinned_ffmpeg():
binp = os.environ["CI_FFMPEG_BIN"]
digest = hashlib.sha256(pathlib.Path(binp).read_bytes()).hexdigest()
assert digest == os.environ["CI_FFMPEG_SHA"], "FFmpeg build drifted from pin"
ver = subprocess.run([binp, "-version"], capture_output=True, text=True).stdout
assert "ffmpeg version 7.0.1" in ver
Verify:
docker run --rm ghcr.io/yourorg/media-worker-base:ffmpeg-7.0.1 ffmpeg -buildconf | head -5
2. Unit-test transforms on tiny fixture media
Unit tests must be fast, so fixtures must be tiny — synthesize sub-second clips with FFmpeg’s lavfi sources rather than committing large binaries. Test the pure logic (filtergraph construction, argument building, metadata parsing) without running long encodes.
# python 3.10+ — generate a 0.5s test tone + test pattern, deterministically
import subprocess, pathlib
def make_fixture(path: str) -> str:
subprocess.run([
"ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=0.5",
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=15:duration=0.5",
"-shortest", "-c:v", "libx264", "-preset", "ultrafast",
"-c:a", "aac", path,
], check=True)
return path
def build_transcode_args(src: str, dst: str, crf: int) -> list[str]:
# pure function — this is what the unit test asserts on
return ["ffmpeg", "-y", "-i", src, "-c:v", "libx264",
"-crf", str(crf), "-c:a", "copy", dst]
def test_crf_lands_in_args():
args = build_transcode_args("in.mp4", "out.mp4", crf=20)
assert "-crf" in args and args[args.index("-crf") + 1] == "20"
Verify:
pytest tests/unit -q -x
3. Run real FFmpeg integration tests in the container
The unit layer never proves FFmpeg actually produces valid output. Integration tests do exactly one expensive thing: run the real transform on a fixture and inspect the result with ffprobe. Mark them so they can be sharded and skipped locally.
# python 3.10+
import json, subprocess, pytest
pytestmark = pytest.mark.integration
def probe(path: str) -> dict:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_streams", "-show_format",
"-of", "json", path],
capture_output=True, text=True, check=True,
)
return json.loads(out.stdout)
def test_transcode_produces_playable_h264(tmp_path):
src = make_fixture(str(tmp_path / "in.mp4"))
dst = str(tmp_path / "out.mp4")
subprocess.run(build_transcode_args(src, dst, crf=20), check=True)
v = next(s for s in probe(dst)["streams"] if s["codec_type"] == "video")
assert v["codec_name"] == "h264"
assert v["pix_fmt"] == "yuv420p" # Safari/QuickTime compatibility
Verify:
pytest tests/integration -q -m integration -n auto
Deep, contract-level assertions on the output stream — sample rate, container brand, loudness — get their own focused workflow in integration testing codec contracts.
4. Gate on golden files and codec contracts
For transforms whose output should stay stable release-to-release, compare against a committed reference. Bit-exactness is usually impossible, so compare structured metadata and perceptual metrics instead. The full technique — PSNR/SSIM thresholds, PCM hashing, and updating fixtures deliberately — is covered in golden-file testing for transcode output.
# python 3.10+ — a lightweight metadata golden gate
def golden_metadata(path: str) -> dict:
s = next(x for x in probe(path)["streams"] if x["codec_type"] == "video")
return {"codec": s["codec_name"], "pix_fmt": s["pix_fmt"],
"width": s["width"], "height": s["height"]}
def test_matches_golden(tmp_path):
dst = str(tmp_path / "out.mp4")
subprocess.run(build_transcode_args(make_fixture(str(tmp_path/"in.mp4")), dst, 20), check=True)
expected = json.loads(pathlib.Path("tests/golden/720p.json").read_text())
assert golden_metadata(dst) == expected
Verify:
pytest tests/golden -q
5. Test Celery and Airflow tasks without a full cluster
Task orchestration code needs its own tests, separate from the media transform. Run Celery tasks eagerly and stub the heavy encode so you test routing, retries, and serialization — not FFmpeg again.
# celery task test — eager mode, transform stubbed
from unittest.mock import patch
def test_transcode_task_retries_on_transient(celery_app):
celery_app.conf.task_always_eager = True
with patch("worker.run_ffmpeg", side_effect=[TimeoutError, {"ok": True}]):
result = transcode_task.apply(args=["s3://in.mp4"]).get()
assert result["ok"] is True
For Airflow, validate DAG integrity (no import errors, no cycles) as a unit test, and exercise a single task with airflow tasks test against a fixture. Broader orchestration lives under orchestrating pipelines with Airflow.
Verify:
pytest tests/tasks -q && python -c "from dags.ingest import dag; assert not dag.test_cycle()"
6. Package the image and deploy workers safely
Only a fully-green pipeline publishes an image, tagged with the commit SHA. Roll it out gradually so a subtle output regression that slipped every gate still cannot corrupt the whole catalogue at once.
# canary: route 5% of the transcode queue to the new image first
deploy:
strategy: canary
steps:
- setWeight: 5 # 5% of jobs to :sha-new
- pause: { duration: 30m } # watch output-quality metrics
- setWeight: 50
- pause: { duration: 30m }
- setWeight: 100 # blue/green cutover, old workers drain then retire
Gate promotion on live signal, not just time: watch the same loudness-drift and PSNR metrics from CI, but measured on real output. If the canary’s out-of-spec rate rises, halt and roll back to the previous image — no queued job is lost because the old workers drain first.
Verify:
kubectl argo rollouts get rollout media-worker --watch
Data Contracts
The test matrix below is the contract between a pipeline change and the outputs it is allowed to produce. Every row is an assertion a build must satisfy; a change that violates one fails CI rather than shipping.
| Test layer | Scope | Runs FFmpeg? | Fixture | Gate / assertion | Typical time |
|---|---|---|---|---|---|
| Lint / type | Argument builders, config | No | none | ruff, mypy clean |
< 5 s |
| Unit | Filtergraph & arg logic | No | in-memory / synthesized | pure-function equality | < 10 s |
| Integration | Real transcode validity | Yes (pinned) | 0.5 s lavfi clip |
ffprobe codec/pix_fmt |
1–3 min |
| Contract | Output codec/container/SR/loudness | Yes | small real clip | jsonschema on ffprobe + LUFS ±0.5 |
1–2 min |
| Golden | Output stability vs reference | Yes | committed reference | PSNR ≥ 42 dB, SSIM ≥ 0.98, PCM hash | 2–4 min |
| Task | Celery/Airflow routing & retry | No (stubbed) | mocked transform | eager-mode result + DAG integrity | < 15 s |
| Smoke (post-deploy) | Canary real output | Yes | one real episode | out-of-spec rate < 0.1% | rolling |
The PSNR_MIN and LUFS_TOLERANCE values are the load-bearing numbers: 42 dB PSNR tolerates encoder non-determinism while still catching a wrong -crf, and ±0.5 LUFS matches the delivery tolerance enforced by audio codec normalization workflows upstream.
Resilience Patterns
- Isolate flakiness. Real FFmpeg tests are the flaky ones. Mark them
@pytest.mark.integration, shard with-n auto, and never gate a fast unit job on them — a slow encode timing out should not block a docs typo. - Determinism over exactness. Force deterministic inputs (
-preset ultrafastis fine for validity tests; fix frame rate and duration) and assert on properties, not byte equality. Encoder builds change bytes without changing correctness. - Quarantine, don’t disable. A flaky integration test moves to a
quarantinemarker that still runs and reports but does not block merge, with a ticket to fix — not a@skipthat rots. - Fail-closed golden updates. Regenerating golden fixtures happens only in a dedicated job with
GOLDEN_UPDATE=1, and the resulting diff is code-reviewed like any change. A test that can rewrite its own expectation on red is not a test. - Rollback on live signal. The canary halts and reverts on real out-of-spec rate, so even a regression that evaded every gate is contained to a few percent of jobs. Failed jobs from a reverted canary flow through retry logic and dead-letter queues for replay against the known-good image.
Observability & Debugging
Instrument the pipeline itself, not just the workers it ships.
- CI metrics to export: per-stage duration (histogram, labeled by stage), integration-test flake rate (reruns to green ÷ runs), golden-diff magnitude (the PSNR/LUFS delta even on pass, so you see drift trending toward the threshold), and image build size.
- Structured job fields:
commit_sha,ffmpeg_build,stage,fixture_id,psnr_db,lufs_delta,exit_code,duration_ms. Emittingpsnr_dbon passing runs turns a future failure into “it was creeping from 46 to 43 over five commits,” not a surprise. - Reproduce failures locally: because CI runs the same pinned image, any red integration test reproduces with
docker runand the printedffmpegcommand. Always log the exact command line the test built. - Debug golden misses by attaching the failing candidate and the reference as CI artifacts, plus the
ffprobe -show_streamsdiff, so a reviewer sees which field moved without re-running anything. - Post-deploy, the canary’s smoke metrics reuse the worker’s own instrumentation; malformed inputs that surface only in production route through media validation and error routing rather than failing the deploy.
Performance Tuning
- Shard the slow layer.
pytest-xdist -n autoon the integration and golden suites turns a 12-minute serial run into a 3-minute parallel one on a 4-core runner. Keep fixtures independent so shards never contend on a shared file. - Cache the base image. The FFmpeg base rarely changes; cache it in the registry and only rebuild the thin test layer per commit. This is the single biggest CI-time win.
- Right-size fixtures. A 0.5-second
lavficlip validates a transcode as well as a 3-minute one for most assertions. Reserve real multi-minute clips for the handful of golden tests that need perceptual metrics. - Skip redundant re-encodes. Generate each fixture once per session (
scope="session") and reuse it across tests rather than re-synthesizing per test function. - Keep GPU out of required CI. Software
libx264on CPU runners is reproducible and cheap; NVENC output differs by driver and card, so test it in a separate hardware-gated job that does not block merges.
Frequently Asked Questions
Why not just mock FFmpeg entirely and keep CI fast?
Because the bugs that actually ship live in FFmpeg's behavior, not in your argument-building code. Mocking proves you called FFmpeg with certain flags; it never proves those flags produce a playable file with the right pixel format, sample rate, or loudness. Keep a fast mocked layer for routing and logic, but always run at least one real transcode against a tiny fixture so the output is actually inspected with ffprobe.
How do I test a transcode when the output is never bit-identical between runs?
Stop comparing bytes and compare properties. Assert structured ffprobe metadata for exact fields (codec, pixel format, dimensions, container brand), use PSNR and SSIM thresholds for the video signal, and compare decoded PCM by loudness and hash for audio. That tolerates encoder non-determinism while still failing on a wrong CRF or a swapped encoder. The dedicated golden-file page covers the thresholds in detail.
Do I need a GPU in CI to test hardware encoding?
No, and you should avoid depending on one for required checks. Hardware encoders like h264_nvenc produce output that varies by driver version and GPU model, which makes golden comparison brittle and pins your CI to specific runners. Test the software libx264/libx265 path as the reproducible baseline, and run any NVENC-specific validation as a separate job gated on hardware availability, not as a blocker for every merge.
How do I deploy a new worker image without risking the whole catalogue?
Roll it out incrementally and gate promotion on real output quality, not elapsed time. Send about 5% of the queue to the new image, watch the same loudness-drift and PSNR metrics you enforce in CI but measured on live output, and only widen the weight if the out-of-spec rate stays flat. Because the old workers drain before retiring, a bad canary reverts with zero lost jobs — the failures replay against the previous image.
Where do Celery and Airflow tests fit versus FFmpeg tests?
They test orthogonal things and should not be entangled. Task tests run in eager mode with the media transform stubbed, so they exercise routing, retries, serialization, and DAG integrity in milliseconds. FFmpeg tests run the real binary on a fixture and inspect output. Mixing them makes a slow, flaky suite that fails for two unrelated reasons; keep the transform mocked in task tests and validate the encode separately in the integration and golden layers.
Related
- Pipeline Automation & Batch Processing — the parent layer this testing discipline serves.
- Integration testing codec contracts — assert that transcode output satisfies a codec, container, sample-rate, and loudness contract.
- Golden-file testing for transcode output — reference-output comparison when bit-exactness is impossible.
- Dockerizing media processing containers — builds the pinned worker image this pipeline tests and ships.
- Media validation and error routing — catches the malformed inputs that only surface in production smoke tests.
- Audio codec normalization workflows — the ±0.5 LUFS delivery tolerance the contract tests enforce.