You inherited a surveillance application that starts, displays a camera tile, and has no tests proving what happens to the video. I would not begin by replacing the cameras or choosing another video-management product, because neither tells you whether the existing code loses frames, exposes recordings, or survives a reconnect. Start with a synthetic feed and make one path observable from ingest to deletion.
A reproducible camera is a better first dependency than a real one
Before running the application, write down what starts it: the runtime version, environment variables, database migrations, storage location, and command that launches each worker. Save the current dependency lockfile and commit hash. If startup depends on a production credential, stop and create a local substitute; a QA run cannot be safely repeated if it requires access to live footage. Use Docker Compose v2 where the repository already supports it, but do not containerize a working host setup merely to make the diagram look tidy.
Trace one video path through the code. Find the RTSP URL parser, the process or library that decodes frames, the recording writer, the index that makes recordings searchable, and the HTTP endpoint that returns them. Name the identifiers carried between those steps: camera ID, stream timestamp, recording ID, and requester ID. This small map gives your first test a precise question: does a known frame entering one camera emerge in the correct recording and remain unavailable to another user?
Create a synthetic source before connecting hardware. FFmpeg with libx264 support, Docker, and a free local RTSP port are the prerequisites for this example. Run the following in a shell; the last command stays in the foreground, so use a second terminal for probes and the application.
mkdir -p fixtures ffmpeg -hide_banner -loglevel error -y -f lavfi \ -i "testsrc2=size=640x360:rate=10" -t 30 \ -c:v libx264 -preset ultrafast -pix_fmt yuv420p \ -g 20 fixtures/camera-a.mp4 docker run -d --name qa-rtsp -p 127.0.0.1:8554:8554 \ bluenviron/mediamtx:1.9.3 ffmpeg -hide_banner -loglevel warning -re -stream_loop -1 \ -i fixtures/camera-a.mp4 -c copy -f rtsp \ -rtsp_transport tcp rtsp://127.0.0.1:8554/cam-a
The 640 × 360 resolution and 10 fps rate are chosen fixture settings, not claims about deployed cameras; adjust them only after the basic path works. The 30-second duration makes the pattern repeat, which helps expose gaps at the loop boundary. Point a host-running application at rtsp://127.0.0.1:8554/cam-a. If the application runs in a container, put it on the same Docker network as MediaMTX and use the container name instead; 127.0.0.1 inside that container refers to itself.
There are two sensible starting sources. A recorded FFmpeg fixture wins for repeatable assertions and costs little setup, but it cannot expose camera-specific authentication or transport faults. An ONVIF-discovered physical camera wins when you need to verify device negotiation and real encoder behavior, but it costs hardware access, stable credentials, and control over a source that can change between runs. Choose the fixture first because a failure you can replay is easier to locate than one you can only watch.
Defer a product shortlist such as Top Surveillance System Software for Smarter Security until this path has a baseline, because a comparison of products cannot identify which inherited component drops a recording. Keep the existing application as the system under test, even if its interface is dated.
A visible tile does not prove that ingest works
Probe the local publisher independently with ffprobe -rtsp_transport tcp against the RTSP URL, and record the reported codec, dimensions, and frame rate. Those probe results are measurements from your environment; do not copy the fixture command into a test assertion and call it observed behavior. Then register cam-a through the application’s normal configuration path, apply its database migrations, and start only the services needed to ingest, store, and retrieve its recording. A tile can display a buffered image while the writer has stopped, so require a new recording ID and a readable output file or object as evidence.
Make the first automated test narrow. With pytest 8.x or the repository’s existing test runner, start from empty local storage, register the source, wait for a recording, request its metadata, and download it as an authorized test user. Use ffprobe on the downloaded bytes to verify that the result is decodable; check that its camera ID matches the registered source and that its timestamps advance. Record the observed time between registering the source and retrieving the first recording. Set an initial 20-second timeout as a value to tune from repeated local runs, not as a performance promise.
Inspect how the application handles clocks before asserting exact wall times. RTSP carries media over RTP, and RTP timestamps are not automatically Unix timestamps; the application must associate them with a clock or record its own receipt time. Run chrony or another NTP client on the test host if the code compares events across processes. Assert ordering and a documented tolerance rather than exact equality, because scheduling and buffering can move receipt times without changing frame order. Include the source timestamp, receipt timestamp, and camera ID in the failure output so a failed assertion points to a boundary rather than a generic timeout.
Next, stop the foreground FFmpeg publisher and start it again while leaving the application running. Record the measured gap between the last pre-stop recording and the first valid post-restart recording; that number reveals recovery behavior more honestly than a green “camera online” flag. Check whether the worker reconnects without a process restart, whether it produces duplicate recording IDs, and whether the interface distinguishes stale video from live video. A 5-second stale-image threshold can serve as an initial test setting, but tune it to the application’s buffering policy and the intended operator experience.
I would not add an end-to-end browser suite yet, because a browser assertion that a tile is visible cannot isolate a failure in RTSP ingest, recording, indexing, or playback. Add the browser check after the ingest test can say which boundary failed.
A recording is not correct unless its access decision is testable
Now follow one recording from bytes to response. For local runs, use the storage interface the code already implements—filesystem, PostgreSQL 16 metadata, or an S3-compatible store such as MinIO—rather than quietly swapping architectures. Give each run a fresh namespace and delete it afterward. That isolation matters because a recording left by a previous run can make a broken writer appear healthy.
Create two test identities: an operator permitted to view cam-a and a user permitted to view only cam-b. Request the same recording ID with both identities. The first request must return decodable media; the second must be denied without revealing a signed object URL or a cached thumbnail. Test the list endpoint as well as direct download, because hiding a recording from search does not protect an endpoint that accepts its ID. If the code uses JSON Web Tokens, verify expiration and audience against the configured issuer; a signature alone does not establish that the token was meant for this service.
Check the route through the storage boundary. If the application issues S3 presigned URLs, capture the returned URL in the test and verify its expiry and object scope. If it proxies bytes, verify that its authorization middleware runs before opening the object. TLS protects video in transit but does not repair a missing object-level check, so test authorization with real requests rather than treating HTTPS configuration as proof of access control.
Retention needs an executable assertion too. Set a short retention period in the test configuration, create a recording, advance the application’s injectable clock if it has one, and run the deletion job. If there is no injectable clock, isolate the job’s age calculation in a smaller test instead of making the suite sleep for hours. Verify removal from both the index and the underlying storage, then request the old ID again. A missing search row with a still-downloadable object is a failed deletion.
Log a correlation ID across registration, ingest, storage, and playback; OpenTelemetry trace IDs are useful if instrumentation already exists, but a consistent structured log field is enough to begin. Keep credentials and full RTSP URLs out of those logs, because replayable test evidence should not become a source of secrets. The Surveillance System Software Development Guide may help identify intended components, but the inherited code’s actual authorization path should determine your assertions.
Deliberate failures make the local setup credible
Once the successful path and access checks pass, break one dependency at a time. Stop MediaMTX while ingest is active, then restart it; disable the storage service during a write; and make the metadata database unavailable during recording finalization. For each interruption, capture the worker state, error message, retry behavior, and result after recovery. Do not declare success merely because the process stays up, because silent loss can be worse for an operator than a visible failure.
Give every test run a small evidence bundle: application commit, fixture checksum, effective non-secret configuration, service versions, probe output, recording ID, request status codes, and relevant logs. This costs some disk space, but it makes an intermittent failure reproducible without preserving sensitive camera footage. Track dropped frames or recording gaps as measured quantities from the run, not estimates inferred from how smooth playback looked.
Put these checks into CI in stages. Run parser and authorization unit tests on each change, run the synthetic RTSP path where Docker is available, and reserve the physical ONVIF camera for a controlled environment. This split keeps the fast checks deterministic while still exercising device behavior that a file cannot reproduce. If the CI runner lacks the permissions to start Docker, report the RTSP job as unrun rather than replacing it with a mock and retaining the same job name.
Begin today by locating the application’s camera registration command and making it accept the local cam-a URL. Save one decodable recording and its ID before writing a larger test suite. If that attempt fails, keep the fixture, configuration, and logs together: the first reproducible failure is a more useful inheritance document than a reassuring screenshot of the interface.


