Many first SwiftUI tutorials stop once the app appears in Xcode’s preview canvas. That leaves out the parts you need before an app is real: data that survives a relaunch, tests that prove it, and a way to build and run the whole thing without clicking through the IDE.

This article builds a to-do list app for iPhone with SwiftUI, Apple’s declarative UI framework, and SwiftData, Apple’s framework for saving Swift objects to an on-device database. The result is an Xcode project that you build, test, and launch in the iOS Simulator with three make commands. Its test suite includes a UI test that adds a to-do, quits the app, relaunches it, and checks the to-do is still there. You will run that test, then open the app and see the saved item yourself.

Three things go wrong along the way, each a common mistake in a first app like this: a test that crashes because its model container was freed, an item that disappears because SwiftData had not saved it yet, and a make target that reports success while its tests fail. Each is explained in the step where it would bite, and the code you write already has the fix.

What you end up with

  • todo-list/, a folder holding an Xcode project named TodoList, created with Xcode’s own project template, its project file kept as Xcode wrote it.
  • A SwiftUI app with a list screen and an edit screen, storing its to-dos with SwiftData.
  • Five unit tests written with Swift Testing, Apple’s newer test framework, and one UI test written with XCTest, the older one, all run from the terminal.
  • A Makefile whose default target prints help, with targets that create a simulator, build, test, launch the app, and take a screenshot.

Prerequisites

  • A Mac running macOS 26.6 or later with Apple silicon, the minimum Xcode 27.1 launches on. Validated on macOS 27.0.1.
  • Xcode 27 or later from the Mac App Store or Apple’s developer downloads. Validated on Xcode 27.1 (build 27A9275), which ships Swift 6.4. Open Xcode once after installing it so it can finish its first-launch setup. The project steps rely on Xcode 27 behavior: the New Project palette, and the Device Hub app that replaced Simulator.
  • About 20 GB of free disk space: the iOS Simulator runtimes Step 1 downloads take about 16 GB once installed.
  • Basic Swift: structs, classes, closures, and optionals. If those are new, The Swift Programming Language covers them.
  • Basic make: what a target and a recipe are. The GNU Make manual’s introduction covers both. macOS includes make with Xcode.
  • No Apple Developer account. Apps you build for the simulator do not need to be code-signed with a developer certificate.

Step 1: Install the iOS Simulator runtime

The iOS Simulator runs iPhone apps on your Mac, but Xcode does not include the iOS system image it needs. That image is a simulator runtime, a disk image of iOS for a given version, and without one there is no simulated iPhone to build for, test on, or launch. Xcode’s first-launch screen offers to download it; xcodebuild, Xcode’s command-line build tool, does the same thing from the terminal, which is the form you would use on a build machine.

xcodebuild -downloadPlatform iOS

The download takes a while. The command redraws a progress line for each runtime it fetches (Downloading iOS 27.1 Simulator (24A94425) (arm64): 42.8% (3.36 GB of 7.85 GB)) and ends by listing what it installed:

iOS 27.0 (24A434) - 2E5BD21F-4B8F-4D9F-B4C8-DF9C9779DC3B
iOS 27.1 (24A94425) - 0B6FE6EA-29BC-43A4-AF3F-6EFB397B6E05

The identifiers after each build number differ on your machine.

Confirm that xcrun simctl, the command-line tool for managing simulators, can see the new runtimes (xcrun runs a tool from the active Xcode):

xcrun simctl list runtimes
== Runtimes ==
iOS 27.0 (27.0 - 24A434) - com.apple.CoreSimulator.SimRuntime.iOS-27-0
iOS 27.1 (27.1 - 24A94425) - com.apple.CoreSimulator.SimRuntime.iOS-27-1

On Xcode 27.1 you get two runtimes, about 16 GB together. The iOS 27.1 runtime here is a build for one new device, the iPhone Duo, and it refuses every other model: asking it for an iPhone 17 fails with Incompatible device. iOS 27.0 runs every other current iPhone. Step 4 creates its simulator without naming a runtime, so the simulator tools pick the newest one that supports the device. Which runtimes you get depends on your Xcode version, so your list may differ.

Step 2: Create the project folder and its .gitignore

Everything in this article lives in one folder, todo-list. The Xcode project will sit in a subfolder of it, with the Makefile beside it at the top. Create the folder and its .gitignore before anything else, so the build output and Xcode’s per-user files the next steps generate are never committed.

Create the file

mkdir -p todo-list
cd todo-list
touch .gitignore

Add the code: .gitignore

# Xcode build output (make builds into ./build)
build/
DerivedData/

# Per-user Xcode state
xcuserdata/
*.xcuserstate

# Swift Package Manager
.swiftpm/
.build/

# Test results and screenshots
*.xcresult
screenshots/

# macOS
.DS_Store

Detailed breakdown

  • build/ is where every make target puts its output (Step 4 explains why it is not Xcode’s default location). DerivedData/ is the name of Xcode’s own build-output folder, ignored in case you point it at the project.
  • xcuserdata/ and *.xcuserstate hold per-user state Xcode writes as you work: which files were open, window layout, breakpoints. They differ for everyone who opens the project and are never worth committing.
  • .swiftpm/ and .build/ are the Swift Package Manager’s working folders. Nothing in this article creates them, but adding a Swift package to the project later would.
  • *.xcresult bundles hold detailed test results, and screenshots/ is where make screenshot writes in Step 11.

Run every command from the rest of this article inside todo-list.

Step 3: Create the Xcode project from the App template

An Xcode project is a folder ending in .xcodeproj that records which source files exist, how to compile them, and what to produce. You could write one by hand, but the format is undocumented and Xcode is the tool that maintains it, so create it with Xcode’s template and treat the result as Xcode’s file.

  1. Open Xcode and choose File → New → Project… (Shift-Command-N). Xcode 27 shows a small palette, not the template grid.

  2. Do not pick App in the palette. In Xcode 27 that entry creates an untitled project straight away, with no product name, no test targets, and every Apple platform selected. Choose Choose Template… at the bottom instead.

  3. In the template chooser, select the iOS tab, then App, then Next.

  4. Fill in the options:

    OptionValue
    Product NameTodoList
    TeamNone (leave Add account… alone)
    Organization Identifiercom.example
    InterfaceSwiftUI
    LanguageSwift
    Testing SystemSwift Testing with XCTest UI Tests
    StorageNone

    The bundle identifier below the fields becomes com.example.TodoList. It is the app’s unique ID on a device, and the Makefile uses it to launch the app. Leave Storage at None: choosing SwiftData there adds sample code you would only delete, and Step 5 adds SwiftData by hand. Xcode remembers the organization identifier, so set it back to your own the next time you create a project.

  5. Click Next. In the save panel, open your todo-list folder, uncheck “Create Git repository on my Mac”, and click Create. The checkbox would start a repository inside TodoList/, leaving out the .gitignore from Step 2 and the Makefile from Step 4. If you want version control, run git init in todo-list instead.

Close the project window, then look at what Xcode wrote:

find TodoList -not -path '*xcuserdata*' | sort
TodoList
TodoList/TodoList
TodoList/TodoList.xcodeproj
TodoList/TodoList.xcodeproj/project.pbxproj
TodoList/TodoList.xcodeproj/project.xcworkspace
TodoList/TodoList.xcodeproj/project.xcworkspace/contents.xcworkspacedata
TodoList/TodoList.xcodeproj/project.xcworkspace/xcshareddata
TodoList/TodoList.xcodeproj/project.xcworkspace/xcshareddata/swiftpm
TodoList/TodoList.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/configuration
TodoList/TodoList/Assets.xcassets
TodoList/TodoList/Assets.xcassets/AccentColor.colorset
TodoList/TodoList/Assets.xcassets/AccentColor.colorset/Contents.json
TodoList/TodoList/Assets.xcassets/AppIcon.appiconset
TodoList/TodoList/Assets.xcassets/AppIcon.appiconset/Contents.json
TodoList/TodoList/Assets.xcassets/Contents.json
TodoList/TodoList/ContentView.swift
TodoList/TodoList/TodoListApp.swift
TodoList/TodoListTests
TodoList/TodoListTests/TodoListTests.swift
TodoList/TodoListUITests
TodoList/TodoListUITests/TodoListUITests.swift

Targets, schemes, and the folders Xcode watches

Ask xcodebuild, Xcode’s command-line build tool, what the project contains:

xcodebuild -list -project TodoList/TodoList.xcodeproj
Information about project "TodoList":
    Targets:
        TodoList
        TodoListTests
        TodoListUITests

    Build Configurations:
        Debug
        Release

    If no build configuration is specified and -scheme is not passed then "Release" is used.

    Schemes:
        TodoList

A target is one thing the project produces. TodoList is the app. TodoListTests is a bundle of unit tests that run inside the app’s process, and TodoListUITests is a bundle of UI tests that run in a separate process and drive the app from outside, the way a person would. A scheme says which targets to build, run, and test together; the TodoList scheme covers all three. Xcode generates it on the fly when none is saved in the project, which is why it works from the terminal on a fresh copy. Debug and Release are the two build configurations: sets of compiler settings, with Debug favoring fast builds and easy debugging.

Each of the three folders inside TodoList/ is a folder-synchronized group: Xcode treats every file in the folder as part of its target. A Swift file you create in TodoList/TodoList/ with touch joins the app the next time you build, with no change to project.pbxproj. That is what lets the following steps add files from the terminal.

The template also sets two compiler options that explain errors you could otherwise hit:

  • The app target runs code on the main actor by default (SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor). An actor is Swift’s unit of isolation for concurrent code, and the main actor is the main thread. The compiler treats code in the app as running there unless you say otherwise, which suits UI code. The two test targets do not have this setting; Step 9 shows the consequence.
  • Each file must import the modules it uses (SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES). Without the setting, one file’s import SwiftData quietly made SwiftData’s methods available in other files that only import SwiftUI. With it, they are not, and Step 7 needs an import because of this.

Step 4: Build from the terminal with a Makefile

Xcode’s Run button does three jobs at once: it builds for a chosen device, boots that simulated device, then installs and launches the app. Doing them from the terminal takes three different tools and several flags, so a Makefile records them once. Every later step runs through it, and so can a build machine that never opens Xcode.

Create the file

touch Makefile

Add the code: Makefile

PROJECT     := TodoList/TodoList.xcodeproj
SCHEME      := TodoList
BUNDLE_ID   := com.example.TodoList
SIM_NAME    := TodoList-iPhone
DEVICE_TYPE := iPhone 17
DERIVED     := build
APP         := $(DERIVED)/Build/Products/Debug-iphonesimulator/TodoList.app
DESTINATION := platform=iOS Simulator,name=$(SIM_NAME)
# Keep only result lines from xcodebuild's test log: Swift Testing (✔/✘),
# XCTest ("Test Case ... passed"), errors, and the final verdict.
RESULTS     := grep -E '(✔|✘) Test|Test Case .*(passed|failed)|error:|\*\* TEST'
XCODEBUILD  := xcodebuild -project $(PROJECT) -scheme $(SCHEME) \
               -destination '$(DESTINATION)' -derivedDataPath $(DERIVED)
TEST_FLAGS  := -parallel-testing-enabled NO -collect-test-diagnostics never

# bash, so the test recipes can use `set -o pipefail` (see below).
SHELL         := /bin/bash
.DEFAULT_GOAL := help
.PHONY: help simulator build test unit-test run screenshot clean delete-simulator

help: ## Show this help
	@echo "Usage: make <target>"
	@echo
	@grep -E '^[a-z-]+:.*## ' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*## "}; {printf "  %-17s %s\n", $$1, $$2}'

simulator: ## Create the TodoList-iPhone simulator if it is missing
	@xcrun simctl list devices available | grep -q "$(SIM_NAME) (" || \
		xcrun simctl create "$(SIM_NAME)" "$(DEVICE_TYPE)"

build: simulator ## Build the app for the simulator
	$(XCODEBUILD) -quiet build

# pipefail makes `xcodebuild ... | grep` fail when xcodebuild fails. It is set
# inside the recipe because macOS ships GNU Make 3.81, which ignores .SHELLFLAGS.
test: simulator ## Run the unit tests and the UI test
	set -o pipefail; $(XCODEBUILD) $(TEST_FLAGS) test 2>&1 | $(RESULTS)

unit-test: simulator ## Run only the unit tests (no UI test)
	set -o pipefail; $(XCODEBUILD) $(TEST_FLAGS) -only-testing:TodoListTests test 2>&1 | $(RESULTS)

run: build ## Boot the simulator, install the app, and launch it
	xcrun simctl bootstatus "$(SIM_NAME)" -b
	open "$$(xcode-select -p)/../Applications/DeviceHub.app"
	xcrun simctl install "$(SIM_NAME)" "$(APP)"
	xcrun simctl launch "$(SIM_NAME)" $(BUNDLE_ID)

screenshot: ## Save a screenshot of the simulator to screenshots/
	@mkdir -p screenshots
	xcrun simctl io "$(SIM_NAME)" screenshot screenshots/todolist.png

clean: ## Delete the build folder
	rm -rf $(DERIVED)

delete-simulator: ## Delete the TodoList-iPhone simulator
	xcrun simctl delete "$(SIM_NAME)"

Recipe lines must start with a tab, not spaces. If you paste the listing and make reports missing separator, your editor converted the tabs.

Detailed breakdown

  • Each make target is a name you run as make <name>, followed by its recipe, the shell lines it runs. To keep the two kinds of target apart, this article says “make target” for these and “target” alone for Xcode’s.
  • simulator gives the project its own simulator: one simulated device with a name, a model, and a runtime. This one is an iPhone 17 named TodoList-iPhone, created with xcrun simctl create. Device Hub, later, is only the app that displays it. The device names Xcode creates on its own vary by Xcode version (Xcode 27.1 created only an iPhone Duo here), so depending on one would make the Makefile work on one machine and fail on the next. The make target creates the simulator only if simctl list devices available does not already show it, and leaves out the runtime so simctl picks the newest one that supports an iPhone 17, as Step 1 described.
  • XCODEBUILD holds the flags every build and test shares. -scheme picks the scheme from Step 3. -destination names the device to build for, and must name a device that exists, which is why the build and test targets depend on simulator. -derivedDataPath build keeps the build output in the project folder. Xcode’s default is a folder under ~/Library/Developer/Xcode/DerivedData with a generated suffix, and run needs a fixed path to find the finished app.
  • build passes -quiet, which prints only warnings and errors.
  • test and unit-test do the opposite: they need the per-test results, which -quiet hides. So they keep xcodebuild’s full log and pipe it through RESULTS, a grep that keeps the lines saying which test passed or failed. Swift Testing marks a pass with ✔ and a failure with ✘; the UI test, which uses the older XCTest framework, prints Test Case ... passed.
  • The piping needs pipefail. A shell pipeline normally exits with the status of its last command, here grep, and grep succeeds whenever it finds a matching line, including the lines reporting a failure. Without pipefail, make test exits 0 while tests fail. The usual way to set it for a whole Makefile is .SHELLFLAGS := -o pipefail -c, but .SHELLFLAGS arrived in GNU Make 3.82, and macOS ships 3.81 (make --version shows it). Make 3.81 ignores the variable silently. Setting pipefail at the start of the recipe line works on every version. That is also why SHELL is bash. On macOS /bin/sh runs bash as well and accepts pipefail, but naming bash keeps the recipes working where sh is a different shell.
  • TEST_FLAGS holds two flags only the test make targets use. -collect-test-diagnostics never stops xcodebuild from collecting simulator diagnostics when a test fails. Without it, two failing runs during review each sat for about 10 minutes in simctl diagnose after printing their results; with it, failing runs here finished in 4 to 5 seconds.
  • -parallel-testing-enabled NO keeps all tests on TodoList-iPhone. By default xcodebuild clones the simulator and spreads the tests across the copies, running them at the same time (here, one copy per test bundle). For two small test bundles that saves nothing, and while validating this article the clone hosting the unit tests sometimes failed to launch the app (Failed to install or launch the test runner; the test runner is the process that hosts a test bundle).
  • run waits for the simulator to finish booting (bootstatus -b boots it first if needed), opens Device Hub so you can see it, installs the built .app, and launches it by bundle identifier. In Xcode 27, Device Hub replaced the Simulator app, and it lives inside Xcode’s bundle rather than in /Applications. xcode-select -p prints the active Xcode’s Developer folder, so the path works wherever Xcode is installed. The $$ makes make pass a literal $ to the shell.
  • help collects every target that has a ## description comment, and .DEFAULT_GOAL := help makes it what plain make runs.

Run make with no arguments to check the help screen:

make
Usage: make <target>

  help              Show this help
  simulator         Create the TodoList-iPhone simulator if it is missing
  build             Build the app for the simulator
  test              Run the unit tests and the UI test
  unit-test         Run only the unit tests (no UI test)
  run               Boot the simulator, install the app, and launch it
  screenshot        Save a screenshot of the simulator to screenshots/
  clean             Delete the build folder
  delete-simulator  Delete the TodoList-iPhone simulator

Then build the untouched template, which also creates the simulator:

make build
No runtime specified, using 'iOS 27.0 (27.0 - 24A434) - com.apple.CoreSimulator.SimRuntime.iOS-27-0'
ADED6804-7A8D-4BCC-A43F-897446B5AA35
xcodebuild -project TodoList/TodoList.xcodeproj -scheme TodoList -destination 'platform=iOS Simulator,name=TodoList-iPhone' -derivedDataPath build -quiet build
2026-10-23 08:30:34.139 xcodebuild[16224:245850] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.

The first line shows simctl choosing iOS 27.0, and the second is the new simulator’s identifier, which is different on every machine. On later runs the simulator exists and those two lines do not appear. The line tagged [MT] is an internal Xcode log message, not a build failure: -quiet reported no errors, and the app is in build/Build/Products/Debug-iphonesimulator/TodoList.app. You will see [MT] lines from make build and make run throughout; they are safe to ignore.

Step 5: Define the to-do model with SwiftData

The app stores one kind of thing, a to-do with a title and a done flag. With SwiftData you describe it as an ordinary Swift class marked @Model, and SwiftData handles the table, the columns, and loading saved rows back into objects. This file also holds the rules the screens and the tests share, so they live in one place.

Create the file

touch TodoList/TodoList/TodoItem.swift

Add the code: TodoList/TodoList/TodoItem.swift

import Foundation
import SwiftData

@Model
final class TodoItem {
    var title: String
    var isDone: Bool
    var createdAt: Date

    init(title: String, createdAt: Date = .now) {
        self.title = title
        self.isDone = false
        self.createdAt = createdAt
    }

    /// Trims surrounding whitespace and returns nil when nothing is left,
    /// so a blank entry can never become a to-do.
    static func cleanedTitle(_ raw: String) -> String? {
        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : trimmed
    }

    /// The line shown under the list: what is left to do.
    static func summary(of items: [TodoItem]) -> String {
        let remaining = items.filter { !$0.isDone }.count
        if items.isEmpty { return "Nothing to do" }
        if remaining == 0 { return "All done" }
        return "\(remaining) remaining"
    }
}

extension ModelContext {
    /// Inserts a new to-do if the title is not blank, and returns it.
    @discardableResult
    func addTodo(titled raw: String) -> TodoItem? {
        guard let title = TodoItem.cleanedTitle(raw) else { return nil }
        let item = TodoItem(title: title)
        insert(item)
        return item
    }
}

Detailed breakdown

  • @Model is a macro: at compile time it rewrites the class so SwiftData can save each stored property, title, isDone, and createdAt, as a column. It must be a class, not a struct, because SwiftData tracks each to-do as one shared object that the list screen and the edit screen both point at.
  • @Model also makes the class observable. It applies the same Observation support as Swift’s @Observable macro, so a SwiftUI view that reads item.title redraws when the title changes. Step 7 relies on this: the edit screen changes an item, and the list updates with no extra code.
  • createdAt exists to give the list a stable order. Step 8 sorts by it, so new to-dos appear at the bottom.
  • cleanedTitle(_:) is the one rule for what counts as a title. The list screen uses it to disable the Add button for blank input, and addTodo uses it to refuse a blank title, so the two can never disagree.
  • summary(of:) produces the line under the list, Nothing to do, All done, or 2 remaining. It is a plain function of the items, so the unit tests check it without any UI.
  • addTodo(titled:) extends ModelContext. A model context is SwiftData’s scratch space for objects: you insert, change, and delete objects in a context, and saving the context writes those changes to disk. insert(_:) only registers the new object with the context; it is not on disk yet, a detail Step 8 comes back to.

Build to check the model compiles. As in Step 4, a good build prints the command and the [MT] line and nothing else:

make build

Step 6: Open the SwiftData store in the app entry point

A model context needs somewhere to write. That is the model container: the object that owns the store, SwiftData’s database file on the device, and hands out contexts. The app creates one container when it starts and shares it with every view. This step replaces the template’s entry point, which Xcode created in Step 3, so the container exists before any screen needs it.

Create the file

touch TodoList/TodoList/TodoListApp.swift

The file already exists. Replace its whole contents with the listing.

Add the code: TodoList/TodoList/TodoListApp.swift

import SwiftUI
import SwiftData

@main
struct TodoListApp: App {
    let container: ModelContainer

    init() {
        do {
            container = try ModelContainer(for: TodoItem.self)
        } catch {
            fatalError("Could not open the to-do store: \(error)")
        }
        // UI tests pass this flag to start from an empty list.
        if ProcessInfo.processInfo.arguments.contains("-reset-data") {
            try? container.mainContext.delete(model: TodoItem.self)
            try? container.mainContext.save()
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

Detailed breakdown

  • @main marks the type the app starts from. body returns a WindowGroup, the scene that shows ContentView full screen on an iPhone.
  • ModelContainer(for: TodoItem.self) opens, or creates on first launch, a database file in the app’s private storage on the simulator, with a table for TodoItem. It throws if the file cannot be opened. Without the store the app cannot do anything useful, so fatalError stops it with the reason rather than running in a broken state.
  • The container is a stored property of the app, so it lives as long as the app does. Step 9 shows what happens when a container is not kept alive.
  • -reset-data is a launch argument the UI test passes in Step 10 so it always starts from an empty list. delete(model:) removes every saved TodoItem; Apple documents the removal as happening at the next save, so the app saves straight away rather than leave an old list to reappear after a crash. (In testing here the deletion was on disk even without the save, but the documented behavior is the one to rely on.) Launching the app normally never passes the flag, so your own to-dos are untouched.
  • .modelContainer(container) puts the container into the SwiftUI environment, the set of values a view can read from anything above it. Every view below the window can then get the container’s main context without passing it along by hand.
make build

Step 7: Build the edit screen

Tapping a to-do in the list will open a screen where you can rename it and mark it done. This screen is short, but it shows how SwiftUI edits a stored object in place, and it needs one import the template’s settings make mandatory. Writing it before the list screen means every step ends with a project that builds, because the list screen refers to this one.

Create the file

touch TodoList/TodoList/TodoDetailView.swift

Add the code: TodoList/TodoList/TodoDetailView.swift

import SwiftUI
import SwiftData

struct TodoDetailView: View {
    @Bindable var item: TodoItem

    var body: some View {
        Form {
            TextField("Title", text: $item.title)
                .accessibilityIdentifier("titleField")
            Toggle("Done", isOn: $item.isDone)
                .accessibilityIdentifier("doneToggle")
        }
        .navigationTitle("Edit")
        .navigationBarTitleDisplayMode(.inline)
        .onDisappear {
            try? item.modelContext?.save()
        }
    }
}

Detailed breakdown

  • @Bindable lets a view create bindings to an observable object’s properties. A binding, written $item.title, is a two-way connection: the text field shows the current title, and each keystroke writes straight back into the TodoItem. No copy is made, so the list screen sees the change as you type.
  • .accessibilityIdentifier gives a control a fixed ID that the UI test in Step 10 uses to find it. Identifiers are not shown to the user, so they do not change when you reword the screen.
  • .onDisappear saves when you leave the screen. An inserted SwiftData object exposes its context as item.modelContext. Step 8 explains why the app saves explicitly.
  • import SwiftData is needed even though the view itself is pure SwiftUI: save() is a SwiftData method. With the member-import setting from Step 3, leaving the import out fails the build with Instance method 'save()' is not available due to missing import of defining module 'SwiftData'.
make build

Step 8: Build the list screen

The list screen is the app’s main screen: a text field and Add button at the top, the to-dos below, and the summary line under them. It also decides when changes reach the disk, and so whether a crash right after a change loses that change. This file replaces the template’s ContentView.swift.

Create the file

touch TodoList/TodoList/ContentView.swift

The file already exists. Replace its whole contents with the listing.

Add the code: TodoList/TodoList/ContentView.swift

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \TodoItem.createdAt) private var items: [TodoItem]
    @State private var newTitle = ""

    var body: some View {
        NavigationStack {
            List {
                Section {
                    HStack {
                        TextField("New to-do", text: $newTitle)
                            .accessibilityIdentifier("newTodoField")
                            .onSubmit(add)
                        Button("Add", action: add)
                            .accessibilityIdentifier("addTodoButton")
                            .disabled(TodoItem.cleanedTitle(newTitle) == nil)
                    }
                }
                Section {
                    ForEach(items) { item in
                        NavigationLink(value: item) {
                            TodoRow(item: item)
                        }
                    }
                    .onDelete(perform: delete)
                } footer: {
                    Text(TodoItem.summary(of: items))
                        .accessibilityIdentifier("summary")
                }
            }
            .navigationTitle("To-Do")
            .navigationDestination(for: TodoItem.self) { item in
                TodoDetailView(item: item)
            }
        }
    }

    private func add() {
        if modelContext.addTodo(titled: newTitle) != nil {
            newTitle = ""
            save()
        }
    }

    private func delete(at offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(items[index])
        }
        save()
    }

    // SwiftData autosaves only now and then; save explicitly so a
    // crash or force-quit right after a change does not lose it.
    private func save() {
        do {
            try modelContext.save()
        } catch {
            print("Save failed: \(error)")
        }
    }
}

struct TodoRow: View {
    let item: TodoItem

    var body: some View {
        Label {
            Text(item.title)
                .strikethrough(item.isDone)
                .foregroundStyle(item.isDone ? .secondary : .primary)
        } icon: {
            Image(systemName: item.isDone ? "checkmark.circle.fill" : "circle")
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: TodoItem.self, inMemory: true)
}

Detailed breakdown

  • Three property wrappers feed the view. A property wrapper is an attribute that gives a property extra behavior, here loading and updating its value. @Environment(\.modelContext) reads the main context from the container Step 6 placed in the environment. @Query fetches every TodoItem, sorted by createdAt, and keeps items current: when an item is inserted, changed, or deleted in the context, the query updates and the view redraws. @State holds a value the view owns, here the text being typed, and redraws the view when it changes.
  • NavigationStack gives the screen a title bar and the ability to push screens. NavigationLink(value: item) makes each row tappable, and .navigationDestination(for: TodoItem.self) says which screen to push for a tapped item: the edit screen from Step 7. Because TodoItem is observable, the strikethrough appears in the list as soon as you flip the Done switch on the edit screen.
  • Adding happens from the Add button or the Return key (onSubmit), and the button is disabled while cleanedTitle returns nil, so a blank entry cannot be submitted.
  • Deleting uses .onDelete, which gives every row the standard swipe-left Delete button. It hands back the row positions, which map to objects in items.
  • footer shows the summary line from Step 5, tagged summary for the UI test.
  • #Preview draws the screen in Xcode’s preview canvas with an in-memory container, so previews never touch your saved data.

Why the app calls save() itself

SwiftData autosaves: the main context writes pending changes to disk on its own from time to time. You do not control when. If the app is killed before the next autosave (by a crash, by force-quitting from the app switcher, or by Xcode or a test stopping it) the change is lost, with no error anywhere.

The UI test in Step 10 does exactly this: it adds a to-do, terminates the app, and relaunches it. While validating this article, that test failed twice out of two runs with the save() calls disabled (the to-do was gone after the relaunch) and passed with them in place. So the app saves after each add and delete, and the edit screen saves when you leave it. save() can throw, for example if the disk is full; this first version only prints the error.

Build, then launch the app:

make build
make run
xcrun simctl bootstatus "TodoList-iPhone" -b
Monitoring boot status for TodoList-iPhone (ADED6804-7A8D-4BCC-A43F-897446B5AA35).
Booting device...
open "$(xcode-select -p)/../Applications/DeviceHub.app"
xcrun simctl install "TodoList-iPhone" "build/Build/Products/Debug-iphonesimulator/TodoList.app"
xcrun simctl launch "TodoList-iPhone" com.example.TodoList
com.example.TodoList: 16901

make run also prints the build command, its [MT] line, and boot progress lines (Waiting on Data Migration, repeated, on a first boot); the excerpt above leaves those out. The last line is the bundle identifier and the process ID of the running app, which differs on every launch. Device Hub opens with TodoList-iPhone selected and the app on screen. Add a few to-dos, tap one, flip Done, go back, and swipe one away. The summary line updates each time.

You can also open TodoList/TodoList.xcodeproj in Xcode, choose TodoList-iPhone in the run destination menu at the top of the window, and press Command-R. Xcode builds into its own DerivedData folder rather than build/, but runs the same app on the same simulator.

Step 9: Unit-test the model with Swift Testing

Swift Testing is Apple’s newer test framework, the one the template selected for TodoListTests. A test is a function marked @Test, and checks are written with the #expect macro. This step replaces the template’s empty example with tests for the title rules, the summary line, and addTodo. One of them crashed in its first version, and the cause is a SwiftData mistake that is easy to make in tests.

Create the file

touch TodoList/TodoListTests/TodoListTests.swift

The file already exists. Replace its whole contents with the listing.

Add the code: TodoList/TodoListTests/TodoListTests.swift

import Testing
import SwiftData
@testable import TodoList

@MainActor
struct TodoListTests {
    // Keep the container itself: a ModelContext does not keep its
    // container alive, and inserting after it is freed crashes.
    let container: ModelContainer
    var context: ModelContext { container.mainContext }

    init() throws {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        container = try ModelContainer(for: TodoItem.self, configurations: config)
    }

    @Test(arguments: [
        ("Buy milk", "Buy milk"),
        ("  Walk the dog  ", "Walk the dog"),
        ("\nCall Sam\t", "Call Sam"),
    ])
    func titlesAreTrimmed(raw: String, expected: String) {
        #expect(TodoItem.cleanedTitle(raw) == expected)
    }

    @Test(arguments: ["", "   ", "\n\t"])
    func blankTitlesAreRejected(raw: String) {
        #expect(TodoItem.cleanedTitle(raw) == nil)
    }

    @Test func addTodoStoresTheCleanedTitle() throws {
        let item = try #require(context.addTodo(titled: "  Pay rent "))
        #expect(item.title == "Pay rent")
        #expect(item.isDone == false)
        #expect(try context.fetchCount(FetchDescriptor<TodoItem>()) == 1)
    }

    @Test func addTodoIgnoresBlankTitles() throws {
        #expect(context.addTodo(titled: "   ") == nil)
        #expect(try context.fetchCount(FetchDescriptor<TodoItem>()) == 0)
    }

    @Test func summaryCountsOnlyUnfinishedItems() throws {
        #expect(TodoItem.summary(of: []) == "Nothing to do")

        let milk = try #require(context.addTodo(titled: "Buy milk"))
        let rent = try #require(context.addTodo(titled: "Pay rent"))
        _ = context.addTodo(titled: "Call Sam")
        milk.isDone = true

        let items = try context.fetch(FetchDescriptor<TodoItem>())
        #expect(TodoItem.summary(of: items) == "2 remaining")

        rent.isDone = true
        #expect(TodoItem.summary(of: items) == "1 remaining")
    }
}

Detailed breakdown

  • @testable import TodoList gives the tests access to the app’s internal types. The tests run inside the app’s own process (the app is the test target’s test host), which is why they can use TodoItem directly.
  • @MainActor is required here. The app target treats its code as main-actor code by default (Step 3), so ModelContainer.mainContext, which belongs to the main actor, is fine there. The test target has no such default, and without the attribute the build fails with main actor-isolated property 'mainContext' can not be referenced from a nonisolated context.
  • A fresh in-memory database per test. Swift Testing creates a new instance of the TodoListTests struct for every test, so init runs each time and each test gets an empty container. isStoredInMemoryOnly: true means nothing touches the disk, so tests cannot see each other’s data or your saved to-dos.
  • Keep the container, not just the context. The first version of this file built the container as a local variable in init and stored only container.mainContext. A context does not keep its container alive, so the container was freed when init returned, and the first insert in each test crashed inside SwiftData (EXC_BREAKPOINT in ModelContext.addTodo(titled:)). Because these tests run inside the app, one crash ends every test in the run, and all of them are reported as failed. Storing container as a property keeps it alive for the whole test.
  • @Test(arguments:) runs one test function once per argument, so titlesAreTrimmed and blankTitlesAreRejected each cover three inputs. Each run is a test case, and the output reports the count. The TodoListTests struct is the suite, the group the tests belong to.
  • #require is #expect that stops the test when it fails, and it unwraps an optional: if addTodo returned nil, there is no item to inspect, so carrying on would only produce confusing follow-on failures.
  • summaryCountsOnlyUnfinishedItems fetches the items once, then marks rent done and checks the summary again on the same array. The second check passes because a fetched TodoItem is the same object as rent, so the change is visible without fetching again.

Run only the unit tests:

make unit-test
set -o pipefail; xcodebuild -project TodoList/TodoList.xcodeproj -scheme TodoList -destination 'platform=iOS Simulator,name=TodoList-iPhone' -derivedDataPath build -parallel-testing-enabled NO -collect-test-diagnostics never -only-testing:TodoListTests test 2>&1 | grep -E '(✔|✘) Test|Test Case .*(passed|failed)|error:|\*\* TEST'
​✔ Test titlesAreTrimmed(raw:expected:) with 3 test cases passed after 0.003 seconds.
​✔ Test blankTitlesAreRejected(raw:) with 3 test cases passed after 0.004 seconds.
✔ Test addTodoStoresTheCleanedTitle() passed after 0.001 seconds.
✔ Test addTodoIgnoresBlankTitles() passed after 0.001 seconds.
✔ Test summaryCountsOnlyUnfinishedItems() passed after 0.001 seconds.
✔ Test run with 5 tests in 1 suite passed after 0.012 seconds.
** TEST SUCCEEDED **

Timings vary from run to run. Five tests ran, two of them with three cases each.

Check that a failing test fails make

A make target that cannot fail proves nothing, so break one expectation on purpose. Change "2 remaining" to "9 remaining" in summaryCountsOnlyUnfinishedItems, run the tests, and print make’s exit status:

make unit-test; echo "make exit: $?"
✘ Test summaryCountsOnlyUnfinishedItems() recorded an issue at TodoListTests.swift:52:9: Expectation failed: TodoItem.summary(of: items) == "9 remaining"
✘ Test summaryCountsOnlyUnfinishedItems() failed after 0.002 seconds with 1 issue.
✘ Test run with 5 tests in 1 suite failed after 0.014 seconds with 1 issue.
** TEST FAILED **
make: *** [unit-test] Error 65
make exit: 2

The excerpt starts at the first failure; the four passing tests print as before. #expect reports the file and line, and the expression that failed. Each failed expectation is recorded as an issue, which is what with 1 issue counts. With pipefail, the recipe fails with xcodebuild’s code, 65, which make reports as Error 65 before exiting 2. With .SHELLFLAGS in place of the in-recipe set -o pipefail, the same run prints the same test results but no make: *** line, and exits 0. Change the string back to "2 remaining" before going on.

Step 10: Prove the data survives a relaunch with a UI test

The unit tests check the rules, but not whether the app works when someone uses it, and they run against an in-memory database. A UI test launches the real app and drives it through its accessibility interface, tapping and typing as a user would. This one checks the promise from the opening: a to-do you add is still there after the app quits and starts again. It uses XCTest, the older framework, because Swift Testing does not drive app UIs; the template’s “Swift Testing with XCTest UI Tests” option set it up that way.

Create the file

touch TodoList/TodoListUITests/TodoListUITests.swift

The file already exists. Replace its whole contents with the listing.

Add the code: TodoList/TodoListUITests/TodoListUITests.swift

import XCTest

final class TodoListUITests: XCTestCase {

    override func setUpWithError() throws {
        continueAfterFailure = false
    }

    @MainActor
    func testTodoIsSavedAcrossLaunches() throws {
        let app = XCUIApplication()
        app.launchArguments = ["-reset-data"]
        app.launch()

        let summary = app.staticTexts["summary"]
        XCTAssertEqual(summary.label, "Nothing to do")

        // Add a to-do through the text field and the Add button.
        let field = app.textFields["newTodoField"]
        field.tap()
        field.typeText("Buy milk")
        app.buttons["addTodoButton"].tap()

        let row = app.buttons["Buy milk"]
        XCTAssertTrue(row.waitForExistence(timeout: 5))
        XCTAssertEqual(summary.label, "1 remaining")

        // Open it, mark it done, and go back to the list.
        row.tap()
        app.switches["doneToggle"].switches.firstMatch.tap()
        app.navigationBars.buttons.element(boundBy: 0).tap()
        XCTAssertEqual(summary.label, "All done")

        // Quit and relaunch without the reset flag: the item must still be there.
        app.terminate()
        app.launchArguments = []
        app.launch()

        XCTAssertTrue(app.buttons["Buy milk"].waitForExistence(timeout: 5))
        XCTAssertEqual(app.staticTexts["summary"].label, "All done")
    }
}

Detailed breakdown

  • XCUIApplication is a handle to the app under test, the app set as the UI test bundle’s target. The test runs in a separate runner process, so it can quit and relaunch the app, which a unit test running inside the app cannot do.
  • continueAfterFailure = false stops the test at the first failed assertion. Each step depends on the one before; there is no point tapping a row that was never added.
  • launchArguments = ["-reset-data"] starts from an empty list using the flag from Step 6, so the first assertion can expect Nothing to do no matter what earlier runs saved.
  • Elements are found by type and identifier: app.textFields[...], app.buttons[...], app.staticTexts[...]. The identifiers are the accessibilityIdentifier values from Steps 7 and 8. A row in the list is a NavigationLink, which the accessibility system presents as a button labeled with its text, so the test finds it as app.buttons["Buy milk"].
  • waitForExistence(timeout:) waits up to five seconds for the row, because the list updates after the tap is processed. Asserting on .exists straight away can fail on a slow machine.
  • .switches.firstMatch.tap() looks odd but is needed. A SwiftUI Toggle appears as a switch element that spans the whole row, label included, and tapping it taps the middle of the row, which does not flip the switch. The switch control inside it is a second, nested switch element, and tapping that one does. With a plain app.switches["doneToggle"].tap() the test fails at the next line: XCTAssertEqual failed: ("1 remaining") is not equal to ("All done").
  • navigationBars.buttons.element(boundBy: 0) is the Back button, the first button in the navigation bar. Leaving the edit screen also runs its onDisappear save from Step 7.
  • terminate() then launch() is the point of the test. Terminating stops the app abruptly, and in the runs here nothing unsaved survived it, just as with a crash. The relaunch drops the reset flag, so the app shows whatever is on disk. This is the test that fails without the explicit save() calls from Step 8.

Run the whole suite:

make test
set -o pipefail; xcodebuild -project TodoList/TodoList.xcodeproj -scheme TodoList -destination 'platform=iOS Simulator,name=TodoList-iPhone' -derivedDataPath build -parallel-testing-enabled NO -collect-test-diagnostics never test 2>&1 | grep -E '(✔|✘) Test|Test Case .*(passed|failed)|error:|\*\* TEST'
​✔ Test titlesAreTrimmed(raw:expected:) with 3 test cases passed after 0.003 seconds.
​✔ Test blankTitlesAreRejected(raw:) with 3 test cases passed after 0.005 seconds.
✔ Test addTodoStoresTheCleanedTitle() passed after 0.001 seconds.
✔ Test addTodoIgnoresBlankTitles() passed after 0.001 seconds.
✔ Test summaryCountsOnlyUnfinishedItems() passed after 0.001 seconds.
✔ Test run with 5 tests in 1 suite passed after 0.013 seconds.
Test Case '-[TodoListUITests.TodoListUITests testTodoIsSavedAcrossLaunches]' passed (14.303 seconds).
** TEST SUCCEEDED **

XCTest calls each test method a Test Case, so the Test Case line is the whole UI test, not one argument run in the Swift Testing sense from Step 9.

The whole run took between 21 and 41 seconds across six runs here, most of it the UI test launching the app twice. The UI test’s line can print before or after the Swift Testing lines; the order varies between runs.

Step 11: See the saved to-do in the running app

The UI test ran on TodoList-iPhone itself, not on a copy, because -parallel-testing-enabled NO stops xcodebuild from cloning the simulator. So the to-do it saved is still in that simulator’s database. Launching the app from scratch now is a separate check, by a separate process, that the data is on disk.

make run
xcrun simctl launch "TodoList-iPhone" com.example.TodoList
com.example.TodoList: 17499

The excerpt shows the last two lines. Device Hub shows the To-Do screen with Buy milk checked and struck through, and All done below the list. Nothing in this launch passed -reset-data or added an item; the app read the to-do back from disk. Save the screen for your records:

make screenshot
xcrun simctl io "TodoList-iPhone" screenshot screenshots/todolist.png
Detected file type from extension: PNG
Note: No display specified. Defaulting to display: 6795552E-2F36-4AA8-94F5-905A3CDF0105 (screenID: 1, name: LCD)

A last line, Wrote screenshot to:, gives the full path of screenshots/todolist.png; open it to see the same screen. The display identifier differs on your machine.

Troubleshooting

  • make says the simulator is unavailable, or xcodebuild reports Unable to clone device due to missing runtime bundle. Run xcrun simctl list runtimes. If it lists no runtimes although Step 1 finished, the simulator service has lost track of them. That happened once while validating this article, right after a runtime finished installing. Restart the service with killall -9 com.apple.CoreSimulator.CoreSimulatorService, wait until xcrun simctl list runtimes shows them again (about 20 seconds here), then run make delete-simulator simulator to recreate the device.
  • A test run sits for several minutes after a test fails. xcodebuild is running simctl diagnose to collect simulator logs, with a 10-minute limit; it is busy, not hung. The make test targets pass -collect-test-diagnostics never to prevent this. If you run xcodebuild test yourself, add the flag, or stop the simctl diagnose process to get the result sooner.
  • open cannot find Simulator. Xcode 27 replaced the Simulator app with Device Hub, inside Xcode.app/Contents/Applications. On an older Xcode, change the run target to open -a Simulator.
  • Failed to install or launch the test runner with “Clone 1 of TodoList-iPhone” in the log. Parallel testing is on. The test and unit-test make targets pass -parallel-testing-enabled NO; check the flag survived your copy.
  • missing separator from make. A recipe line starts with spaces. Make requires a tab.
  • Instance method ... is not available due to missing import. Add the import to the file that calls the method. See Step 7.
  • A test run fails on every test in 0.000 seconds. The test host app crashed, which ends every test running inside it. Read the newest TodoList-*.ips report in ~/Library/Logs/DiagnosticReports for the crashing line; Step 9 shows the common SwiftData cause.

Recap

You created an iOS app project with Xcode 27’s App template and found what it sets up: three targets, a generated scheme, folders that pick up new files automatically, and compiler defaults that shape the code you write. You gave it a SwiftData model, a list screen and an edit screen, and made it save its changes explicitly instead of relying on autosave. Swift Testing covers the rules in memory, an XCTest UI test proves a to-do survives a relaunch, and a Makefile builds, tests, and launches all of it with xcodebuild and xcrun simctl, including on a stock Mac whose make ignores .SHELLFLAGS.

Next improvements:

  • Show save errors to the user with an alert instead of printing them.
  • Add a second UI test for swipe-to-delete.
  • Move TodoItem into a Swift package so another app can share it.