"Here's an API β build a small feature." It sounds like a gift and quietly fails more senior candidates than the whiteboard. The happy path is the easy 20%. The signal is the states nobody demos, the seam that makes it testable, the accessibility you added by reflex, and your ability to defend every call. This page is the reference build β do it once until each piece is muscle memory, and the real take-home becomes a formality.
Take any small JSON API and ship a SwiftUI feature with all four states (loading / error / empty / loaded), a protocol-injected network layer, unit tests that run offline against a fake, VoiceOver + Dynamic Type support, and a one-paragraph performance note β then walk an interviewer through every decision calmly. That last part is the senior signal.
Build alongside, don't skim. Open Xcode and recreate each piece as you read it.
Pin the contract down before you write UI. A senior reads the API and immediately asks: what can go wrong?
Take a representative brief: show a searchable list of repositories from a JSON endpoint; tapping one shows detail. The endpoint returns:
{
"items": [
{ "id": 1, "name": "swift", "stargazers_count": 65000, "description": "The Swift language" },
{ "id": 2, "name": "SwiftUI-Intro", "stargazers_count": 320, "description": null }
]
}
The senior move is to enumerate the failure modes before the happy path:
"items": []. This is a designed state, not an accidental blank list.description is nullable β model that honestly.Two decisions carry the whole build: model state as a single value, and put a protocol between you and the network.
struct Repo: Identifiable, Decodable, Equatable {
let id: Int
let name: String
let stars: Int
let description: String? // nullable in the API β keep it optional
enum CodingKeys: String, CodingKey {
case id, name, description
case stars = "stargazers_count"
}
}
struct RepoSearchResponse: Decodable { let items: [Repo] }
Don't scatter isLoading, error, and repos as independent properties β they let
impossible states exist (loading and errored). Make state one enum:
enum LoadState<Value> {
case loading
case loaded(Value)
case empty
case failed(String) // user-facing message
}
The single most important line in a senior take-home: the view-model depends on a protocol, not on
URLSession. That seam is what lets your tests run offline, instantly, deterministically.
protocol RepoService {
func search(_ query: String) async throws -> [Repo]
}
struct LiveRepoService: RepoService {
let session: URLSession = .shared
func search(_ query: String) async throws -> [Repo] {
var c = URLComponents(string: "https://api.example.com/search/repositories")!
c.queryItems = [URLQueryItem(name: "q", value: query)]
let (data, response) = try await session.data(from: c.url!)
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(RepoSearchResponse.self, from: data).items
}
}
The view-model is @MainActor (UI state mutates on the main actor), holds the injected service, and
maps results to the state enum β including the empty case:
import Observation
@MainActor @Observable
final class RepoListModel {
private(set) var state: LoadState<[Repo]> = .loading
private let service: RepoService
init(service: RepoService) { // <- injected, not a singleton
self.service = service
}
func search(_ query: String) async {
state = .loading
do {
let repos = try await service.search(query)
state = repos.isEmpty ? .empty : .loaded(repos)
} catch is CancellationError {
return // a newer search superseded this one
} catch {
state = .failed("Couldn't load repositories. Check your connection and try again.")
}
}
}
URLSession, never sees a raw Error. It renders state. The model maps empty results to .empty explicitly β that one line is what separates a senior submission.The view is a switch over the state. Every branch is a real, designed screen β including the two everyone forgets.
struct RepoListView: View {
@State private var model: RepoListModel
@State private var query = "swift"
init(service: RepoService) {
_model = State(initialValue: RepoListModel(service: service))
}
var body: some View {
Group {
switch model.state {
case .loading:
ProgressView("Loading repositoriesβ¦") // never a frozen blank screen
case .loaded(let repos):
List(repos) { RepoRow(repo: $0) }
case .empty:
ContentUnavailableView("No repositories",
systemImage: "magnifyingglass",
description: Text("Nothing matched β\(query)β. Try another search."))
case .failed(let message):
VStack(spacing: 12) {
ContentUnavailableView("Something went wrong",
systemImage: "wifi.slash", description: Text(message))
Button("Try again") { Task { await model.search(query) } }
.buttonStyle(.borderedProminent)
}
}
}
.task { await model.search(query) } // initial load
}
}
print(error) and shows an empty list. To a grader that's indistinguishable from "the feature is broken." Always give the user a message and a way out (retry).ContentUnavailableView (iOS 17+) is the platform-native empty/error affordance β reaching for it shows you keep current. On older targets, a centred VStack with an SF Symbol + copy + action does the same job.Because the service is a protocol, the test injects a fake and exercises every state in milliseconds β no network, no flake.
Before revealing the reference: which three tests would you write first? (Hint: one per non-happy state, because those are the ones that ship broken.)
struct FakeRepoService: RepoService {
var result: Result<[Repo], Error>
func search(_ query: String) async throws -> [Repo] {
try result.get()
}
}
import Testing
@testable import RepoFeature
@MainActor
struct RepoListModelTests {
@Test func loadedWhenServiceReturnsRepos() async {
let repo = Repo(id: 1, name: "swift", stars: 10, description: nil)
let model = RepoListModel(service: FakeRepoService(result: .success([repo])))
await model.search("swift")
guard case .loaded(let repos) = model.state else { Issue.record("expected .loaded"); return }
#expect(repos == [repo])
}
@Test func emptyWhenServiceReturnsNoRepos() async {
let model = RepoListModel(service: FakeRepoService(result: .success([])))
await model.search("zzz")
guard case .empty = model.state else { Issue.record("expected .empty"); return }
}
@Test func failedWhenServiceThrows() async {
let model = RepoListModel(service: FakeRepoService(result: .failure(URLError(.notConnectedToInternet))))
await model.search("swift")
guard case .failed = model.state else { Issue.record("expected .failed"); return }
}
}
Mention what you'd add with more time: a decode test feeding raw JSON through JSONDecoder, and a snapshot
test per state. Naming the next test you'd write shows judgement about where coverage is worth buying.
Seniors are spotted by whether accessibility is in the first draft. It's a few lines, and graders look for it.
struct RepoRow: View {
let repo: Repo
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(repo.name).font(.headline)
if let description = repo.description {
Text(description).font(.subheadline).foregroundStyle(.secondary)
.lineLimit(2) // truncates, never clips layout
}
Label("\(repo.stars)", systemImage: "star.fill").font(.caption)
}
// one clear spoken phrase instead of three disjointed reads:
.accessibilityElement(children: .combine)
.accessibilityLabel("\(repo.name), \(repo.stars) stars")
}
}
.headline, not fixed sizes); test the largest size for clipping.You won't profile a take-home under time β but a senior leaves a short, specific note on where it could hitch and what they'd measure. That note is the deliverable.
Repo is Identifiable with a stable id, so SwiftUI diffs cleanly. If rows showed remote avatars I'd decode images off the main actor and add a memory+disk cache, since image decode on the main thread is the classic source of scroll hitches. I'd verify with the Time Profiler and an os_signpost around row appearance, targeting 60fps. The search re-fetches on each keystroke β I'd debounce ~300ms and cancel the in-flight task (the model already handles CancellationError) to avoid wasted work."Identifiable with a real id so the diffing engine isn't re-rendering everything.This is roughly what a senior reviewer ticks. Answer honestly; then the checklist below is your build target.
RepoService as a protocol instead of calling URLSession in the view-model? testabilityenum LoadState instead of separate isLoading/error/repos properties mainly buys youβ¦ stateswitch β the compiler forces you to handle every state.Tick these honestly β they're the build target, and they save in your browser.
LoadState enum β loading / loaded / empty / failed.This is the practical app-build item on the readiness gate, the highest-leverage round in the Australia track, and your strongest counter to "senior but can't ship." Build it twice: once reading along here, once cold under a timer.