Testing Camera Features When You Have No Camera
Last verified July 27, 2026 · 16 min read
A pull request retunes one smoothing constant in the knee-angle filter. CI is green, it merges, and two weeks later the support queue fills with users saying the squat counter stops at eight when they did twelve — but only when the phone is on the floor at a side-on angle. CI was green because nothing in the suite touches the camera path, and nothing touches the camera path because the build machine has no camera. The iOS Simulator has none at all. The Linux runner has no webcam pointed at anybody doing squats.
The assertion that would have caught it is one line:
XCTAssertEqual(repCount(in: "squat-side-45deg-12reps.mov"), 12)
Nothing about that assertion is difficult. What is difficult is that it is only writable if frames can enter your pipeline from somewhere other than a live capture session — and that is a decision you make on the day you write the capture code, not on the day you decide to write tests.
This is an architecture decision wearing a testing costume#
Our position, stated up front and defended below: put an injectable frame source in the pipeline on day one, before there is any pose code to test. Adding it later is not a refactor. It is a rewrite of the pipeline's contract, because a capture session does not just hand you pixels — it silently supplies five other things that your analysis code will quietly start depending on.
Timestamps. Live code reaches for the wall clock, because on a live camera the wall clock and the frame clock agree. Replay a file at decode speed and they diverge by a factor of ten. A rep counter that derives tempo from Date() deltas will read a twelve-rep fixture as twelve reps at a 0.3-second tempo, decide that is impossible, and reject them all. The test then fails for a reason that has nothing to do with the model. Timestamps must come off the frame, always, including in the live path.
Orientation. The capture connection knows the device orientation and which lens you are on. A file knows neither. If orientation is resolved deep inside your pose call rather than at the source, a front-camera fixture and a back-camera fixture cannot be distinguished, and mirroring bugs — the ones where left and right knee swap and your form feedback tells a user to fix the leg that is fine — become untestable.
Pixel format. The capture output picks one. Your fixture decoder picks another. If the analyser has an implicit format assumption, the seam is where you find out.
Backpressure. A live pipeline drops late frames to stay real-time. A test must not, or the same fixture yields a different count on a loaded runner and you have built a flaky test. The drop policy has to be a property of the source, not a hard-coded behaviour of the analyser.
End of stream. This is the one that bites hardest and it is invisible until you try. A live camera never ends. A file always does. Pipelines written against a camera routinely have no flush path, no terminal state, and no way to say "that was the last frame" — so the final rep never completes, the last partial window never closes, and the test hangs waiting for a frame that will never arrive.
Retrofitting means re-deriving all five. That is why the seam is a day-one decision. If you are still designing the capture path, the camera pose tracking guide is where the pipeline shape belongs; this page is only about the part of it you can assert on.
The seam#
Keep it small. The interface carries a frame and the frame's own metadata, nothing else, and it has a terminal signal.
struct Frame {
let buffer: CMSampleBuffer
let presentationTime: CMTime
let orientation: CGImagePropertyOrientation
}
protocol FrameSource: AnyObject {
/// Delivers frames in capture order, then calls `onFinish`.
/// A live source never calls `onFinish`. A file source always does.
func run(onFrame: @escaping (Frame) -> Void, onFinish: @escaping () -> Void)
func cancel()
}
Two implementations, and the production pipeline cannot tell them apart: CaptureFrameSource wraps your capture output's delegate callbacks, and FileFrameSource reads sample buffers out of a recorded asset. The test then instantiates the real pipeline type, not a parallel test-only one:
func testSideAngleSquatCountsTwelveReps() {
let source = FileFrameSource(url: fixture("squat-side-45deg-12reps.mov"))
let counter = RepCounter(exercise: .squat)
PosePipeline(sink: counter).drain(source) // the shipping pipeline type
XCTAssertEqual(counter.completedReps, 12)
}
The Kotlin shape is the same, and deliberately says nothing about which camera or inference library you use — that is the point of the seam:
data class TimedFrame(
val image: FrameImage, // your analyser's existing input type
val timestampNanos: Long, // read off the frame, never off the clock
val rotationDegrees: Int, // resolved here, not deep in the analyser
)
interface FrameSource {
/** Emits frames in capture order, then completes. A live source never completes. */
fun frames(): Sequence<TimedFrame>
fun close()
}
One rule that costs nothing now and saves the suite later: the live source and the file source must be constructible in the same place, from configuration. If the only way to get a file source is a compile-time flag in a test target, your instrumented tests and your on-device debug builds cannot use it, and those are exactly where you will want it.
iOS: the Simulator has no camera, and Apple names the alternative#
Apple's current AVCam sample documentation states it plainly: "Because Simulator doesn't have access to device cameras, it isn't suitable for running the app—you'll need to run it on a device." (Read 2026-07-30.) The retired iOS Simulator Guide listed "Audio and video input (camera and microphone)" among hardware the Simulator does not simulate; that page carries a retirement notice, so treat it as historical corroboration rather than current policy. Apple's page on configuring a simulated device documents what you can set — accessibility, appearance, location, audio, orientation, including audio input routing — and has no camera input control at all. The absence is not an oversight you can configure around.
So do not try to give the Simulator a camera. Feed the analysis a file instead, and Apple documents the way to do it.
VNVideoProcessor is the documented path for running a Vision request over a video file rather than a live capture session. Apple describes it as "An object that performs offline analysis of video content." You create it from a URL, attach requests with addRequest(_:processingOptions:), and call analyze(_:) over a CMTimeRange. Two documented properties make it unusually good as a test harness:
analyze(_:)is documented as synchronous — "The system executes this method synchronously, so you typically call it from a separate dispatch queue. It returns when the video processor finishes analyzing the time range." A synchronous finish is a deterministic finish, which is exactly what an assertion needs and exactly what a live capture session refuses to give you.- Frame sampling is configurable rather than incidental.
VNVideoProcessor.RequestProcessingOptionsexposescadence, withVNVideoProcessor.FrameRateCadenceandVNVideoProcessor.TimeIntervalCadence. Your fixture can be analysed at a pinned cadence instead of at whatever rate the hardware happened to deliver.
var poses: [VNHumanBodyPoseObservation] = []
let request = VNDetectHumanBodyPoseRequest { request, _ in
poses.append(contentsOf: request.results as? [VNHumanBodyPoseObservation] ?? [])
}
let processor = VNVideoProcessor(url: fixtureURL)
try processor.addRequest(request, processingOptions: VNVideoProcessor.RequestProcessingOptions())
try processor.analyze(CMTimeRange(start: .zero, duration: fixtureDuration))
// Documented as synchronous: `poses` is complete on this line.
Compile that against the SDK you actually ship before you paste it into a suite — the class, the initializer, the two methods and the options type are all Apple-documented, but signatures move. One thing you will have to change: poses is a captured var mutated from inside the completion handler, which strict concurrency checking rejects. Collect into a class box or an actor-isolated collector instead; the shape of the call is what matters here.
Two honest qualifications. First, Apple documents VNVideoProcessor as offline video analysis, not as a testing facility. Using it as a deterministic pose harness is our inference from the documented behaviour, not something Apple says. Second, if your pose logic depends on temporal continuity between frames — most rep counters do — the handler you want in the live path is VNSequenceRequestHandler, "An object that processes image-analysis requests for each frame in a sequence," which Apple documents as taking frames one by one rather than at construction. VNImageRequestHandler also accepts a CMSampleBuffer or a plain URL directly, so a single frame fixture on disk is a legitimate test input with no camera anywhere in the picture.
Write the assertion in normalized space. Apple documents that recognizedPoints(_:) returns points "in normalized coordinates (0.0 to 1.0), with the origin at the bottom-left," and that you should "ignore any recognized points with a confidence value of 0, because they're invalid." Both matter for a test. Normalized coordinates mean a re-encoded fixture at a different resolution does not invalidate your expectations. Zero-confidence filtering means a test that counts detected joints without filtering will happily pass on a fixture where the model found nothing usable — an assertion that cannot fail is not coverage. Apple's schema is nineteen named joints across head, arms, waist and legs, so "how many joints were recognised above threshold" is a real, bounded number you can assert on rather than a vibe.
One thing nobody has documented: whether Vision body-pose requests execute on the iOS Simulator at all. We found no Apple statement either way, and Apple's own sports-analysis sample asks for a physical device with an A12 or later — but attributes that requirement to the app, not to the pose request. Do not assume it works and do not assume it does not. Run one fixture through it on your Simulator and your CI image, record the result, and re-check it on every Xcode bump. If it does not run there, the fixture suite moves to an on-device test target and you plan for that in the device lab and CI page rather than discovering it the week before a release.
Android: the emulator gives you a frame source, not a subject#
The Android side is genuinely better, and it is still not what people assume it is.
Google documents three camera back-ends in the AVD hardware profile: "The Emulated and VirtualScene settings produce a software-generated image, while the Webcam setting uses your development computer's webcam to take a picture." Note the constraint on the same page — camera options "are not available for Wear OS, Android TV, or Google TV." If you ship a Wear OS companion with any camera surface, the emulator does not help you there at all.
The useful path is the virtual scene, and the menu route is exactly this: "When using the emulator with a camera app, you can import an image in PNG or JPEG format to be used within a virtual scene. To choose an image for use in a virtual scene, open the Extended controls window, select the Camera > Virtual scene images tab, and click Add image." PNG or JPEG only; Google documents no resolution or file-size limit, so do not repeat one.
That is the manual route. The reason this matters for CI is that the same capability is exposed on the emulator console as virtualscene-image {wall|table} image_path — documented as customising "the background walls or horizontal tables displayed on the virtual scene camera feed with a custom user image file" — and the console is reachable non-interactively through adb emu command. Composed, that is:
# Each half is documented; the composition is ours. Run it before you trust it.
adb emu virtualscene-image wall ./fixtures/calibration-target-01.png
adb emu nodraw on # documented for headless automation runs
Now the honest part, because this is where teams overclaim. The virtual scene puts a still image on a wall or a table inside a 3D scene. Google's own stated use case is "custom images such as QR codes for use with any camera-based app." It is not a moving human. It will not exercise a rep counter, a tempo estimator, or anything that depends on motion between frames.
What it is good for is the half of your camera code that the frame-source seam deliberately excludes from the pose tests: does the capture path start, does it survive a permission grant and a configuration change, does it deliver frames in the pixel format and rotation the analyser expects, does the preview aspect ratio match the analysis aspect ratio, does the session tear down without leaking. Those are real bugs, they are emulator-testable, they are scriptable in CI, and they are exactly the bugs that a file-based fixture suite cannot see because it bypasses capture entirely. Use both. They test disjoint things.
Webcam passthrough is the third option and we would not build CI on it: it is live, it is whatever is in front of your laptop, and it is by construction not reproducible. It is a decent local development affordance and a bad test input.
What you cannot automate, and what replaces it#
The ladder stops here, and naming the rung is the service.
A recorded fixture froze one set of physical conditions at capture time. It cannot tell you what happens under a different one. Specifically, no video-fixture suite will ever catch:
- Exposure and low light. Auto-exposure hunting in a dim garage produces motion blur that wrecks wrist and ankle keypoints. Your fixture was filmed in whatever light you filmed it in.
- Sustained frame rate. A twenty-minute session on a warm phone throttles. A ninety-second fixture on a cold CI machine does not. The frame budget discussion belongs with real-time pose estimation constraints; the point here is only that your green suite is silent about it.
- Real permission dialogs on real OS versions, with the real deny-then-open-settings path.
- Front-camera mirroring as the actual hardware reports it, which is where left/right joint swaps live.
- Old accelerators. A three-year-old device may run a different delegate path with different numerics.
What replaces automation is not nothing, and it is not "we'll notice." Our recommendation is three things. A written manual device pass per release — a fixed script, a fixed set of exercises, two lighting conditions, front and back camera, on the oldest device you support; it takes twenty minutes and it is the only thing that sees the list above. A device-lab run for the matrix you cannot hold in your hand, sized by the criteria on the device lab page linked above. And production telemetry on the pipeline rather than on the video: dropped-frame rate, delivered FPS percentiles, and the distribution of per-joint confidence. A shift in the confidence distribution after a release is the earliest camera-quality signal you will get, and it costs you no user video.
The third-party escape hatches, dated and flagged#
Everything in this section is third-party, unofficial, and moves faster than this page. All observations are from 2026-07-30.
There are commercial tools that stream a Mac camera into the iOS Simulator. software-mansion/simcam.app describes itself as a macOS menu-bar app that lets the iOS Simulator use the Mac's camera, with images, videos or generated QR codes as sources, no application code changes required, a simcamctl CLI, and separate CI licensing; the repository was small when we read it, and the copyright notice reads 2026. RocketSim advertises simulator camera support and a well-known Swift blog has an article on the topic, but both of those hosts were unreachable from our research environment, so we are not describing how they work or what they currently support. Check them yourself before adopting one.
The other commonly repeated workaround is adding a "Mac (Designed for iPad)" destination so the app runs on an Apple silicon Mac and reaches the Mac's camera. That suggestion comes from a developer reply in an Apple Developer Forums thread (July 2023), not from Apple. Apple does document that iOS apps run on Apple silicon "with no porting process," and its audit checklist warns you not to assume "a front- or rear-facing camera is present" and to discover cameras instead — but Apple nowhere frames this as a camera-testing technique. For a pose pipeline the caveat is fatal anyway: you get a laptop webcam, at laptop height, with a laptop field of view and no depth sensor. That is not the camera your users point at their squat.
One correction worth stating explicitly, because our own earlier scoping document got it wrong: do not attribute camera image injection to Firebase Test Lab. Our research could not reach firebase.google.com at all and found no evidence for the feature there; the camera-image-injection capability it did find named in product marketing belonged to a third-party device cloud, LambdaTest, at search-result level only. Test Lab's injection support is unverified as of 2026-07-30. If you need injection on a device cloud, verify it against the vendor's current documentation yourself and price the vendor lock-in, because the same fixture videos driven through your own frame source cost nothing and run everywhere.
For the cross-platform asymmetry in one artifact: react-native-vision-camera issue #1045, opened 13 May 2022, is titled "The Camera doesn't show on iOS simulator, but works on Android emulator." Any React Native camera test matrix has to treat iOS as device-only.
Tolerances, plumbing, and determinism#
This trap recurs across the whole /test cluster and the camera version is the easiest to fall into.
A tolerance wide enough that nothing fails it. "Assert the detected knee angle is within 40 degrees of the label" is not a regression test, it is a smoke test with a confident name. Set the tolerance from the observed frame-to-frame variance on a passing fixture, then verify the test actually fails by feeding it a deliberately broken build. A regression assertion you have never seen fail is an assertion you do not know works. The corpus and tolerance design itself belongs to pose detection accuracy testing and rep counting tests.
Asserting on the fake. If the test checks that FileFrameSource emitted 900 frames, it is testing your decoder. Assert on pipeline output — reps, keypoints, form verdicts — never on the plumbing you wrote to enable the test.
Assuming determinism instead of asserting it. Run each fixture twice in the same test and assert the two outputs are identical. If they are not, you have nondeterminism from frame dropping or thread scheduling, and every accuracy number downstream is noise. Catch that once, at the seam, rather than by slowly widening tolerances until the suite goes quiet.
Where this leaves you#
You cannot test a camera without a camera. You can test everything downstream of the frame, on both platforms, in CI, deterministically — and the only thing standing between you and that is whether frames arrive through an interface or through a capture session. Decide it before the pose code exists. On iOS, feed fixtures through VNVideoProcessor or a sequence handler and assert in normalized coordinates with zero-confidence points filtered out. On Android, use the emulator virtual scene for the capture plumbing and files for the analysis. Then write down, honestly, the five things your green suite still cannot see, and buy those with twenty minutes of manual device time per release.
Frequently asked questions
- Why is adding a frame-source seam later a rewrite rather than a refactor?
- Because a capture session silently supplies five things besides pixels, and your analysis code will have come to depend on all of them. Timestamps: live code reads the wall clock, and a file replayed at decode speed makes that clock lie, so a tempo-sensitive rep counter rejects every rep in the fixture. Orientation: the capture connection knows the device orientation and the lens, and a file knows neither, so left-right joint mirroring becomes untestable. Pixel format: capture picks one, your decoder picks another. Backpressure: live pipelines drop late frames to stay real-time and a test must not, or the same fixture yields different counts on a loaded runner. End of stream: a live camera never ends and a file always does, so a pipeline with no flush path never completes the final rep and the test simply hangs. Inserting an interface is easy. Re-deriving those five contracts across a shipped pipeline is not.
- Does VNVideoProcessor let me run body pose detection over a video file instead of a camera?
- Yes, and it is Apple's documented path for it. Apple describes VNVideoProcessor as an object that performs offline analysis of video content: you create it from a URL, attach requests with addRequest with processing options, and call analyze over a CMTimeRange. Two documented properties make it unusually good for tests. Apple documents analyze as synchronous, returning when the processor finishes the time range, so the results are complete on the next line and the test is deterministic. And sampling is configurable through RequestProcessingOptions cadence, with frame-rate and time-interval cadence types, so the fixture is analysed at a pinned rate rather than at whatever the hardware delivered. One qualification: Apple documents this as offline video analysis, not as a testing facility, so using it as a pose harness is our inference from documented behaviour. If your logic depends on temporal continuity, VNSequenceRequestHandler is the handler that takes frames one at a time.
- Can the Android emulator play a recorded workout video into my camera pipeline?
- No. Google documents importing a still image in PNG or JPEG format into the virtual scene through the Extended controls window, the Camera tab, Virtual scene images, and Add image, and the emulator console exposes a virtualscene-image command taking wall or table plus an image path, which makes it scriptable from CI through the adb emu shortcut. But that is a still picture pasted onto a wall or a table inside a 3D scene, and Google's own stated use case is custom images such as QR codes. It is not a moving human, so it will not exercise a rep counter or anything that depends on motion between frames. The webcam back-end passes through your development machine's webcam, which is live and by construction not reproducible. Use the emulator to prove the capture plumbing, and use files for the analysis.
- Are third-party tools that stream a Mac camera into the iOS Simulator worth depending on?
- They exist and they are all unofficial. As of 30 July 2026 we could read one of them directly: a macOS menu-bar app from Software Mansion that lets the Simulator use the Mac camera with images, videos or generated QR codes as sources, requiring no application code changes, shipping a command-line tool and carrying separate CI licensing. Two other commonly cited sources, a commercial simulator utility and a well-known Swift blog article, were unreachable from our research environment, so we describe no current behaviour for them. Our recommendation is to treat all of these as convenience for local development, not as CI infrastructure. Even the popular Designed for iPad on macOS trick, which is a developer suggestion on the Apple forums rather than Apple guidance, hands you a laptop webcam at laptop height with a laptop field of view, which is not the camera your users point at their squat.
- What breaks on real hardware after a green video-fixture run?
- Five things, and they are the reason the fixture suite is a floor rather than a ceiling. Auto-exposure hunting in a dim room produces motion blur that wrecks wrist and ankle keypoints, and your fixture was filmed in whatever light you filmed it in. Sustained frame rate drops as a phone warms over a twenty-minute session, which a ninety-second clip on a cold runner never shows. Real permission dialogs, including the deny-then-open-settings path, only exist on a real OS. Front-camera mirroring as the hardware actually reports it is where left-right joint swaps live. And older accelerators may take a different delegate path with different numerics. Our recommendation is a fixed twenty-minute manual device script per release covering two lighting conditions and both cameras on your oldest supported device, plus production telemetry on dropped-frame rate, delivered FPS percentiles and the per-joint confidence distribution, which shifts before users complain and costs you no user video.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
Next steps
Was this page useful?
Independent comparison, last reviewed July 27, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All testing · by AIFitnessAPI