Testing Strategy

Coverage targets here are pragmatic for a solo developer, not enterprise dogma. The aim is confidence where bugs hurt most — perception correctness, accessibility, and awareness-not-safety phrasing (see docs/SAFETY-FRAMING.md) — not a coverage percentage for its own sake.

Layer What How Target
Unit Pure logic: reading-order from OCR, phrasing/hedging rules, awareness thresholds, output-profile selection Swift Testing, no device, fixture perception records High coverage on the Reasoning layer specifically — this is where a subtle bug produces a confidently wrong statement
Integration Perception services against fixed inputs (OCR, detection, sound classification) Swift Testing with bundled fixtures Happy path plus key failure modes (blurry image, no text found, ambiguous object) per service
Accessibility Every screen is VoiceOver-navigable; labels/traits present; focus behaves Xcode Accessibility Inspector + manual VoiceOver checklist per screen Zero unlabeled interactive elements — a hard gate, not a percentage
AI evaluation Quality of OCR/labels/scene descriptions — synthetic fixtures now, held-out real-world photos later npm run eval (eval-harness, in-package Swift executable): renders fixtures, runs the real OCR/object services and LabelListSceneComposer, runs composed output through ReasoningOutputValidator; output is eyeballed periodically No regressions in reading accuracy; no new over-confident or hallucinated claims — content-rule rejections print as OVERCLAIM lines
End-to-end Full flows (open app → Read → capture → hear result; Awareness → fixture route → cautious alerts) XCUITest where stable; scripted-manual where camera/depth resist automation At least three E2E tests per feature — one happy path, one error path, one edge case — all passing before every beta build
Device Real-device behavior: latency, battery, thermal, Neural Engine load Instruments profiling on the actual iPhone target Responsive; no thermal throttling in a normal session; acceptable battery drain
Field Real blind users, real environments, real tasks TestFlight beta with recruited testers, structured feedback At least one or two testers completing real tasks unaided and willing to keep using it
Beta Wider TestFlight group once MVP is stable Public TestFlight, feedback channel, issue triage Stable, crash-rare, generating real usage feedback before any App Store consideration

Frameworks

Swift Testing (import Testing, @Test, #expect) for unit and integration. XCTest for end-to-end and performance — XCUITest and XCTMetric have no Swift Testing equivalent, so that is a permanent split, not a migration in progress. The two coexist in the same test target; neither excludes the other.

Decided 2026-07-16, while the repo had zero .swift files. Three reasons, all about what this costs as the suite grows:

Requires the Swift 6 toolchain, which ENVIRONMENT.md already mandates. Nothing here weakens the priority order: correct hedging first, then not crashing, then performance.

swift test cannot validate String Catalog localization. The bare SwiftPM CLI (swift build/swift test) does not compile .xcstrings files — it copies the raw catalog into a flat, uncompiled resource bundle, so Bundle.module.localizations is always ["en"] regardless of what the catalog contains. Only xcodebuild test (Xcode’s real build system, which invokes xcstringstool compile) produces the per-locale .lproj bundles a locale-dependent test needs. Any test that asserts on a non-default-locale string (e.g. SenseBridgeCoreTests/PhrasingTests, SenseBridgeCoreTests/SceneComposerTests) must be run via xcodebuild test -scheme SenseBridgeCore -destination 'platform=macOS', not swift test — CI’s package-test loop (.github/workflows/ci.yml) uses xcodebuild test for this reason. Run it from app/Packages/SenseBridgeCore, not app/: the umbrella Xcode project’s auto-generated SenseBridgeCore scheme has no shared Test action (TODO.md’s 2026-08-11 entry), so the same command fails from app/ with “Scheme SenseBridgeCore is not currently configured for the test action” — ci.yml avoids this by cd-ing into each package directory before running it.

On-device UI tests need -allowProvisioningUpdates. The UI-test runner gets its own bundle identifier (<prefix>.SenseBridgeUITests.xctrunner) that no profile covers until Xcode generates one, and xcodebuild will not create it unattended without that flag:

xcodebuild -project app/SenseBridge.xcodeproj -scheme SenseBridge \
  -destination "platform=iOS,id=$DEVICE" -allowProvisioningUpdates \
  build-for-testing

Without it the build fails with “No profiles for ‘…UITests.xctrunner’ were found … Automatic signing is disabled”, which reads like a project misconfiguration but is not — signing style is already automatic. Contributors signing with their own team should also set BUNDLE_ID_PREFIX first; see ENVIRONMENT.md.

Coverage philosophy

All code ships with tests — nothing merges untested. Depth scales with risk: chase exhaustive coverage where wrongness is dangerous (reasoning, phrasing, awareness logic) and where regressions are silent (perception); for glue code and SwiftUI views a minimal covering test suffices. A test you’ll actually maintain beats one that looks good in a report.

One deliberate exception: App-layer types that wrap an Xcode-generated or system-provided type carry no package-level unit test. FoundationModelsSceneComposer (needs SystemLanguageModel) and CustomSoundClassifier (needs the Create ML model’s auto-generated Swift wrapper, which only exists in the App target’s compiled output — see docs/ARCHITECTURE.md’s “Sound Alerts data flow”) both live in the App layer specifically because SenseBridgeCore cannot depend on either type. Neither can be constructed from a SenseBridgeCoreTests-style unit test for the same reason, so this is a structural limit of where the type can live, not a gap to backfill.

That does not mean both are manual-only. FoundationModelsSceneComposer is manual-only: no automated test touches it or SystemLanguageModel, so its coverage is the manual/VoiceOver pass — this covers the SpokenDetail-scaled word ceiling too: xcodebuild build proves the two @Generable guide structs and the structural length check compile, never that a real model actually respects the word-budget hint at .detailed, which needs a device listen (see TODO.md’s device-validation entry). CustomSoundClassifier is not — after an audit found non-alert audio (room hiss, white noise, a pure tone, a frequency sweep) scoring an alert class at 1.000 confidence and reaching spoken output (see audits/safety-framing/20260806-064241-custom-sound-classifier-out-of-distribution-false-positives-reach-spoken-output.md), app/SenseBridgeTests/CustomSoundClassifierOODTests.swift was added as an App-target Swift Testing suite that runs the shipped .mlmodel through the same model, request, and target-class filter CustomSoundClassifier.process(_:) uses. It still does not construct CustomSoundClassifier itself — so the package-level-unit-test limit above still holds — but it is real automated regression coverage of the inference path the type wraps, not a manual check.

The accessibility-audit gate

Every screen’s audit runs through one shared helper, XCTestCase.performScopedAccessibilityAudit in app/SenseBridgeUITests/AccessibilityAuditSupport.swift, which also carries the evidence for every exemption. Two properties of it are worth knowing before adding an audit:

A device-free gate catches the arithmetic half of this. npm run check:contrast (tools/check-color-contrast.mjs) computes the WCAG 2.1 ratio for every authored color asset against each system background it is drawn on, in both appearances, and fails below 4.5:1. It does not replace the device audit — a color can clear the arithmetic and still be composited over something unexpected — but the contrast ratio of two known sRGB colors never needed a phone, an unlocked screen, or six minutes to compute. It found the defect behind all four device failures in milliseconds: AccentColor’s dark variant was Apple’s own systemBlue #0A84FF, which is 3.82:1 against iOS’s dark grouped-row background #2C2C2E and 3.50:1 against the #323236 this app was pixel-sampled rendering on hardware. Both sit in the 3.0-4.5 band whose audit message is precisely “not high enough unless font size is larger”.

Which destination a UI test means something on

npm run app:test runs the UI suite in the Simulator. npm run app:device-test runs the same suite on an attached iPhone, and is the only command that exercises real ARKit, LiDAR, the camera, and arm64. They are not interchangeable, and a green Simulator run is not evidence for the device.

The first device run of this suite (2026-08-19) failed eight tests, and not one of them was a regression. They divided cleanly:

Everything else in the suite is destination-independent by design, and should stay that way. If a test can only pass on one destination, that belongs in its doc comment, in the words of what it actually depends on.

What only a device can settle

Some claims are structurally beyond both swift test and the Simulator suite, and this list exists so they are tracked rather than assumed:

Recruiting field testers

NFB or ACB local chapters, accessibility Discord/forum communities, and GitHub’s accessibility open-source initiatives (see COMMUNITY_GUIDELINES.md) are the likely channels. This is on the critical path, not a nicety — resolve it early.

The one sentence that matters most

If a blind person has not used a feature eyes-free and found it useful, it is not validated — no amount of green CI substitutes for that.


Need help? See SUPPORT.md.