| name | swiftui-preview-convergence |
| description | Architecture pattern where one scenario system powers SwiftUI previews, tests, and production. Use when creating or refactoring views, state objects, mocks, fixtures, or dependency injection. Forces clean separation by making previews the design linter. macOS 26+, iOS 26+, Swift 6. |
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
@Observable state 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
.task lifecycle 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 one = Item(
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] {
[]
}
}
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
Task doing 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.