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 holding TodoItem and its Swift Testing tests, which builds and tests with swift build and swift test.
  • The TodoList app project from the first article, now depending on TodoKit by a relative path, with its own unit-test target removed.
  • A Makefile with a package-test target that runs the model tests on the Mac, and a test target that runs them and then the UI test.

Prerequisites

  • The finished project from Build Your First SwiftUI App on macOS: the todo-list folder with its Makefile, the TodoList-iPhone simulator, 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: public and internal. 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 is internal, which means visible inside the module that declares it. While TodoItem lived in the app, the app’s views were in the same module, so internal was enough. In TodoKit the app is outside the module, and only declarations marked public reach it.
  • Each member the app uses is marked, not only the class. A public class with internal properties is visible but empty from outside: the app could name TodoItem but not read title. The list screen’s @Query(sort: \TodoItem.createdAt) needs createdAt, the edit screen binds to title and isDone, and the views call summary(of:) and addTodo(titled:). cleanedTitle(_:) is public because the tests call it, and they import the package from outside it too.
  • @Model works 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 members public one 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

  • platforms sets 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 lets swift test run the model tests on the Mac itself, which is the point of Step 4.
  • products is what other code sees. The .library line exposes the TodoKit target as a library named TodoKit. A target that no product lists can still be used by other targets in the package, but not by the app.
  • ApproachableConcurrency comes 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 TodoList became import TodoKit, and the struct is now TodoItemTests, named for what it tests. The test bodies are the same.
  • A plain import is enough. @testable lets tests reach internal declarations. Everything these tests call is public now, so the tests use the package exactly as the app will, and a member you forget to make public fails here first.
  • @MainActor is still needed. ModelContainer.mainContext belongs 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

  • PACKAGE names the package folder once, for package-test and clean.
  • package-test needs no simulator. It does not depend on the simulator target, and swift test builds for the Mac, so it works with the simulator shut down.
  • --disable-xctest skips a second, empty test run. swift test runs 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 pipefail keeps a failing test a failure. Without it the recipe’s exit status is grep’s, and grep succeeds whenever it prints a line. With it, a failing test stops make with Error 1.
  • test now runs package-test first. 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-test is gone. It ran -only-testing:TodoListTests, the Xcode test target Step 6 deletes; package-test replaces it.
  • clean also removes TodoKit/.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:

  1. 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.
  2. Click Add Local… at the bottom left, select the todo-list/TodoKit folder, the one containing Package.swift, and click Add Package.
  3. A sheet titled Choose Package Products for TodoKit lists the TodoKit library. 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:

  1. Click TodoList, the blue project icon at the top of the Project navigator. The project editor opens.
  2. 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.
  3. 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’s platforms list is missing that operating system. See Step 3.
  • cannot find 'TodoItem' in scope or cannot find type 'TodoItem' in scope in the app. The file does not import TodoKit, or the package is not linked to the TodoList target. Check the imports from Step 7, then select the project, the TodoList target, and its General tab: Frameworks, Libraries, and Embedded Content should list TodoKit. If it does not, click + there and add it.
  • 'title' is inaccessible due to 'internal' protection level (or another member). The member is not public in TodoItem.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 TodoRow into 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.