Skip to content

Repository files navigation

Swift Testing — SwiftUI Store

An iPhone store app built as an architecture vehicle: a realistic catalog → detail → cart flow used to exercise a layered SwiftUI/SwiftData stack — a generic repository layer, a three-tier image cache, and a heterogeneous product model — without the noise of a real commerce backend (no auth, no payments, no orders).

Written in Swift 5/SwiftUI with no third-party dependencies. Everything below is first-party Apple frameworks.


Why this repository exists

The store app is the payload; the repository is the experiment. It was built to exercise three things at once:

1. A unit-testing practice. Tests are written with Swift Testing@Test / #expect, suites as plain structs — rather than XCTest, against an in-memory SwiftData store so no test ever touches disk. The architecture is shaped by that requirement: the repository layer takes an injected stack precisely so a test can hand it a memory-only one.

2. A target for Xcode Cloud. The project is kept deliberately plain for Apple's hosted CI — no SPM packages, no CocoaPods, no generated files, no scripted build steps. xcodebuild on a clean clone is the whole build, which is the property that makes a project cheap to run on managed CI.

3. Spec-driven development (SDD). Nothing here was written by opening a file and improvising. Each feature starts as a versioned spec in specs/ — one file per domain, decisions settled before code — which is then broken into a task backlog and implemented against that spec. The specs/ documents in this repository are the actual inputs the store app was built from, not documentation written afterwards.

The SDD workflow is driven by akios, a public open-source plugin for spec-driven development with coding agents. It supplies the pipeline — idea → spec → task backlog → implementation → review — while AGENTS.md, Context.md and CLAUDE.md at the repo root are this project's own operating manual: stack, commands, conventions, and which gate applies to which kind of task.


Stack

Language / UI Swift · SwiftUI (@Observable, NavigationStack)
Persistence SwiftData (@Model, @Query, ModelContainer)
Concurrency async/await, actor, @MainActor isolation
Tests Swift Testing (@Test / #expect) for units · XCTest for UI
Target iOS 17.6+, iPhone only (TARGETED_DEVICE_FAMILY = 1)
Dependencies none (no SPM/CocoaPods)

Running it

The project path and the scheme both contain a space — quote them.

open "Testing App/Testing App.xcodeproj"

Build:

xcodebuild build -project "Testing App/Testing App.xcodeproj" -scheme "Testing App" -destination 'generic/platform=iOS Simulator'

Test:

xcodebuild test -project "Testing App/Testing App.xcodeproj" -scheme "Testing App" -destination 'platform=iOS Simulator,name=iPhone 16'

No install step, no code generation, no lint config — clone and run.


Features

  • Catalog — two-column masonry grid over five product categories, with category chip filtering, a sort sheet (popularity / recency / price ↑↓) and fuzzy search.
  • Search — two-stage matching: exact substring first, then word-level Levenshtein with a length-scaled edit-distance threshold, so ceramik still finds Ceramic. Recent searches persist in UserDefaults.
  • Product detail — hero image with a zoomable full-screen viewer, per-category attribute section, add-to-cart.
  • Cart — sheet-presented, quantity editing, live total, empty state.
  • Product statusnew / onSale badges with a salePrice that drives effectivePrice everywhere (sorting, cart totals, price display).
  • Engagement — a ProductEngagement record per product feeds the popularity sort.

Architecture

Three layers, enforced by folder and by dependency direction — the presentation layer never touches ModelContext directly.

Presentation Layer/     SwiftUI views, @Observable view models, routing
        ↓
Domain Layer/           @Model product types + the ProductDisplayable protocol
        ↓
Data Layer/             DIContainer · repositories · PersistenceStack · image cache

Domain: one protocol, five concrete models

Paintings, sculptures, ceramics, jewelry and clothing have genuinely different attribute shapes, so they are five separate @Model types unified by ProductDisplayable:

protocol ProductDisplayable: AnyObject {
    var id: String { get set }
    var price: Decimal { get set }
    var salePrice: Decimal? { get set }
    var status: ProductStatus { get set }
    // …
    func displayAttributes() -> [(label: String, value: String)]
}

Views consume [any ProductDisplayable] and render displayAttributes() generically, so adding a sixth category means adding a model — not touching the catalog UI.

One deliberate subtlety, documented at the declaration: Identifiable is not listed as a requirement. PersistentModel already provides it non-isolated; re-declaring it on a @MainActor protocol creates an isolated duplicate that breaks conformance.

Data: generic repository over SwiftData

SwifDataRepository is a @MainActor protocol with an associated Model: PersistentModel and a default CRUD implementation (Repository+CRUD.swift), so each concrete repository — cart, engagement, and one per product type — is a few lines of type declaration plus whatever domain queries it actually needs.

The stack is valid or it does not exist. PersistenceStack exposes container and context as non-optional, because its initialiser throws when SwiftData cannot open the store. Holding a stack is therefore proof that the store is open: no call site branches on a nil context, no write can silently degrade into a no-op, and the app never force-unwraps a container. In-memory versus on-disk is chosen by which initialiser you call rather than by a boolean flag, so a production call site cannot accidentally end up with a throwaway store.

Failure has one owner. DataStoreService is the only place that opens a store: it resolves the store URL, runs the schema-version guard, recovers from a store that will not open (wipe the file and its -shm/-wal companions, then retry once), seeds an empty store, and hands the resulting stack to a DIContainer. Everything that can fail lives in that one readable sequence. Its inMemory() and inMemorySeeded() doors build the same graph over a memory-only store — which is what tests and previews use, so nothing under test ever touches the on-disk store.

DIContainer is then a passive composition root with a non-throwing initialiser: it takes an already-open stack, hands the same stack to every repository, and exposes the same ModelContainer to .modelContainer() — so @Query-driven views and repository writes observe one context rather than diverging.

The launch outcome is kept as a Result at the app entry point. A device that genuinely cannot open a store — full disk, restricted container — gets a screen with the error and a retry button instead of a crash report.

@State private var store = Result { try DataStoreService.load() }

Image caching

ImageCaching is an actor singleton with a three-tier fetch strategy:

L1  NSCache (~100 MB, in memory)
 ↓  miss
L2  SwiftData store (separate container, "image-cache")
 ↓  miss
L3  URLSession

Concurrent requests for the same URL are deduplicated through an activeTasks: [URL: Task] map, so a URL is never downloaded twice in parallel. Actor isolation makes that map safe without locks; the SwiftData hops are @MainActor because the persistence layer is.

Card layout takes the matching care: an image that is still loading reserves space with a shimmer at an estimated ratio, and a loaded image is .scaledToFit() with no enclosing fixed ratio — so real photos keep their true proportions instead of being cropped to a uniform grid cell.


Tests

22 @Test cases across nine Swift Testing suites, covering the parts where a regression would be silent:

  • displayAttributes() for each of the five product types
  • repository CRUD round-trips (PaintingRepositoryTests) against an in-memory stack
  • cart behaviour — add-or-increment, quantity increment, total price, product resolution
  • ProductStatus / effectivePrice / isOnSale invariants
  • engagement counters — view/cart-add increments and the derived popularity score

UI tests stay in XCTest (Testing AppUITests); unit tests stay in Swift Testing. The two are never mixed.


Layout

Testing App/Testing App/
├── Presentation Layer/
│   ├── Routing/            App entry, ContentView, cart toolbar
│   ├── Catalog/            Grid, chips, sort & filter sheets, search, product card
│   ├── Product Detail/     Hero image, attributes, image viewer, add-to-cart
│   ├── Cart/               CartView + CartViewModel + row/empty state
│   └── Components/         Shared (PriceView)
├── Domain Layer/           5 @Model types, ProductDisplayable, engagement, sort options
├── Data Layer/
│   ├── DataStoreService         Store lifecycle: open, recover, seed
│   ├── DIContainer              Composition root (repositories)
│   ├── Swift Data Repository/   PersistenceStack, protocol, generic CRUD
│   ├── Model Repositories/      One per model type + cart + engagement
│   └── Image Caching/           actor ImageCaching, ImageModel, ImageRepository
└── Assets/                 SeedData

Known limitations

Stated plainly, since they are choices rather than oversights:

  • Seed data only — products come from SeedData.swift; there is no backend, so imageAspectRatio is wired through the model layer but left nil everywhere.
  • No ViewModel for the catalog — filtering and sorting live in CatalogView computed properties.

Repository notes

The app is entirely under Testing App/. Everything else at the git root belongs to the SDD workflow described above: specs/ holds the versioned specs each feature was built from, tasks/ the backlog folders work moves through, and AGENTS.md / Context.md / CLAUDE.md the operating manual. Memory.md is the decision log — including the approaches that were tried, rejected, and why, so a later reader does not relitigate them.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages