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 namedTodoList, 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
Makefilewhose 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 includesmakewith 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 everymaketarget 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*.xcuserstatehold 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.*.xcresultbundles hold detailed test results, andscreenshots/is wheremake screenshotwrites 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.
Open Xcode and choose File → New → Project… (Shift-Command-N). Xcode 27 shows a small palette, not the template grid.
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.
In the template chooser, select the iOS tab, then App, then Next.
Fill in the options:
Option Value Product Name TodoListTeam None (leave Add account… alone) Organization Identifier com.exampleInterface SwiftUI Language Swift Testing System Swift Testing with XCTest UI Tests Storage None The bundle identifier below the fields becomes
com.example.TodoList. It is the app’s unique ID on a device, and theMakefileuses 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.Click Next. In the save panel, open your
todo-listfolder, uncheck “Create Git repository on my Mac”, and click Create. The checkbox would start a repository insideTodoList/, leaving out the.gitignorefrom Step 2 and theMakefilefrom Step 4. If you want version control, rungit initintodo-listinstead.
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’simport SwiftDataquietly 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
maketarget is a name you run asmake <name>, followed by its recipe, the shell lines it runs. To keep the two kinds of target apart, this article says “maketarget” for these and “target” alone for Xcode’s. simulatorgives the project its own simulator: one simulated device with a name, a model, and a runtime. This one is an iPhone 17 namedTodoList-iPhone, created withxcrun 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 theMakefilework on one machine and fail on the next. Themaketarget creates the simulator only ifsimctl list devices availabledoes not already show it, and leaves out the runtime sosimctlpicks the newest one that supports an iPhone 17, as Step 1 described.XCODEBUILDholds the flags every build and test shares.-schemepicks the scheme from Step 3.-destinationnames the device to build for, and must name a device that exists, which is why the build and test targets depend onsimulator.-derivedDataPath buildkeeps the build output in the project folder. Xcode’s default is a folder under~/Library/Developer/Xcode/DerivedDatawith a generated suffix, andrunneeds a fixed path to find the finished app.buildpasses-quiet, which prints only warnings and errors.testandunit-testdo the opposite: they need the per-test results, which-quiethides. So they keepxcodebuild’s full log and pipe it throughRESULTS, agrepthat 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, printsTest Case ... passed.- The piping needs
pipefail. A shell pipeline normally exits with the status of its last command, heregrep, andgrepsucceeds whenever it finds a matching line, including the lines reporting a failure. Withoutpipefail,make testexits 0 while tests fail. The usual way to set it for a wholeMakefileis.SHELLFLAGS := -o pipefail -c, but.SHELLFLAGSarrived in GNU Make 3.82, and macOS ships 3.81 (make --versionshows it). Make 3.81 ignores the variable silently. Settingpipefailat the start of the recipe line works on every version. That is also whySHELLis bash. On macOS/bin/shruns bash as well and acceptspipefail, but naming bash keeps the recipes working whereshis a different shell. TEST_FLAGSholds two flags only the testmaketargets use.-collect-test-diagnostics neverstopsxcodebuildfrom collecting simulator diagnostics when a test fails. Without it, two failing runs during review each sat for about 10 minutes insimctl diagnoseafter printing their results; with it, failing runs here finished in 4 to 5 seconds.-parallel-testing-enabled NOkeeps all tests onTodoList-iPhone. By defaultxcodebuildclones 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).runwaits for the simulator to finish booting (bootstatus -bboots 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 -pprints the active Xcode’sDeveloperfolder, so the path works wherever Xcode is installed. The$$makesmakepass a literal$to the shell.helpcollects every target that has a## descriptioncomment, and.DEFAULT_GOAL := helpmakes it what plainmakeruns.
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
@Modelis a macro: at compile time it rewrites the class so SwiftData can save each stored property,title,isDone, andcreatedAt, 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.@Modelalso makes the class observable. It applies the same Observation support as Swift’s@Observablemacro, so a SwiftUI view that readsitem.titleredraws when the title changes. Step 7 relies on this: the edit screen changes an item, and the list updates with no extra code.createdAtexists 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, andaddTodouses 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, or2 remaining. It is a plain function of the items, so the unit tests check it without any UI.addTodo(titled:)extendsModelContext. 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
@mainmarks the type the app starts from.bodyreturns aWindowGroup, the scene that showsContentViewfull 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 forTodoItem. It throws if the file cannot be opened. Without the store the app cannot do anything useful, sofatalErrorstops 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-datais a launch argument the UI test passes in Step 10 so it always starts from an empty list.delete(model:)removes every savedTodoItem; 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
@Bindablelets 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 theTodoItem. No copy is made, so the list screen sees the change as you type..accessibilityIdentifiergives 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..onDisappearsaves when you leave the screen. An inserted SwiftData object exposes its context asitem.modelContext. Step 8 explains why the app saves explicitly.import SwiftDatais 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 withInstance 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.@Queryfetches everyTodoItem, sorted bycreatedAt, and keepsitemscurrent: when an item is inserted, changed, or deleted in the context, the query updates and the view redraws.@Stateholds a value the view owns, here the text being typed, and redraws the view when it changes. NavigationStackgives 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. BecauseTodoItemis 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 whilecleanedTitlereturns 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 initems. footershows the summary line from Step 5, taggedsummaryfor the UI test.#Previewdraws 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 TodoListgives 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 useTodoItemdirectly.@MainActoris required here. The app target treats its code as main-actor code by default (Step 3), soModelContainer.mainContext, which belongs to the main actor, is fine there. The test target has no such default, and without the attribute the build fails withmain 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
TodoListTestsstruct for every test, soinitruns each time and each test gets an empty container.isStoredInMemoryOnly: truemeans 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
initand stored onlycontainer.mainContext. A context does not keep its container alive, so the container was freed wheninitreturned, and the firstinsertin each test crashed inside SwiftData (EXC_BREAKPOINTinModelContext.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. Storingcontaineras a property keeps it alive for the whole test. @Test(arguments:)runs one test function once per argument, sotitlesAreTrimmedandblankTitlesAreRejectedeach cover three inputs. Each run is a test case, and the output reports the count. TheTodoListTestsstruct is the suite, the group the tests belong to.#requireis#expectthat stops the test when it fails, and it unwraps an optional: ifaddTodoreturned nil, there is no item to inspect, so carrying on would only produce confusing follow-on failures.summaryCountsOnlyUnfinishedItemsfetches the items once, then marksrentdone and checks the summary again on the same array. The second check passes because a fetchedTodoItemis the same object asrent, 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
XCUIApplicationis 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 = falsestops 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 expectNothing to dono matter what earlier runs saved.- Elements are found by type and identifier:
app.textFields[...],app.buttons[...],app.staticTexts[...]. The identifiers are theaccessibilityIdentifiervalues from Steps 7 and 8. A row in the list is aNavigationLink, which the accessibility system presents as a button labeled with its text, so the test finds it asapp.buttons["Buy milk"]. waitForExistence(timeout:)waits up to five seconds for the row, because the list updates after the tap is processed. Asserting on.existsstraight away can fail on a slow machine..switches.firstMatch.tap()looks odd but is needed. A SwiftUIToggleappears 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 plainapp.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 itsonDisappearsave from Step 7.terminate()thenlaunch()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 explicitsave()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
makesays the simulator is unavailable, orxcodebuildreportsUnable to clone device due to missing runtime bundle. Runxcrun 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 withkillall -9 com.apple.CoreSimulator.CoreSimulatorService, wait untilxcrun simctl list runtimesshows them again (about 20 seconds here), then runmake delete-simulator simulatorto recreate the device.- A test run sits for several minutes after a test fails.
xcodebuildis runningsimctl diagnoseto collect simulator logs, with a 10-minute limit; it is busy, not hung. Themaketest targets pass-collect-test-diagnostics neverto prevent this. If you runxcodebuild testyourself, add the flag, or stop thesimctl diagnoseprocess to get the result sooner. opencannot find Simulator. Xcode 27 replaced the Simulator app with Device Hub, insideXcode.app/Contents/Applications. On an older Xcode, change theruntarget toopen -a Simulator.Failed to install or launch the test runnerwith “Clone 1 of TodoList-iPhone” in the log. Parallel testing is on. Thetestandunit-testmaketargets pass-parallel-testing-enabled NO; check the flag survived your copy.missing separatorfrommake. A recipe line starts with spaces. Make requires a tab.Instance method ... is not available due to missing import. Add theimportto 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-*.ipsreport in~/Library/Logs/DiagnosticReportsfor 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
TodoIteminto a Swift package so another app can share it.