canShip requires build ≥ 400.
A unit harness asserts both 418 and 200. A shallow UI harness only asks whether a Ship button exists. This bench runs the same gate under three scenarios and shows who blinks.
Healthy — both agree
ShipmentGate.canShip(build: 418) // true
ShipmentGate.canShip(build: 200) // false
| Harness | Score | Notes |
|---|---|---|
| UNIT | 2/2 | 418 true, 200 false |
| UI | 2/2 | button exists + a11y label |
Sample UI: Ship 418 enabled, Ship 200 disabled.
VM bug — unit fails, UI stays green
Invert the gate. Build 418 wrongly refuses; build 200 wrongly ships.
| Harness | Score | Notes |
|---|---|---|
| UNIT | 0/2 | both assertions fail |
| UI | 2/2 | still finds Ship + label |
That green UI column is the control: existence checks do not see inverted logic. Sample UI shows the lie — Ship 200 lit, Ship 418 dark.
Reusing the ch8 LaunchLabAXTests target:
testShipButtonExists_vmBug_stillFindsButton passedIt launched -mode vm, waited for ship-418, and passed — same miss the on-screen UI column prints.
UI bug — unit green, label fails
Logic restored; accessibility label stripped.
| Harness | Score | Notes |
|---|---|---|
| UNIT | 2/2 | gate sound again |
| UI | 1/2 | button exists PASS, a11y label FAIL |
Unit tests do not open VoiceOver's tree. A labelled control is a UI concern — ch8's lesson, reused here.
TDD books split unit / integration / UI. This still is the split as scoreboards: UNIT catches the inverted ViewModel; UI catches the missing label; neither catches everything alone.
Mini-exercise
Add testCanShip200_vmMode that launches -mode vm and asserts Ship 200 is not enabled. Watch it fail today — that is the missing assertion the shallow harness skipped.
Challenges
- Add a
LaunchLabUnitTeststarget that importsShipmentGateand asserts the healthy matrix without the bench UI. - Extend the UI test to assert
Ship 200is not hittable in healthy mode. - Inject a network client protocol and show a mock unit test vs a live UI smoke.
- Make the UI harness read the on-screen PASS/FAIL labels via
XCUIApplication().staticTexts. - Wire CI to run
-only-testing:LaunchLabAXTests/ShipmentGateUITestson the LaunchLab simulator.
Key Points
- Observed HEALTHY: UNIT 2/2, UI 2/2.
- Observed VM BUG: UNIT 0/2, UI 2/2 — control miss.
- Observed UI BUG: UNIT 2/2, UI 1/2 (label).
ShipmentGate.canShipis pure — unit tests do not need a simulator.- Shallow UI tests can pass while the ViewModel lies.
- Real
ShipmentGateUITestsstill foundship-418under-mode vm. - Accessibility labels are UI-test territory (ch8 harness reused).
- Use both layers; photograph what each refuses to see.
Next up: dependency injection — one screen wired to a live service, a stub, and a failing stub, switchable by launch argument.
Read next
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper