% async-and-network-testing.tex — XCTestExpectation vs async tests, MockURLProtocol, the three
% error paths, injected clocks, Combine / AsyncStream, cancellation.
% Sources: docs/memos/testing-async.md, docs/memos/testing-mocking-network.md,
%          docs/memos/testing-async-and-time.md.
% Build ONLY with: tools/print/print-sheet.py <this>.tex --dry-run
% @source: hiot monorepo, docs/school/sheets/testing/async-and-network-testing.tex — the SOURCE OF TRUTH; a copy anywhere else (e.g. artur.gurgul.pro) is regenerated from it, never edited
% @labels: area=testing kind=api level=senior platform=apple new=no round=round2-2026-09-23 topic=testing,concurrency,networking
% @tags: xctestexpectation, fulfillment, mockurlprotocol, urlprotocol, async-test, withcheckedcontinuation, test-clock, swift-clocks, asyncstream, combine-scheduler, cancellation, decodingerror
\documentclass[8pt]{extarticle}
\usepackage{printup-sheet}
\usepackage{array}

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

\tikzset{
  sb/.style={box, font=\scriptsize, inner sep=2pt, minimum height=6mm},
  tst/.style={sb, draw=sheetOrange, fill=sheetOrange!10},
  mk/.style={sb, draw=sheetGreen!70!black, fill=sheetGreen!12, align=left},
  lbl/.style={font=\scriptsize, text=black!80, inner sep=1pt, align=center},
  num/.style={circle, fill=sheetBlue, text=white, font=\tiny\bfseries, inner sep=1pt},
}

\begin{document}

\sheettitle{Async \& network testing — expectations, MockURLProtocol, clocks}{testing · memo}

\oneliner{\textbf{Wait for the event, never for time.} \texttt{async} code: make the test
\texttt{async throws} and \texttt{await}. Callbacks: \texttt{XCTestExpectation} +
\texttt{wait(for:timeout:)}. Network: run the \emph{real} \texttt{URLSession} with a
\texttt{MockURLProtocol} on an \texttt{.ephemeral} config, and cover \textbf{all three} failure
paths. Time: inject a \textbf{clock}/scheduler and advance it — no \texttt{sleep}.}

\vspace{3pt}
\noindent\begin{tikzpicture}[sheet]
  \node[tst, minimum width=13mm] (t) at (0.7,1.3) {\textbf{Test}};
  \node[sb, minimum width=15mm] (sut) at (2.9,1.3) {SUT \texttt{APIClient}\\\texttt{data(for:)}};
  \node[sb, minimum width=22mm] (ses) at (5.7,1.3) {\texttt{URLSession} (\texttt{.ephemeral})\\\texttt{protocolClasses = [Mock…]}};
  \node[mk, minimum width=34mm] (mp) at (9.6,1.3) {\textbf{MockURLProtocol}\\
     \texttt{class canInit(with:)} $\to$ \texttt{true}\\
     \texttt{startLoading()} calls \texttt{handler}};
  \node[tst, minimum width=28mm, align=left] (h) at (14.3,1.3) {\texttt{static var handler}\\
     \texttt{(URLRequest) throws ->}\\\texttt{(HTTPURLResponse, Data)}};
  % forward path
  \draw[flow] ([yshift=1.5mm]t.east) -- ([yshift=1.5mm]sut.west);
  \draw[flow] ([yshift=1.5mm]sut.east) -- ([yshift=1.5mm]ses.west);
  \draw[flow] ([yshift=1.5mm]ses.east) -- ([yshift=1.5mm]mp.west);
  \draw[flow] ([yshift=1.5mm]mp.east) -- ([yshift=1.5mm]h.west);
  % return path
  \draw[hot] ([yshift=-1.5mm]h.west) -- ([yshift=-1.5mm]mp.east);
  \draw[hot] ([yshift=-1.5mm]mp.west) -- ([yshift=-1.5mm]ses.east);
  \draw[hot] ([yshift=-1.5mm]ses.west) -- ([yshift=-1.5mm]sut.west -| sut.east);
  \draw[hot] ([yshift=-1.5mm]sut.west) -- ([yshift=-1.5mm]t.east);
  \node[lbl, text=sheetOrange, align=left, anchor=north] at (10.4,0.55)
     {\texttt{client?.urlProtocol(self, didReceive:cacheStoragePolicy:)}\\
      \texttt{client?.urlProtocol(self, didLoad:)} · \texttt{urlProtocolDidFinishLoading}\\
      or \texttt{client?.urlProtocol(self, didFailWithError:)}};
  \node[lbl, text=sheetOrange, anchor=north] at (2.9,0.55) {real decode runs\\$\to$ \texttt{User} or throws};
  \node[lbl, text=sheetOrange, anchor=north] at (0.7,0.55) {assert};
  % test sets the handler
  \draw[flow, draw=sheetOrange, dashed] (t.north) |- node[lbl, above, pos=0.75, text=sheetOrange]
     {\textbf{Arrange:} test sets \texttt{MockURLProtocol.handler = \{ req in … \}} — may also assert on \texttt{req} (URL, method, headers)} (14.3,2.45) -- (h.north);
  % internet crossed out
  \node[lbl, text=sheetRed, draw=sheetRed, dashed, rounded corners=2pt, inner sep=2pt] (net) at (5.7,0.2) {network: never reached};
  \draw[sheetRed, thick] ($(ses.south)+(-0.12,-0.12)$) -- ($(ses.south)+(0.12,-0.36)$);
  \draw[sheetRed, thick] ($(ses.south)+(0.12,-0.12)$) -- ($(ses.south)+(-0.12,-0.36)$);
\end{tikzpicture}

\begin{multicols}{2}

\section{How it works}
\begin{itemize}
  \item \textbf{Callback API} — \texttt{let exp = expectation(description:)};
        \texttt{exp.fulfill()} in the handler; \texttt{wait(for: [exp], timeout: 1)}.
        Knobs: \texttt{expectedFulfillmentCount = N}; \texttt{isInverted = true} (fails
        \emph{if} fulfilled — ``must not happen'', always waits the full timeout);
        \texttt{wait(…, enforceOrder: true)}; over-fulfilling fails
        (\texttt{assertForOverFulfill} is on for \texttt{expectation(description:)}).
  \item \textbf{\texttt{async} API} — \texttt{func test\_x() async throws} and
        \texttt{await}; XCTest awaits the method. Inside an \texttt{async} test,
        \texttt{wait(for:)} is \texttt{noasync} (Xcode 14.3+) — use
        \texttt{await fulfillment(of: [exp], timeout: 1)}. Swift Testing:
        \texttt{@Test func x() async throws} + \texttt{\#expect(throws:)};
        \texttt{await confirmation \{ c in … \}} replaces expectations.
  \item \textbf{Bridge} a callback you cannot change:\newline
        \texttt{await withCheckedContinuation \{ c in … \}} — resume \emph{exactly once}
        (twice = crash, never = hang).
  \item \textbf{Timeout} is a failure ceiling, not a duration: a passing test returns the
        moment the expectation is met. 1--2\,s for stubbed work.
\end{itemize}

\section{Example — MockURLProtocol}
\begin{lstlisting}[language=SwiftSheet]
final class MockURLProtocol: URLProtocol {
  static var handler:                            // set per test
    ((URLRequest) throws -> (HTTPURLResponse, Data))?
  override class func canInit(with r: URLRequest)
    -> Bool { true }                    // intercept all
  override class func canonicalRequest(for r: URLRequest)
    -> URLRequest { r }
  override func startLoading() {
    do { let (resp, data) = try Self.handler!(request)
      client?.urlProtocol(self, didReceive: resp,
                          cacheStoragePolicy: .notAllowed)
      client?.urlProtocol(self, didLoad: data)
      client?.urlProtocolDidFinishLoading(self)
    } catch {
      client?.urlProtocol(self, didFailWithError: error) } }
  override func stopLoading() {} }
\end{lstlisting}
\begin{lstlisting}[language=SwiftSheet]
let config = URLSessionConfiguration.ephemeral  // no disk cache
config.protocolClasses = [MockURLProtocol.self] // not global
let sut = APIClient(session: URLSession(configuration: config))
\end{lstlisting}

\section{Remember}
\textbf{Await, don't sleep. Stub bytes, not objects. Three ways to fail:
wire · status · decode.}

\columnbreak

\section{The three failure paths — test all of them}
{\footnotesize
\begin{tabular}{@{}>{\raggedright\arraybackslash}p{13mm}>{\raggedright\arraybackslash}p{24mm}>{\raggedright\arraybackslash}p{37mm}@{}}
\toprule
\textbf{Path} & \textbf{handler does} & \textbf{what happens} \\
\midrule
\textbf{Transport} & \texttt{throw URLError(\allowbreak.notConnectedToInternet)} & \texttt{data(for:)} throws \texttt{URLError} $\to$ map to \texttt{.offline} \\
\textbf{HTTP status} & returns 404 / 500 & URLSession does \textbf{not} throw; \emph{you} check \texttt{200..<300} $\to$ \texttt{.status(500)} \\
\textbf{Decoding} & 200 + malformed JSON & \texttt{JSONDecoder} throws \texttt{DecodingError} (\texttt{keyNotFound}, \texttt{typeMismatch}, \texttt{valueNotFound}, \texttt{dataCorrupted}) \\
\bottomrule
\end{tabular}}

\section{Time, streams, cancellation}
\begin{itemize}
  \item \textbf{Clock}: depend on \texttt{any Clock<Duration>} — \texttt{ContinuousClock()}
        in prod, a test clock (\texttt{TestClock} / \texttt{ImmediateClock}, Point-Free
        \emph{swift-clocks}) in tests: \texttt{await clock.advance(by: .seconds(5))} — 0\,ms
        real time. Plain \texttt{Date}: inject \texttt{() -> Date}.
  \item \textbf{Combine}: inject the scheduler (\texttt{ImmediateScheduler}, or a
        \texttt{TestScheduler} you \texttt{advance}); \texttt{sink} into an array and
        \emph{keep} the \texttt{AnyCancellable}; or \texttt{publisher.values}.
  \item \textbf{AsyncStream}: \texttt{for await} \emph{bounded} — \texttt{break} after N
        or \texttt{stream.prefix(3)}; an unfinished stream hangs the test.
  \item \textbf{Cancellation}: start a \texttt{Task}, \texttt{cancel()} it,
        \texttt{try await task.value} must throw \texttt{CancellationError} — but a
        cancelled \texttt{URLSession} call throws \texttt{URLError(.cancelled)}. Also
        assert the side effect did \emph{not} happen.
\end{itemize}

\section{Interview traps}
\begin{itemize}
  \trap{\texttt{Thread.sleep}/\texttt{Task.sleep} ``to let it settle'' — slow \emph{and}
        flaky. Await the signal or advance a test clock.}
  \trap{\texttt{Task \{ await vm.load() \}} then assert at once = race; \texttt{await}
        directly or \texttt{await task.value}. \texttt{@MainActor} VM $\to$ \texttt{@MainActor} test.}
  \trap{\texttt{.default} config can serve a \emph{cached} response; \texttt{URLProtocol.registerClass}
        is \emph{process-wide}. Use \texttt{.ephemeral} + \texttt{protocolClasses}; nil the
        handler in \texttt{tearDown}.}
  \trap{\texttt{static var handler} is shared state: tests using it must not run in
        parallel; Swift~6 mode needs \texttt{nonisolated(unsafe)}.}
  \trap{\texttt{request.httpBody} is often \texttt{nil} in the protocol — the body
        arrives as \texttt{httpBodyStream}.}
\end{itemize}

\section{Likely questions}
\begin{enumerate}
  \item Expectation vs async test? — callbacks/delegates vs anything awaitable.
  \item Why \texttt{URLProtocol} over a protocol-wrapped session? — real request + decode run.
  \item Does a 500 make \texttt{data(for:)} throw? — no; check \texttt{statusCode} yourself.
  \item Test a 300\,ms debounce fast? — inject a clock, \texttt{advance(by:)}.
\end{enumerate}

\end{multicols}

\noindent{\footnotesize\color{sheetGrey}\textit{Related:} test doubles (stub vs spy) · testable
design (injected clock, DI) · testing fundamentals (Repeatable, flaky) · Swift concurrency ·
Combine · Codable}

\end{document}
