# Swiftui Preview Convergence

> 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.

- Skill: `jordancoin/swiftui-preview-convergence` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add jordancoin/swiftui-preview-convergence`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jordancoin/swiftui-preview-convergence/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: jordancoin (https://skillmd.com/u/jordancoin)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jordancoin/swiftui-preview-convergence

---


# 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

```swift
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:
```swift
final class MockItemService: ItemServicing {
    var items: [Item] = []
    func fetchItems() async throws -> [Item] { items }
}
```

Good:
```swift
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

```swift
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

```swift
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

```swift
struct Item: Identifiable, Equatable, Sendable {
    let id: UUID
    let title: String
}
```

### Protocol

```swift
protocol ItemServicing: Sendable {
    func fetchItems() async throws -> [Item]
}
```

### Live service

```swift
struct LiveItemService: ItemServicing {
    func fetchItems() async throws -> [Item] {
        // real implementation
        []
    }
}
```

### Mock service

```swift
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

```swift
@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)

```swift
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

```swift
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

```swift
#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)

```swift
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.

