The to-do app from Build Your First SwiftUI App on macOS keeps its model inside the app target. That works for one app, but nothing else can use the code, and its unit tests can only run by building the whole app and booting an iPhone simulator.
This article moves that model into a Swift package: a folder of Swift code
described by a Package.swift file, which Swift Package Manager (SwiftPM, the
build tool that ships with Swift) can build and test on its own. You will
create a library package named TodoKit, move the TodoItem model and its
five unit tests into it, and run those tests on your Mac with swift test in
about a second, with no simulator. Then you will add the package to the Xcode
project as a local dependency, so the app imports TodoKit instead of owning
the model. The finished project runs the package tests and the UI test from
one make test, and the app still shows the to-do you saved in the first
article, read from the same database by code that now lives in the package.
Two failures come up on the way, and each one teaches a rule of packages: a build that fails because SwiftPM builds for an older macOS than SwiftData supports, and an app that cannot see the model until each file imports the package. Both are shown where they happen.
What you end up with
todo-list/TodoKit/, a library package holdingTodoItemand its Swift Testing tests, which builds and tests withswift buildandswift test.- The
TodoListapp project from the first article, now depending onTodoKitby a relative path, with its own unit-test target removed. - A
Makefilewith apackage-testtarget that runs the model tests on the Mac, and atesttarget that runs them and then the UI test.
Prerequisites
- The finished project from
Build Your First SwiftUI App on macOS: the
todo-listfolder with itsMakefile, theTodoList-iPhonesimulator, and the iOS Simulator runtime that article installs. Step 8 expects the to-do that article saved, Buy milk, marked done. - Xcode 27 or later on a Mac with Apple silicon. Validated on Xcode 27.1 (build 27A9275) with Swift 6.4, on macOS 27.0.1. The Xcode menus named below are from Xcode 27.1.
- Basic Swift access control:
publicandinternal. The Access Control chapter of The Swift Programming Language covers both.
Every command runs from the todo-list folder unless a step says otherwise.
Step 1: Create the package
A package is the unit SwiftPM builds. Its Package.swift file, the
manifest, names the package’s targets (each target is a module: a
folder of source files compiled together and imported by name) and its
products (what the package offers to code outside it, such as a library).
Xcode projects and Makefiles have targets too, each a thing to build; where
the kind matters, the article names it.
Unlike a framework target added to TodoList.xcodeproj, a package does not
belong to any Xcode project. It carries its own build settings, supported
platforms, and tests, so it builds with swift build on its own, and any
project can add it by path. That is what lets later articles in this series
share one package between several apps.
Create the package next to the Xcode project, in its own folder. SwiftPM names the package after the folder:
mkdir TodoKit
cd TodoKit
swift package init --type library
cd ..
Creating library package: TodoKit
Creating Package.swift
Creating .gitignore
Creating Sources
Creating Sources/TodoKit/TodoKit.swift
Creating Tests/
Creating Tests/TodoKitTests/
Creating Tests/TodoKitTests/TodoKitTests.swift
--type library makes a package whose product is a library other code can
import. SwiftPM wrote a manifest, one source file, one test file, and a
.gitignore that ignores .build/, the folder SwiftPM builds into. The
.gitignore from the first article already ignores .build/ and .swiftpm/
anywhere in the project, so nothing new needs adding there.
Look at the manifest SwiftPM generated:
cat TodoKit/Package.swift
// swift-tools-version: 6.4
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "TodoKit",
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "TodoKit",
targets: ["TodoKit"]
),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "TodoKit",
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
],
),
.testTarget(
name: "TodoKitTests",
dependencies: ["TodoKit"],
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
],
),
]
)
The first line is not a comment Swift ignores. swift-tools-version: 6.4
tells SwiftPM which manifest features the file uses, and it also sets the
Swift language mode for the package’s code to Swift 6, with its stricter
concurrency checks. There is one library product, TodoKit, built from one
target of the same name, plus a test target, TodoKitTests, that depends on
it. SwiftPM finds each target’s files by convention: Sources/TodoKit/ and
Tests/TodoKitTests/.
Step 2: Move the model into the package
The model leaves the app now. Moving the file with mv also removes it from
the app: the app’s TodoList/TodoList/ folder is a folder-synchronized
group, so Xcode compiles whatever is in it, and stops compiling a file that is
no longer there. Delete the placeholder source file SwiftPM generated at the
same time, then edit the moved file to match the listing, adding public to
the class and to every member the app uses.
Create the file
mv TodoList/TodoList/TodoItem.swift TodoKit/Sources/TodoKit/TodoItem.swift
rm TodoKit/Sources/TodoKit/TodoKit.swift
Add the code: TodoKit/Sources/TodoKit/TodoItem.swift
import Foundation
import SwiftData
@Model
public final class TodoItem {
public var title: String
public var isDone: Bool
public var createdAt: Date
public 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.
public 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.
public 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
public 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
- The code is unchanged apart from
public. Swift’s default access level isinternal, which means visible inside the module that declares it. WhileTodoItemlived in the app, the app’s views were in the same module, sointernalwas enough. InTodoKitthe app is outside the module, and only declarations markedpublicreach it. - Each member the app uses is marked, not only the class. A
publicclass withinternalproperties is visible but empty from outside: the app could nameTodoItembut not readtitle. The list screen’s@Query(sort: \TodoItem.createdAt)needscreatedAt, the edit screen binds totitleandisDone, and the views callsummary(of:)andaddTodo(titled:).cleanedTitle(_:)is public because the tests call it, and they import the package from outside it too. @Modelworks on a public class without extra steps. The macro adds the protocol conformances SwiftData needs to the class, and the stored properties keep the access level you write.- The extension method is public, the extension is not. An extension of
a type from another module, here SwiftData’s
ModelContext, can mark its memberspublicone by one.
Build the package:
swift build --package-path TodoKit
--package-path runs SwiftPM on the package in TodoKit/ without changing
directory. The build fails. Among the errors is this one, on line 4 of
TodoItem.swift, where @Model is used:
error: 'Model()' is only available in macOS 14 or newer
SwiftPM builds this package for macOS 12 unless the manifest says otherwise, and SwiftData requires macOS 14 or iOS 17. Each error names the version it needs, and the next step declares it.
Step 3: Declare the platforms the package supports
A package states the oldest version of each operating system it supports in
the manifest’s platforms list. SwiftPM uses it in two places: swift build
and swift test compile for that macOS version, and Xcode compiles the
package for that iOS version when an app uses it. Leave an operating system
out and the package gets SwiftPM’s built-in minimum for it, which is too old
for SwiftData on both. With only .macOS(.v14) in the list, swift test
works but the app build in Step 7 fails with the same kind of error for iOS:
'Schema' is only available in iOS 17 or newer.
Create the file
touch TodoKit/Package.swift
The file exists already. Replace its contents with the listing below.
Add the code: TodoKit/Package.swift
// swift-tools-version: 6.4
import PackageDescription
let package = Package(
name: "TodoKit",
platforms: [
.iOS(.v17),
.macOS(.v14),
],
products: [
.library(name: "TodoKit", targets: ["TodoKit"]),
],
targets: [
.target(
name: "TodoKit",
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
]
),
.testTarget(
name: "TodoKitTests",
dependencies: ["TodoKit"],
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
]
),
]
)
Detailed breakdown
platformssets minimums. iOS 17 and macOS 14 are the oldest systems with SwiftData, and newer ones work too. The app’s deployment target, the oldest iOS version it runs on, is iOS 27.0, above the package’s iOS 17 minimum.- macOS is listed for the tests, not for a Mac app. No Mac app uses
TodoKit. Listing macOS 14 letsswift testrun the model tests on the Mac itself, which is the point of Step 4. productsis what other code sees. The.libraryline exposes theTodoKittarget as a library namedTodoKit. A target that no product lists can still be used by other targets in the package, but not by the app.ApproachableConcurrencycomes from the template. It turns on a group of Swift 6 concurrency changes that make code without explicit concurrency annotations simpler to write. The app target from the first article has the same setting (SWIFT_APPROACHABLE_CONCURRENCY = YES). Keep it. The package still differs from the app in two ways: it compiles in the Swift 6 language mode, and its code does not run on the main actor by default (Step 4).
Build again:
swift build --package-path TodoKit
Building for debugging...
[Planning deferred tasks]
[2 / 11]
[3 / 12] TodoKit
[5 / 14] TodoKit
[11 / 14] TodoKit
Build complete! (1.74 sec)
Step 4: Move the unit tests into the package
The five Swift Testing tests from the first article test TodoItem and
nothing else, so they belong with it. Moving them changes where they run. In
the app they ran inside the app’s process, which xcodebuild had to build
and launch in the simulator. In the package they run in a small test program
SwiftPM builds for the Mac. Change the import and the struct name after
moving the file, as the listing shows.
Create the file
mv TodoList/TodoListTests/TodoListTests.swift TodoKit/Tests/TodoKitTests/TodoItemTests.swift
rm TodoKit/Tests/TodoKitTests/TodoKitTests.swift
Add the code: TodoKit/Tests/TodoKitTests/TodoItemTests.swift
import Testing
import SwiftData
import TodoKit
@MainActor
struct TodoItemTests {
// 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
- Two lines changed.
@testable import TodoListbecameimport TodoKit, and the struct is nowTodoItemTests, named for what it tests. The test bodies are the same. - A plain
importis enough.@testablelets tests reachinternaldeclarations. Everything these tests call ispublicnow, so the tests use the package exactly as the app will, and a member you forget to make public fails here first. @MainActoris still needed.ModelContainer.mainContextbelongs to the main actor, the actor that runs code on the main thread. The app target puts its code on the main actor by default; the package, like the app’s old test target, does not, so the struct declares it.
TodoList/TodoListTests/ is empty now. Leave it; Step 6 removes the target
that owns it.
Step 5: Run the package tests from make
swift test prints each result with a glyph from Apple’s SF Symbols font,
which only looks right in a terminal using Apple’s fonts, and it prints a
line when each test starts as well as when it ends. The new package-test
Makefile target sets SWT_SF_SYMBOLS_ENABLED=0, an environment variable
Swift Testing reads, so results use the plain ✔ and ✘ that xcodebuild
printed in the first article. It filters the output through the same grep
as the test target. Replace the whole Makefile.
Create the file
touch Makefile
Add the code: Makefile
PROJECT := TodoList/TodoList.xcodeproj
PACKAGE := TodoKit
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 the test logs: Swift Testing (✔/✘),
# XCTest ("Test Case ... passed"), errors, and xcodebuild's 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 package-test 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 `... | grep` fail when the command before it fails. It is set
# inside the recipe because macOS ships GNU Make 3.81, which ignores .SHELLFLAGS.
# SWT_SF_SYMBOLS_ENABLED=0 makes swift test print ✔/✘ instead of SF Symbols.
package-test: ## Run the TodoKit tests on the Mac (no simulator)
set -o pipefail; SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path $(PACKAGE) \
--disable-xctest 2>&1 | $(RESULTS)
test: package-test simulator ## Run the package tests, then the UI test
set -o pipefail; $(XCODEBUILD) $(TEST_FLAGS) 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 folders
rm -rf $(DERIVED) $(PACKAGE)/.build
delete-simulator: ## Delete the TodoList-iPhone simulator
xcrun simctl delete "$(SIM_NAME)"
Detailed breakdown
PACKAGEnames the package folder once, forpackage-testandclean.package-testneeds no simulator. It does not depend on thesimulatortarget, andswift testbuilds for the Mac, so it works with the simulator shut down.--disable-xctestskips a second, empty test run.swift testruns XCTest tests and Swift Testing tests one after the other. The package has no XCTest tests, so without the flag the output includes an XCTest summary reporting zero tests.set -o pipefailkeeps a failing test a failure. Without it the recipe’s exit status isgrep’s, andgrepsucceeds whenever it prints a line. With it, a failing test stopsmakewithError 1.testnow runspackage-testfirst. Make runs a target’s prerequisites before its recipe, so a broken model fails in about a second, before the slower UI test starts.unit-testis gone. It ran-only-testing:TodoListTests, the Xcode test target Step 6 deletes;package-testreplaces it.cleanalso removesTodoKit/.build, where SwiftPM builds the package for the Mac.
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
package-test Run the TodoKit tests on the Mac (no simulator)
test Run the package tests, then the 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 folders
delete-simulator Delete the TodoList-iPhone simulator
Run the package tests:
make package-test
set -o pipefail; SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path TodoKit \
--disable-xctest 2>&1 | grep -E '(✔|✘) Test|Test Case .*(passed|failed)|error:|\*\* TEST'
✔ Test summaryCountsOnlyUnfinishedItems() passed after 0.011 seconds.
✔ Test addTodoStoresTheCleanedTitle() passed after 0.011 seconds.
✔ Test addTodoIgnoresBlankTitles() passed after 0.011 seconds.
✔ Test blankTitlesAreRejected(raw:) with 3 test cases passed after 0.011 seconds.
✔ Test titlesAreTrimmed(raw:expected:) with 3 test cases passed after 0.011 seconds.
✔ Test run with 5 tests in 1 suite passed after 0.011 seconds.
All five tests pass. The two with an arguments: list run once per value,
three test cases each, and the one suite is the TodoItemTests struct
that groups them. Swift Testing runs tests in parallel, so the order of the
lines changes from run to run. The first run also compiles the package and
takes a few seconds; after that, a run takes about a second here. Run through
xcodebuild as the first article set them up, the same tests took about 5
seconds here with the simulator already running and about 14 seconds when it
had to boot first. Nothing about the app has been tested yet, and the app does
not build right now: its views use TodoItem, which it no longer has.
Step 6: Add the package to the app in Xcode
The app gets the model back by depending on TodoKit. Adding a local package
is done in Xcode, which records it in project.pbxproj. Open the project:
open TodoList/TodoList.xcodeproj
Then add the package:
- Choose File → Add Package Dependencies…. A window opens listing remote packages (ones Xcode downloads from a Git URL), such as Apple’s collection or ones you used recently. You need none of them.
- Click Add Local… at the bottom left, select the
todo-list/TodoKitfolder, the one containingPackage.swift, and click Add Package. - A sheet titled Choose Package Products for TodoKit lists the
TodoKitlibrary. Its Add to Target column says None. Change it to TodoList, then click Add Package. Left at None, the package is added to the project but linked to no target, and the app still cannot import it.
The Project navigator, the file list on the left, now has a Package Dependencies section with TodoKit marked local.
Xcode stores the package as a path relative to the project file’s folder.
grep -o prints just the matching part of the line, without the file’s
indentation:
grep -o 'relativePath.*' TodoList/TodoList.xcodeproj/project.pbxproj
relativePath = ../TodoKit;
../TodoKit is relative to TodoList/, the folder holding
TodoList.xcodeproj, so the project keeps working when you move or clone the
whole todo-list folder, as long as the package stays beside the project.
Remove the old unit-test target
The app’s TodoListTests target still exists, with an empty folder. Its
tests now live in the package, so delete the target and its folder in Xcode:
- Click TodoList, the blue project icon at the top of the Project navigator. The project editor opens.
- Under Targets, select TodoListTests and click the − button below the list. Confirm with Delete when Xcode asks whether to delete the target and all of its references.
- In the Project navigator, select the TodoListTests folder, press Command-Delete, and choose Move to Trash.
Check that nothing in the project refers to it any more:
grep -c TodoListTests TodoList/TodoList.xcodeproj/project.pbxproj
ls TodoList
0
TodoList
TodoList.xcodeproj
TodoListUITests
Step 7: Import the package in the app
The app now links TodoKit, but its Swift files do not import it. Build
from the terminal to see the result. make clean first deletes the earlier
build: on an incremental build, Xcode stops at the first file that fails and
may report a single error. The grep keeps only the error lines, without the
compiler’s long command lines:
make clean build 2>&1 | grep -o 'TodoList/TodoList/[A-Za-z]*\.swift:[0-9:]* error: .*' | sort -u
TodoList/TodoList/ContentView.swift:6:19: error: cannot find 'TodoItem' in scope
TodoList/TodoList/ContentView.swift:6:59: error: cannot find type 'TodoItem' in scope
TodoList/TodoList/ContentView.swift:67:15: error: cannot find type 'TodoItem' in scope
TodoList/TodoList/ContentView.swift:82:30: error: cannot find 'TodoItem' in scope
TodoList/TodoList/TodoDetailView.swift:5:25: error: cannot find type 'TodoItem' in scope
TodoListApp.swift uses TodoItem too, inside its init. Xcode first
builds the module’s interface, which checks declarations such as property
types but skips function bodies, and the build stopped there. (The
#Preview line counts as a declaration, because a macro’s arguments are
checked with it.) So that file has no error yet; it needs the import all the
same.
A dependency makes a module available; each Swift file still has to import
it before it can use the module’s names. Add import TodoKit below
import SwiftData in ContentView.swift, TodoDetailView.swift, and
TodoListApp.swift:
for f in ContentView.swift TodoDetailView.swift TodoListApp.swift; do
sed -i '' '/^import SwiftData$/a\
import TodoKit
' "TodoList/TodoList/$f"
done
head -3 TodoList/TodoList/TodoListApp.swift
import SwiftUI
import SwiftData
import TodoKit
The files keep import SwiftData. They use SwiftData directly
(@Query, modelContext, save()), and the app target requires each file
to import every module it uses, as the first article’s Step 3 explains.
Importing TodoKit does not bring SwiftData in with it.
Build again:
make build
xcodebuild -project TodoList/TodoList.xcodeproj -scheme TodoList -destination 'platform=iOS Simulator,name=TodoList-iPhone' -derivedDataPath build -quiet build
2026-10-23 13:51:00.647 xcodebuild[31320:485937] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.
No error: lines means the build succeeded. xcodebuild printed the
IDERunDestination line on every build while this article was validated,
failed or not; it does not mean anything went wrong.
Step 8: See your saved to-do, read by the package’s model
The app on the simulator is the build from the first article, with
TodoItem compiled into it. The database it saved is still in the simulator.
Install the new build over it and launch it:
make run
xcrun simctl launch "TodoList-iPhone" com.example.TodoList
com.example.TodoList: 31418
The excerpt shows the last two lines. Device Hub, the Xcode app that shows
the simulator’s screen, opens on the To-Do screen with
Buy milk checked and struck through, and All done below the list,
which is the to-do the first article’s UI test saved. Installing a new build
of an app keeps that app’s data, and the new build reads it with the
TodoItem class from TodoKit.
The class is now in a different module, so check that SwiftData still treats it as the same type. Read the entity name and the stored rows from the simulator’s copy of the database:
sqlite3 "$(xcrun simctl get_app_container TodoList-iPhone com.example.TodoList data)/Library/Application Support/default.store" \
"SELECT Z_NAME FROM Z_PRIMARYKEY WHERE Z_NAME = 'TodoItem'; SELECT ZTITLE, ZISDONE FROM ZTODOITEM;"
TodoItem
Buy milk|1
SwiftData stores each model class as an entity, a record type in the
database, and names it after the class, TodoItem, with no module
name, so the rows saved by the old build and the class in the package match.
In Buy milk|1, the 1 means done.
If your simulator shows different to-dos, it is showing whatever you saved
last; the point is that they survived the move.
Save the screen:
make screenshot
xcrun simctl io "TodoList-iPhone" screenshot screenshots/todolist.png
Detected file type from extension: PNG
Note: No display specified. Defaulting to display: 459A9BC3-6621-403B-817E-B45E64657398 (screenID: 1, name: LCD)
A last line, Wrote screenshot to:, gives the full path of
screenshots/todolist.png. The display identifier differs on your machine.
Step 9: Run the package tests and the UI test
make test runs the package’s model tests on the Mac, then the UI test in
the simulator. The UI test starts the app with -reset-data, so it clears
the to-do list before adding Buy milk again.
make test
set -o pipefail; SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path TodoKit \
--disable-xctest 2>&1 | grep -E '(✔|✘) Test|Test Case .*(passed|failed)|error:|\*\* TEST'
✔ Test summaryCountsOnlyUnfinishedItems() passed after 0.010 seconds.
✔ Test addTodoIgnoresBlankTitles() passed after 0.010 seconds.
✔ Test addTodoStoresTheCleanedTitle() passed after 0.010 seconds.
✔ Test titlesAreTrimmed(raw:expected:) with 3 test cases passed after 0.011 seconds.
✔ Test blankTitlesAreRejected(raw:) with 3 test cases passed after 0.011 seconds.
✔ Test run with 5 tests in 1 suite passed after 0.011 seconds.
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 Case '-[TodoListUITests.TodoListUITests testTodoIsSavedAcrossLaunches]' passed (15.518 seconds).
** TEST SUCCEEDED **
Five model tests pass in the package, and the UI test passes against the app built on top of it: it adds a to-do through the screens, marks it done, relaunches the app, and finds it still there. Both pass from one command, with the model in a package the app imports.
Troubleshooting
'Model()' is only available in macOS 14 or newer, or'Schema' is only available in iOS 17 or newer, when building. The manifest’splatformslist is missing that operating system. See Step 3.cannot find 'TodoItem' in scopeorcannot find type 'TodoItem' in scopein the app. The file does not importTodoKit, or the package is not linked to theTodoListtarget. Check the imports from Step 7, then select the project, the TodoList target, and its General tab: Frameworks, Libraries, and Embedded Content should listTodoKit. If it does not, click + there and add it.'title' is inaccessible due to 'internal' protection level(or another member). The member is notpublicinTodoItem.swift.
Recap
You created a library package with swift package init, read what its
manifest declares, and moved the app’s SwiftData model into it with public
access on everything the app uses. The platforms list fixed a build that
SwiftPM had pointed at macOS 12, and it also decides which iOS version Xcode
builds the package for. The model’s tests now run on the Mac with
swift test in about a second. The app depends on the package by a relative
path that Xcode added through Add Local…, each view file imports
TodoKit, and the to-do saved before the move still loads, because SwiftData
keys the stored entity by class name.
Next improvements:
- Move
TodoRowinto the package too, as a SwiftUI view in its own target. - Add a second library product to the package and use it from a new target.
- Put the app and the package in an Xcode workspace, the next step toward a repository that holds several apps.