% testing-viewmodels-ui-snapshot.tex — ViewModel/Coordinator unit tests, XCUITest, snapshot tests.
% Sources: docs/memos/testing-swiftui-viewmodels.md, docs/memos/testing-ui-xcuitest.md,
%          docs/memos/testing-snapshot.md, docs/memos/testing-performance-and-strategy.md (Q8, Q9).
% Build ONLY with: tools/print/print-sheet.py <this>.tex --dry-run
% @source: hiot monorepo, docs/school/sheets/testing/testing-viewmodels-ui-snapshot.tex — the SOURCE OF TRUTH; a copy anywhere else (e.g. artur.gurgul.pro) is regenerated from it, never edited
% @labels: area=testing kind=pattern level=senior platform=ios new=no round=round2-2026-09-23 topic=testing,ui,architecture
% @tags: viewmodel-testing, coordinator-testing, spy-router, xcuitest, xcuiapplication, launch-arguments, page-object, accessibilityidentifier, waitforexistence, snapshot-testing, swift-snapshot-testing, record-mode
\documentclass[8pt]{extarticle}
\usepackage{printup-sheet}
\usepackage{array}

\lstdefinelanguage{SwiftSheet}{
  morekeywords={protocol,class,final,struct,enum,func,var,let,weak,init,override,
    if,else,return,guard,self,nil,try,await,async,throws,private,some,in,
    true,false,AnyObject,Void,String,Bool,Int,import,case,do,catch},
  sensitive=true, morecomment=[l]{//}, morecomment=[s]{/*}{*/}, morestring=[b]"}

\tikzset{
  sb/.style={box, font=\scriptsize, inner sep=1.5pt, minimum height=4.6mm},
  proc/.style={draw=sheetGrey, dashed, rounded corners=3pt, inner sep=2.5mm},
  runner/.style={sb, draw=sheetBlue, fill=sheetBlue!8},
  app/.style={sb, draw=sheetGreen, fill=sheetGreen!12},
  stub/.style={sb, draw=sheetOrange, fill=sheetOrange!10},
  lbl/.style={font=\tiny, text=black!75, inner sep=1pt, align=center},
  ttl/.style={font=\scriptsize\bfseries, text=sheetGrey},
}

\begin{document}

\sheettitle{Testing ViewModels, Coordinators, UI \& snapshots}{testing · memo}

\oneliner{Put logic in a \textbf{ViewModel with protocol-injected dependencies} and unit-test its
\textbf{state transitions} (fast, in-process); test a Coordinator's \textbf{routing decision}, not
UIKit; keep \textbf{XCUITest} (separate process, accessibility tree) for a few critical journeys;
pin \textbf{snapshots} to one device/OS/locale/appearance.}

\begin{multicols}{2}

\section{How it works}
\begin{itemize}\raggedright
  \item \textbf{ViewModel:} a SwiftUI \texttt{View} is an opaque value description — you can't
        read what it shows, and \texttt{@State} only lives inside a rendered hierarchy. So:
        thin View, logic in an \texttt{@Observable}/\texttt{ObservableObject} VM. Test =
        \textbf{construct with doubles → call an input → assert state}. A VM that builds
        \texttt{URLSession.shared} itself is untestable — DI is the enabler.
  \item \texttt{@MainActor} VM → mark the test \texttt{@MainActor} and \texttt{await} the call,
        or you read stale state. \textbf{Sequence} \texttt{[.idle,.loading,.loaded]}: record via
        \texttt{\$state.sink} (Combine) or \texttt{withObservationTracking} (\texttt{@Observable}
        has no \texttt{\$} publisher — assert properties directly).
  \item Model state as an \textbf{enum} (\texttt{idle/loading/loaded/error}); put formatting in
        the VM (\texttt{priceText == "\$10.00"}); inject \texttt{@Environment} values
        (locale, scenePhase) as plain params.
  \item \textbf{Coordinator:} inject a navigation abstraction (\texttt{Router} protocol / a
        \texttt{path: [Route]}) and assert the \textbf{route produced} for an event — a spy
        records \texttt{show(.detail(id: 7))}; no real \texttt{push}.
  \item \textbf{XCUITest:} \texttt{let app = XCUIApplication()}; set
        \texttt{launchArguments} / \texttt{launchEnvironment} \emph{before}
        \texttt{app.launch()}; query \texttt{app.buttons["login\_button"]};
        \texttt{continueAfterFailure = false}; sync with
        \texttt{waitForExistence(timeout:)}; \texttt{exists} $\neq$ \texttt{isHittable}.
        Alerts: \texttt{addUIInterruptionMonitor}. On failure attach
        \texttt{XCTAttachment(screenshot:)}.
  \item \textbf{Page Object:} one type per screen owns the selectors + actions
        (\texttt{LoginPage.login(email:password:)}); tests read as journeys; one place to fix.
  \item \textbf{Snapshot} (Point-Free \texttt{swift-snapshot-testing}):
        \texttt{assertSnapshot(of: view, as: .image(on: .iPhone13))} — first run \textbf{records}
        a reference in \texttt{\_\_Snapshots\_\_/}, later runs \textbf{assert} (compare). Strategies:
        \texttt{.image}, \texttt{.json}, \texttt{.dump}, \texttt{.recursiveDescription}.
        Tolerance: \texttt{.image(precision: 0.98, perceptualPrecision: 0.98)}.
\end{itemize}

\section{Example — ViewModel state transition}
\begin{lstlisting}[language=SwiftSheet]
protocol FeedAPI { func items() async throws -> [Item] }
struct StubFeedAPI: FeedAPI {
  let result: Result<[Item], Error>
  func items() async throws -> [Item] { try result.get() } }

@MainActor func test_load_failure_showsError() async {
  let sut = FeedViewModel(api: StubFeedAPI(result: .failure(URLError(.notConnectedToInternet))))
  var seen: [FeedState] = []
  let c = sut.$state.sink { seen.append($0) }   // record sequence
  await sut.load()
  XCTAssertEqual(seen, [.idle, .loading, .error("Offline")])
  _ = c
}
\end{lstlisting}

\section{UI test skeleton — stub + Page Object}
\begin{lstlisting}[language=SwiftSheet]
override func setUpWithError() throws {
  continueAfterFailure = false
  app.launchArguments += ["-uitesting", "-disableAnimations"]
  app.launchEnvironment["API_BASE"] = "http://localhost:9999"
  app.launch() }                       // args BEFORE launch
func test_login_showsWelcome() {
  LoginPage(app: app).login(email: "a@b.co", password: "pw")
  let welcome = app.staticTexts["welcome"]      // an identifier
  XCTAssertTrue(welcome.waitForExistence(timeout: 5)) }
// app side, at launch:
if CommandLine.arguments.contains("-uitesting") { useStubs() }
\end{lstlisting}

\columnbreak

\section{Picture — where each kind of test runs}
\noindent\begin{tikzpicture}[sheet]
  % unit test: one process
  \node[runner, minimum width=16mm] (ut) at (0.8,0) {XCTest bundle};
  \node[app, minimum width=16mm] (vm) at (0.8,-0.95) {FeedViewModel};
  \node[stub, minimum width=16mm] (sa) at (0.8,-1.9) {StubFeedAPI};
  \draw[flow] (ut) -- node[lbl, fill=white]{\texttt{@testable}} (vm);
  \draw[flow] (vm) -- node[lbl, fill=white]{protocol} (sa);
  \node[proc, fit=(ut)(vm)(sa)] (p1) {};
  \node[ttl, above=0.3mm of p1, align=center] {unit test:\\ONE process, ms};
  % UI test: two processes
  \node[runner, minimum width=17mm] (tc) at (3.55,0) {test code};
  \node[runner, minimum width=17mm] (po) at (3.55,-0.95) {LoginPage};
  \node[runner, minimum width=17mm] (xa) at (3.55,-1.9) {XCUIApplication};
  \draw[flow] (tc) -- (po); \draw[flow] (po) -- (xa);
  \node[proc, fit=(tc)(po)(xa)] (p2) {};
  \node[ttl, above=0.3mm of p2, align=center] {UI test:\\runner process};
  \node[app, minimum width=17mm] (ax) at (6.9,0) {a11y tree};
  \node[app, minimum width=17mm] (av) at (6.9,-0.95) {real app code};
  \node[stub, minimum width=17mm] (as) at (6.9,-1.9) {stubbed services};
  \draw[flow] (ax) -- (av); \draw[flow] (av) -- (as);
  \node[proc, fit=(ax)(av)(as)] (p3) {};
  \node[ttl, above=0.3mm of p3, align=center] {app process\\(separate)};
  \draw[hot] (xa.east) -- node[lbl, above=0.3mm, text=sheetOrange]{launch\\args/env} (as.west);
  \draw[hot, <->] (tc.east) -- node[lbl, below=0.3mm, text=sheetOrange]{IPC:\\query · tap} (ax.west);
  \node[lbl, text=sheetRed, anchor=north] at (4.2,-2.55)
    {no shared memory: a UI test can't read a VM property — only what the\\accessibility tree exposes (identifier, label, value, frame, isHittable)};
\end{tikzpicture}

\section{Coordinator test — spy the navigation}
\begin{lstlisting}[language=SwiftSheet]
final class SpyRouter: Router {              // Router = protocol
  var shown: [Route] = []
  func show(_ r: Route) { shown.append(r) } }
func test_selectingItem_routesToDetail() {
  let router = SpyRouter()
  let sut = FeedCoordinator(router: router)
  sut.didSelect(Item(id: 7))                   // the event
  XCTAssertEqual(router.shown, [.detail(id: 7)]) }  // the DECISION
\end{lstlisting}

\section{Interview traps}
\begin{itemize}
  \trap{\textbf{\texttt{accessibilityIdentifier}} = test hook, never localized, never read aloud;
        \textbf{\texttt{accessibilityLabel}} = VoiceOver text, localized. Query by label →
        passes locally, fails on a \texttt{de} CI or a copy edit.}
  \trap{\texttt{sleep(3)} before a tap: flaky when short, slow when long, waits for time not the
        event → \texttt{waitForExistence}.}
  \trap{Pointing UI tests at the \textbf{real backend}: nondeterministic, slow, may mutate prod.
        Stub via \texttt{launchEnvironment["API\_BASE"]} / a \texttt{-uitesting} flag; disable
        animations.}
  \trap{Record mode committed (\texttt{record: .all}): it \textbf{always fails} and rewrites
        goldens. And ``just re-record'' green-lights real regressions — review the diff.}
  \trap{Snapshot passes locally, fails on CI: recorded on a different simulator/OS/scale.
        \textbf{Pin} device, OS, locale, \texttt{userInterfaceStyle}, content size; inject fixed
        dates/IDs.}
  \trap{``80\% View coverage via ViewInspector'' = logic lives in Views + brittle structure tests.}
\end{itemize}

\section{Remember}
\textbf{VM: inject → drive → assert state.} UI test: \textbf{ID, wait, stub, Page Object, few}.
Snapshot: \textbf{pin, record once, review diffs}.

\section{Likely questions}
\begin{enumerate}
  \item Why not unit-test a SwiftUI View? — opaque \texttt{body}; \texttt{@State} is runtime-owned.
  \item Test a Coordinator? — fake nav/router; assert the route chosen, not the push.
  \item Stub the backend in XCUITest? — launch args/env read at app start → stub services.
  \item Can a UI test read VM state? — no; separate process, a11y tree only.
  \item Record vs assert? — record writes the reference (and fails); assert compares.
\end{enumerate}

\end{multicols}

\noindent{\footnotesize\color{sheetGrey}\textit{Related:} MVC/MVP/MVVM · Coordinator ·
DI · SwiftUI state · Observation \& Combine · XCTest \& Swift Testing · flaky tests \& CI}

\end{document}
