SwiftUI Preview/Test Convergence
Build one scenario system that powers previews, tests, and production.
If previews are hard to build, the architecture is wrong.
Outcome
Each feature has:
- A
View(renders state, triggers intent) - An
@Observablestate object - A service
protocol - A live implementation
- A scenario-driven mock
- Centralized fixtures
- Shared constructors for previews and tests
The same scenario language works everywhere: production, previews, tests.
Rules
1. Views are dumb
Views render state and trigger intent. Nothing else.
Allowed:
- State rendering
- User interaction
.tasklifecycle triggers- Lightweight composition
Not allowed:
- API calls
- Business logic
- Parsing
- Hidden dependency creation
2. State objects depend on protocols
protocol ItemServicing: Sendable {
func fetchItems() async throws -> [Item]
}
The state object depends on ItemServicing, never on URLSession or a concrete SDK.
3. Mocks are scenarios, not placeholders
Bad:
final class MockItemService: ItemServicing {
var items: [Item] = []
func fetchItems() async throws -> [Item] { items }
}
Good:
final class MockItemService: ItemServicing, Sendable {
enum Scenario: Sendable {
case success([Item])
case empty
case failure(Error)
case delayed([Item], duration: Duration = .seconds(1))
}
private let scenario: Scenario
init(_ scenario: Scenario) {
self.scenario = scenario
}
func fetchItems() async throws -> [Item] {
switch scenario {
case .success(let items): return items
case .empty: return []
case .failure(let error): throw error
case .delayed(let items, let duration):
try await Task.sleep(for: duration)
return items
}
}
}
4. Centralize fixtures
enum ItemFixtures {
static let
id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,
title: "First Item"
)
static let two = Item(
id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!,
title: "Second Item"
)
static let list = [one, two]
}
Deterministic. Readable names. Zero duplicated fake setup.
5. Shared constructors — the convergence point
enum ItemScenarios {
@MainActor static func loaded() -> ItemsState {
ItemsState(service: MockItemService(.success(ItemFixtures.list)))
}
@MainActor static func empty() -> ItemsState {
ItemsState(service: MockItemService(.empty))
}
@MainActor static func error() -> ItemsState {
ItemsState(service: MockItemService(.failure(AppError("Something went wrong"))))
}
@MainActor static func loading() -> ItemsState {
ItemsState(service: MockItemService(.delayed(ItemFixtures.list)))
}
}
Same constructors in previews, tests, and dev exploration.
6. Previews are your design linter
If previews are hard to build, the feature is too coupled.
Smells:
- Preview requires real networking
- Preview needs app-wide state
- Preview breaks without a singleton
- Preview setup is longer than the feature
7. One scenario vocabulary
Use the same names everywhere: loaded, empty, error, loading, delayed, partial, unauthorized.
Do not invent separate setup languages for previews and tests.
Canonical Template (Swift 6, macOS 26+)
Model
struct Item: Identifiable, Equatable, Sendable {
let id: UUID
let title: String
}
Protocol
protocol ItemServicing: Sendable {
func fetchItems() async throws -> [Item]
}
Live service
struct LiveItemService: ItemServicing {
func fetchItems() async throws -> [Item] {
// real implementation
[]
}
}
Mock service
final class MockItemService: ItemServicing, Sendable {
enum Scenario: Sendable {
case success([Item])
case empty
case failure(Error)
case delayed([Item], duration: Duration = .seconds(1))
}
private let scenario: Scenario
init(_ scenario: Scenario) { self.scenario = scenario }
func fetchItems() async throws -> [Item] {
switch scenario {
case .success(let items): return items
case .empty: return []
case .failure(let error): throw error
case .delayed(let items, let duration):
try await Task.sleep(for: duration)
return items
}
}
}
State object
@Observable
@MainActor
final class ItemsState {
enum ViewState: Equatable {
case idle, loading
case loaded([Item])
case empty
case error(String)
}
private(set) var viewState: ViewState = .idle
private let service: ItemServicing
private var hasLoaded = false
init(service: ItemServicing) {
self.service = service
}
func loadIfNeeded() async {
guard !hasLoaded else { return }
hasLoaded = true
await load()
}
func load() async {
viewState = .loading
do {
let items = try await service.fetchItems()
viewState = items.isEmpty ? .empty : .loaded(items)
} catch {
viewState = .error(error.localizedDescription)
}
}
}
Scenarios (shared constructors)
enum ItemScenarios {
@MainActor static func loaded() -> ItemsState {
ItemsState(service: MockItemService(.success(ItemFixtures.list)))
}
@MainActor static func empty() -> ItemsState {
ItemsState(service: MockItemService(.empty))
}
@MainActor static func error() -> ItemsState {
ItemsState(service: MockItemService(.failure(AppError("Something went wrong"))))
}
@MainActor static func loading() -> ItemsState {
ItemsState(service: MockItemService(.delayed(ItemFixtures.list)))
}
}
struct AppError: LocalizedError {
let message: String
init(_ message: String) { self.message = message }
var errorDescription: String? { message }
}
View
struct ItemsView: View {
@State private var state: ItemsState
init(state: ItemsState) {
_state = State(initialValue: state)
}
var body: some View {
content
.task { await state.loadIfNeeded() }
}
@ViewBuilder
private var content: some View {
switch state.viewState {
case .idle, .loading:
ProgressView("Loading...")
case .loaded(let items):
List(items) { item in Text(item.title) }
case .empty:
ContentUnavailableView("No Items", systemImage: "tray")
case .error(let message):
ContentUnavailableView("Error", systemImage: "exclamationmark.triangle",
description: Text(message))
}
}
}
Previews
#Preview("Loaded") { ItemsView(state: ItemScenarios.loaded()) }
#Preview("Empty") { ItemsView(state: ItemScenarios.empty()) }
#Preview("Error") { ItemsView(state: ItemScenarios.error()) }
#Preview("Loading") { ItemsView(state: ItemScenarios.loading()) }
Tests (Swift Testing)
import Testing
struct ItemsStateTests {
@Test @MainActor func loadedState() async {
let state = ItemScenarios.loaded()
await state.load()
#expect(state.viewState == .loaded(ItemFixtures.list))
}
@Test @MainActor func emptyState() async {
let state = ItemScenarios.empty()
await state.load()
#expect(state.viewState == .empty)
}
@Test @MainActor func errorState() async {
let state = ItemScenarios.error()
await state.load()
#expect(state.viewState == .error("Something went wrong"))
}
}
Folder shape
Features/
Items/
ItemsView.swift
ItemsState.swift
ItemServicing.swift
LiveItemService.swift
MockItemService.swift
ItemFixtures.swift
ItemScenarios.swift
ItemsStateTests.swift
Quality bar
A feature is correct when:
- The view renders without constructing dependencies
- The state object depends only on protocols
- Mocks model behavior through scenarios
- Previews need zero networking or app bootstrap
- Tests use the same constructors as previews
- Fixtures are deterministic and reusable
- Scenario names map to actual UI states
Fix immediately
- Service creation inside a view
Taskdoing real work in the view body- Previews using live API clients
- Tests building ad-hoc mocks divergent from previews
- Fake data duplicated across files
- State objects depending on concrete SDK clients
- Singletons required to render a feature
Principle
Don't think "I need a mock for tests."
Think "I need a scenario system for this feature."
That system constructs service behavior, state transitions, preview states, and test states.
That is the pattern. That is the convergence.