Unit Testing with Swift Testing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Earlier pages in this section already contain a few FieldNotes tests: NoteStoreTests on
App Architecture the Apple Way, PersistenceTests on
Persisting Models with SwiftData and StoreManagerTests on
In-App Purchases and Subscriptions with StoreKit 2. This page turns
them into a test suite for the whole app. It covers the building blocks, then how to test the parts that make an app
harder to test than a library: main-actor models, a SwiftData store, App Intents and a codebase that still has some
XCTest in it. Everything targets the 27 releases (iOS 27, iPadOS 27, macOS 27, watchOS 27 and visionOS 27),
built with Xcode 27 and Swift 6.4. FieldNotes keeps a deployment minimum of 26, so the one framework
that needs 27, AppIntentsTesting, is marked with @available.
Swift Testing as a language library (the macros, how #expect captures sub-expressions, how swift test runs
it) is covered in the Swift reference under Testing, and the macro
machinery under Macros. This page doesn’t repeat that material; it
shows what the same APIs look like in an app. UI tests, performance tests, test plans and code coverage are on the
next page, UI Tests, Test Plans, Performance Tests and
Coverage.
FieldNotes’s Test Targets
FieldNotes has three test targets. Each one uses the framework that suits the kind of test it holds:
| Target | Framework | What it tests |
|---|---|---|
|
Swift Testing |
Unit and integration tests hosted by the app: |
|
Swift Testing |
The |
|
XCTest and XCUIAutomation |
UI tests, performance tests and App Intents tests ( |
Every test file in the two Swift Testing targets can use the same four tags. A tag is a static property declared
with the @Tag macro in an extension of Tag. The test plan on the next page includes or excludes tests by tag:
// FieldNotesTests/Support/Tags.swift (also compiled into FieldNotesKitTests)
import Testing
extension Tag {
/// SwiftData, UserDefaults and anything else that writes to a store.
@Tag static var persistence: Self
/// iCloud and the notes API.
@Tag static var sync: Self
/// App Intents, Shortcuts and Spotlight.
@Tag static var intents: Self
/// Views rendered in tests, such as snapshots attached to a report.
@Tag static var ui: Self
}
Figure: Swift Testing or XCTest?
Apple recommends Swift Testing for new unit tests and XCTest for UI automation and performance tests. Tests that must catch Objective-C exceptions also have to be XCTests written in Objective-C, because Swift can’t catch them safely. FieldNotes makes that decision for each new test:
XCUIApplication?} B -->|Yes| U[XCTest in FieldNotesUITests
see UI Tests page] B -->|No| C{Measures performance with
measure or an XCTMetric?} C -->|Yes| P[XCTest performance test
see UI Tests page] C -->|No| D{Runs App Intents out of process
with AppIntentsTesting?} D -->|Yes| I[XCTestCase in FieldNotesUITests
FieldNotesShortcutsTests] D -->|No| E{Must catch an
Objective-C exception?} E -->|Yes| O[XCTest written in Objective-C] E -->|No| F{Existing XCTest that passes
and rarely changes?} F -->|Yes| K[Keep it for now
interoperability mode complete or strict] F -->|No| S[Swift Testing in FieldNotesTests
or FieldNotesKitTests]
The last question matters because migration is optional. Xcode 27 runs both frameworks in the same target and, with interoperability (covered at the end of this page), reports assertion failures across them. An XCTest that works can stay until someone needs to change it.
Tests and Suites: @Test and @Suite
@Test marks a function as a test, whether it is a global function or a member of a type. A type that contains tests
is a suite. It becomes one implicitly, or explicitly with @Suite, which adds a display name and traits. Swift
Testing creates a new instance of the suite for every test, so stored properties set in init() are fresh each
time. init() does the work of XCTest’s setUp(), and deinit in a class or actor suite does the work of
tearDown().
NoteStoreTests already exists; it is @MainActor because NoteStore is. New tests can be added in an extension,
which keeps the original file untouched. A test’s display name can be a string argument, or, since Swift 6.2, the
function name itself written as a raw identifier between backticks:
// FieldNotesTests/NoteStoreTests+Editing.swift (adds tests to the NoteStoreTests suite from App Architecture)
import Testing
import FieldNotesModel
import FieldNotesServices
extension NoteStoreTests {
@Test("Deleting a note removes only that note")
func deleteRemovesOnlyThatNote() {
let heron = Note(title: "Heron")
let kingfisher = Note(title: "Kingfisher")
let store = NoteStore(notes: [heron, kingfisher])
store.delete(heron)
#expect(store.notes.map(\.id) == [kingfisher.id])
}
@Test func `a new store has no notebooks`() {
#expect(NoteStore().notebooks.isEmpty)
}
}
See Apple Developer Documentation — Defining test functions and Apple Developer Documentation — Organizing test functions with suite types.
Because the suite is @MainActor, every test in it runs on the main actor. Tests that don’t touch main-actor state
should live in a suite that isn’t isolated, so Swift Testing can run them in parallel on other threads.
Expectations: #expect and try #require
#expect records a failure and lets the test continue, which is what you want when several independent checks
follow one another. try #require records the failure and throws, which stops the test. Use it when the rest of
the test depends on the value: unwrapping an optional, or checking a count before indexing.
extension NoteStoreTests {
@Test func pinningTheFirstNoteLeavesTheRestUnpinned() throws {
let store = NoteStore(notes: [Note(title: "Heron"), Note(title: "Kingfisher"), Note(title: "Otter")])
let first = try #require(store.notes.first) // stops here if the store is empty
store.togglePin(first)
#expect(store.notes[0].isPinned)
#expect(store.notes.dropFirst().allSatisfy { !$0.isPinned })
}
}
When an expectation fails, the report shows the values of each sub-expression, such as store.notes[0].isPinned
evaluating to false, so a message is rarely needed.
Expecting errors: #expect(throws:)
#expect(throws:) passes only if its closure throws an error of the given type, and it returns that error so the
test can check it further. FieldNotes uses it to check that NoteStore.refresh() passes sync errors on to the
caller rather than swallowing them. The failing syncer is a page-local test double, like RecordingNoteSyncer on
the architecture page:
import Foundation
/// Behaves like a device with no network connection.
struct OfflineNoteSyncer: NoteSyncing {
func sync() async throws { throw URLError(.notConnectedToInternet) }
}
extension NoteStoreTests {
@Test(.tags(.sync))
func refreshReportsSyncErrors() async {
let store = NoteStore(syncer: OfflineNoteSyncer())
let error = await #expect(throws: URLError.self) {
try await store.refresh()
}
#expect(error?.code == .notConnectedToInternet)
}
}
#expect(throws: Never.self) { … } states the opposite: the code must not throw. try #require(throws:) works
the same way but stops the test if no error is thrown.
Confirming events: confirmation
Some behaviour can’t be checked with a value at the end: a callback fires, a notification is posted, an observer is
told about a change. confirmation gives the test a Confirmation to call when the event happens, and fails if it
was called a different number of times than expectedCount (1 by default) when the closure returns. It replaces
XCTestExpectation.
FieldNotes uses it to test the @Observable part of NoteStore: views redraw only if Observation reports a
change, so the test registers for changes with withObservationTracking and confirms that the change arrives. A
second test checks that toggling a note that isn’t in the store changes nothing, with expectedCount: 0:
import Observation
extension NoteStoreTests {
@Test func addingANoteNotifiesObservers() async {
let store = NoteStore()
await confirmation("notes changed") { notesChanged in
withObservationTracking {
_ = store.notes
} onChange: {
notesChanged() // Confirmation is Sendable, so the @Sendable closure may call it
}
store.add(Note(title: "Otter"))
}
}
@Test func togglingAnUnknownNoteChangesNothing() async {
let store = NoteStore(notes: [Note(title: "Heron")])
await confirmation("notes changed", expectedCount: 0) { notesChanged in
withObservationTracking { _ = store.notes } onChange: { notesChanged() }
store.togglePin(Note(title: "Not in this store"))
}
}
}
See Apple Developer Documentation — Testing asynchronous code and Apple Developer Documentation -- withObservationTracking(_:onChange:).
onChange fires once, just before the first change, so each observation covers one mutation. The confirmation
must happen before the closure returns: confirmation doesn’t wait, so an event that arrives later, for example
from a detached task, counts as not happening at all. For those cases, await the work inside the closure.
Known issues: withKnownIssue
A bug that is known and tracked shouldn’t turn the whole run red. withKnownIssue records any failure inside it as
expected: the test still runs and is reported, but it doesn’t fail. When the bug is fixed and the failure stops
happening, Swift Testing reports that the known issue was not recorded, which reminds you to remove the wrapper.
Pass isIntermittent: true for a failure that only happens sometimes.
extension NoteStoreTests {
@Test func addTrimsWhitespaceFromTitles() {
let store = NoteStore()
store.add(Note(title: " Heron "))
withKnownIssue("NoteStore.add keeps surrounding whitespace; tracked as FIELDNOTES-212") {
#expect(store.notes.first?.title == "Heron")
}
}
}
If the test can’t run at all in the current situation, rather than failing in a known way, don’t use a known
issue: disable it with a condition trait, described below, or call try Test.cancel("reason") from inside the test
(Xcode 26.4 and later).
Parameterized Tests: @Test(arguments:)
A parameterized test runs once per argument, and each argument is reported as its own test case, so a failure names the exact input. Test cases run in parallel by default, and a single failing case can be run again on its own from the Test navigator.
NoteSortOrder, defined on Localization with String Catalogs, is stored by its
raw value in @AppStorage, in UserDefaults.fieldNotesShared and in iCloud key-value storage. Renaming a case would
silently reset every user’s setting, so NoteSortOrderTests fixes the raw values. zip pairs each case with the
string it must keep:
// FieldNotesTests/NoteSortOrderTests.swift
import Foundation
import Testing
import FieldNotesModel
@Suite("Sort order preference", .tags(.persistence), .serialized)
struct NoteSortOrderTests {
private static let suiteName = "com.example.fieldnotes.tests"
/// A private defaults suite, so tests never touch the App Group preferences of the simulator or Mac they run on.
let defaults: UserDefaults
init() throws {
defaults = try #require(UserDefaults(suiteName: Self.suiteName))
defaults.removePersistentDomain(forName: Self.suiteName)
}
@Test("Stored raw values never change",
arguments: zip(NoteSortOrder.allCases, ["newestFirst", "oldestFirst", "title"]))
func rawValueIsStable(order: NoteSortOrder, stored: String) {
#expect(order.rawValue == stored)
}
@Test(arguments: NoteSortOrder.allCases)
func roundTripsThroughUserDefaults(order: NoteSortOrder) throws {
defaults.set(order.rawValue, forKey: "noteSortOrder")
let raw = try #require(defaults.string(forKey: "noteSortOrder"))
#expect(NoteSortOrder(rawValue: raw) == order)
}
}
If a case is added to NoteSortOrder without a stored value in the list, zip stops at the shorter collection and
the new case goes untested. That is why the second test iterates over allCases directly. Two collections passed as
separate arguments, instead of zipped, produce every combination (their Cartesian product), here 2 × 3 = 6 cases:
extension NoteStoreTests {
@Test(arguments: [false, true], 1...3)
func pinStateFollowsTheNumberOfToggles(startsPinned: Bool, toggles: Int) {
let note = Note(title: "Heron", isPinned: startsPinned)
let store = NoteStore(notes: [note])
for _ in 0..<toggles { store.togglePin(note) } // togglePin finds the note by its id
let expected = toggles.isMultiple(of: 2) ? startsPinned : !startsPinned
#expect(store.notes[0].isPinned == expected)
}
}
Arguments must be Sendable, and they should be cheap to create: they are evaluated when the tests are discovered,
before any test runs.
Traits
A trait is an argument to @Test or @Suite that changes how a test runs or how it is reported. Traits on a
suite apply to every test in it.
| Trait | FieldNotes uses it to |
|---|---|
|
Group tests across files and targets ( |
|
Skip the iCloud test unless the run has an iCloud account. |
|
Fail a network test that hangs instead of blocking the run. |
|
Run |
A custom |
Give each persistence test its own in-memory SwiftData store. |
Conditions and time limits
.enabled(if:) evaluates its condition before the test runs (possibly more than once, so keep it cheap and free of
side effects); a disabled test is reported as skipped, with the comment. .timeLimit takes whole minutes. When a
limit is exceeded, the test’s task is cancelled and the test fails. If several limits apply, for example one on the
suite and one on the test, the shortest wins; on a parameterized test the limit applies to each case separately.
extension NoteStoreTests {
@Test(.tags(.sync),
.enabled(if: ProcessInfo.processInfo.environment["FIELDNOTES_CLOUD_TESTS"] == "1",
"Needs a signed-in iCloud account; set FIELDNOTES_CLOUD_TESTS=1 in the test plan"),
.timeLimit(.minutes(1)))
func refreshAgainstICloud() async throws {
let syncer = CloudNoteSyncer()
await syncer.enqueue([Note(title: "Sync probe")])
try await NoteStore(syncer: syncer).refresh()
}
}
See Apple Developer Documentation — Enabling and disabling tests and Apple Developer Documentation — Limiting the running time of tests.
This test needs the iCloud capability (a paid Apple Developer Program membership) and a device or simulator signed in to iCloud; everywhere else it is skipped.
Running tests one at a time: .serialized
Swift Testing runs tests in parallel by default, including the cases of a parameterized test. .serialized on a suite
runs its tests, and their cases, one after another. Use it only for tests that genuinely share state, as
NoteSortOrderTests above does with its defaults suite, and prefer giving each test its own state: a serialized
suite is as slow as XCTest. Suites that are not serialized still run in parallel with it.
@Suite("Sort order preference", .tags(.persistence), .serialized)
struct NoteSortOrderTests { /* as above */ }
Custom traits: TestScoping
TestScoping (Xcode 16.3 and later) lets a trait wrap a test in set-up and tear-down code. The trait implements
provideScope(for:testCase:performing:), prepares something, calls function() to run the test, and cleans up
afterwards. A task-local value is the natural way to hand the prepared object to the test, because it is
visible only inside that call and is Sendable-checked.
InMemoryStoreTrait gives each test a fresh SwiftDataNotePersistence backed by
ModelContainer.fieldNotes(inMemory: true), so persistence tests never see each other’s notes and can run in
parallel:
// FieldNotesTests/Support/InMemoryStoreTrait.swift
import Testing
import SwiftData
import FieldNotesServices
struct InMemoryStoreTrait: TestTrait, SuiteTrait, TestScoping {
/// The store for the test that is currently running; nil outside a test that has the trait.
@TaskLocal static var persistence: SwiftDataNotePersistence?
/// On a suite, give every test its own store instead of one store for the whole suite.
var isRecursive: Bool { true }
func provideScope(for test: Test, testCase: Test.Case?,
performing function: @Sendable () async throws -> Void) async throws {
let persistence = SwiftDataNotePersistence(modelContainer: try .fieldNotes(inMemory: true))
try await Self.$persistence.withValue(persistence) {
try await function()
}
}
}
extension Trait where Self == InMemoryStoreTrait {
static var inMemoryStore: Self { Self() }
}
If opening the container throws, provideScope throws, and Swift Testing records the error as a failure of that test
instead of running it. On a single test function the scope is provided once per test case; on a suite, isRecursive
decides whether it wraps each test or the suite as a whole.
Testing SwiftData and @Observable Models
The existing PersistenceTests suite shows the approach: use the real persistence layer with an in-memory
container, not a mock, because the mapping between Note and NoteRecord is the code most likely to break. The
trait makes that one line per test. Two rules follow from how NoteStore works:
-
NoteStoreupdates its arrays at once and writes to the store in the background, in order. Before reading the store directly, a test callsawait store.waitForPendingWrites().load()waits for pending writes by itself. -
The store, like the views, is main-actor isolated, so the suite is
@MainActor. The persistence actor is not, and the test awaits it like any other actor.
// FieldNotesTests/PersistenceTests+Scoped.swift (adds tests to PersistenceTests from the SwiftData page)
import Testing
import FieldNotesModel
import FieldNotesServices
extension PersistenceTests {
@Test(.inMemoryStore, .tags(.persistence))
func deletedNoteStaysDeleted() async throws {
let persistence = try #require(InMemoryStoreTrait.persistence)
let store = NoteStore(persistence: persistence)
let note = Note(title: "Dipper")
store.add(note)
store.delete(note)
await store.waitForPendingWrites()
let saved = try await persistence.fetchNotes()
if !saved.isEmpty {
// Keep the evidence with the failure: which notes survived.
Attachment.record(saved.map(\.title).joined(separator: "\n"), named: "surviving-notes.txt")
}
#expect(saved.isEmpty)
}
@Test(.inMemoryStore, .tags(.persistence))
func notesKeepTheirNotebookAfterReload() async throws {
let persistence = try #require(InMemoryStoreTrait.persistence)
let store = NoteStore(persistence: persistence)
let notebook = Notebook(name: "Loch survey")
store.add(notebook)
store.add(Note(title: "Otter spraint", notebookID: notebook.id))
let reloaded = NoteStore(persistence: persistence)
await store.waitForPendingWrites()
await reloaded.load()
let note = try #require(reloaded.notes.first)
#expect(note.notebookID == notebook.id)
#expect(reloaded.notebooks.map(\.name) == ["Loch survey"])
}
}
The second test relies on NoteStore saving the notebook before the note that refers to it; if the write queue ever
reordered them, the relationship would be lost and the test would catch it. Never point unit tests at the App Group
store: it holds the developer’s own notes on a Mac, and tests running in parallel would read each other’s data.
For @Observable types in general, test the model, not the view: call its methods and check its properties, and
use withObservationTracking with confirmation, as shown earlier, when the notification itself is the behaviour.
Views are checked with previews and UI tests on the next page.
Attachments
An attachment saves data with a test’s result, so the report explains a failure without running the test again.
Attachment.record (Xcode 26 and later) accepts strings (saved as UTF-8 text), Data and byte arrays, and any
Encodable value once the test imports Foundation. The persistence test above attaches the surviving titles only
when something went wrong. Images, such as a rendered view, can be attached from Xcode 26.4; the UI Tests page uses
that for snapshots.
extension PersistenceTests {
@Test(.inMemoryStore, .tags(.persistence))
func exportedTitlesAreAttached() async throws {
let persistence = try #require(InMemoryStoreTrait.persistence)
let store = NoteStore(notes: [], persistence: persistence)
store.add(Note(title: "Heron", tags: ["birds"]))
await store.waitForPendingWrites()
let titles = try await persistence.fetchNotes().map(\.title)
Attachment.record(titles.joined(separator: "\n"), named: "titles.txt")
#expect(titles == ["Heron"])
}
}
Xcode shows attachments in the test report next to the test; xcodebuild stores them in the result bundle. Whether
attachments of passing tests are kept is a test plan setting, described on the next page.
Exit Tests (macOS Only)
Code that calls precondition or fatalError can’t be tested in the normal way, because the failure ends the test
process. An exit test (Xcode 26 and later) runs its closure in a child process and checks how that process ends:
.success, .failure, a specific exitCode(_:) or a signal(_:). Exit tests are available on macOS, Linux,
FreeBSD, OpenBSD and Windows, not on iOS, iPadOS, watchOS or visionOS, so the test is compiled only for the Mac.
FieldNotes uses a precondition in one place: Notebook.named(_:), a page-local factory that the notebook sheet
calls, treats an empty name as a programming error because the Save button is disabled until a name is typed. The
exit test lives in the package’s test target, which swift test runs as a plain command-line process on the Mac:
// Packages/FieldNotesKit/Sources/FieldNotesModel/Notebook+Named.swift
import Foundation
extension Notebook {
/// Notebooks created from the UI always have a visible name.
public static func named(_ name: String, symbolName: String = "book.closed") -> Notebook {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
precondition(!trimmed.isEmpty, "A notebook needs a name")
return Notebook(name: trimmed, symbolName: symbolName)
}
}
// Packages/FieldNotesKit/Tests/FieldNotesKitTests/NotebookExitTests.swift
#if os(macOS)
import Testing
import FieldNotesModel
@Test func blankNotebookNameStopsTheProcess() async {
await #expect(processExitsWith: .failure) {
_ = Notebook.named(" ")
}
}
#endif
The closure runs in another process, so it can’t capture ordinary local variables. Values it needs are passed in an
explicit capture list ({ [name] in … }) and must be Sendable and Codable. The child’s standard output and
error can be checked through the observing: argument.
Testing App Intents with AppIntentsTesting (New in 27)
The AppIntentsTesting framework (new in the 27 releases) runs intents, entity queries and Spotlight lookups the
way Siri and Shortcuts do: out of process, in the running app, looking every type up by name. Apple’s
documentation places these tests in a UI testing target, because they need a live app process, and every example
it gives is an XCTestCase. Apple doesn’t document using the framework from Swift Testing, so FieldNotes follows
the documented form: FieldNotesShortcutsTests is an XCTest class in FieldNotesUITests.
App Intents, Siri, Shortcuts, Spotlight, Deep Links and
Extensions already tests CreateNoteIntent, TogglePinIntent and Spotlight in FieldNotesIntentTests.
FieldNotesShortcutsTests covers the three intents behind FieldNotesShortcuts, the phrases people actually say:
// FieldNotesUITests/FieldNotesShortcutsTests.swift
import XCTest
import AppIntentsTesting
@available(iOS 27, macOS 27, *)
final class FieldNotesShortcutsTests: XCTestCase {
/// Launches FieldNotes without --uitesting: entity queries read the App Group store, as they do for Siri.
@MainActor
private func launchedDefinitions() -> IntentDefinitions {
let app = XCUIApplication()
app.launch()
return IntentDefinitions(bundleIdentifier: "com.example.fieldnotes")
}
@MainActor
func testNewNoteShortcutReturnsTheNote() async throws {
let definitions = launchedDefinitions()
let title = "Otter \(UUID().uuidString.prefix(8))" // unique, so earlier runs don't interfere
let result = try await definitions.intents["CreateNoteIntent"]
.makeIntent(noteTitle: title, text: "Holt under the alder", pin: true)
.run()
let created: String = try result.value.title
XCTAssertEqual(created, title)
}
@MainActor
func testOpenNoteShortcutFindsTheNoteByTitle() async throws {
let definitions = launchedDefinitions()
let title = "Kingfisher \(UUID().uuidString.prefix(8))"
try await definitions.intents["CreateNoteIntent"]
.makeIntent(noteTitle: title, text: "", pin: false)
.run()
let matches = try await definitions.entities["NoteEntity"].entities(matching: title)
XCTAssertEqual(matches.count, 1)
try await definitions.intents["OpenNoteIntent"].makeIntent(target: matches[0]).run()
}
@MainActor
func testPinnedNotesShortcutAcceptsItsScreen() async throws {
let definitions = launchedDefinitions()
let pinned = definitions.enums["FieldNotesScreen"].makeCase("pinned")
try await definitions.intents["OpenFieldNotesScreenIntent"].makeIntent(target: pinned).run()
}
}
See Apple Developer Documentation — Testing your App Intents code and Apple Developer Documentation — IntentDefinitions.
Intents, entities and enums are looked up by their Swift type names, and parameters by their property names
(noteTitle, target), so renaming either breaks these tests, which is exactly the regression they exist to catch.
The UI test target must use the same signing team as the app. AppIntentsTesting’s documentation lists every 27
platform, but UI testing itself isn’t available for apps built with the visionOS SDK, so these tests run on the iPhone,
iPad and Mac destinations.
XCTest Interoperability in Xcode 27
Most apps have XCTest code they won’t rewrite at once, especially helper functions that wrap XCTAssert… or
XCTFail. Test framework interoperability lets each framework’s failures count in the other’s tests: an
XCTFail inside a Swift Testing test, or an #expect or Issue.record inside an XCTestCase, is a
cross-library issue. Four modes decide how Swift Testing reports them:
| Mode | Behaviour | Result of XCTFail("…") in a Swift Testing test |
|---|---|---|
|
Interoperability is off. Both frameworks ignore cross-library issues. |
No message; the test passes. |
|
Issues from Swift Testing keep their severity. Issues from XCTest become warnings with a hint to modernize. |
Warning, plus "Replace XCTest API" hint; the test passes. |
|
All cross-library issues keep their severity; XCTest issues also get the modernization hint. |
Failure, plus the hint. |
|
Issues from Swift Testing keep their severity; issues from XCTest stop the test with |
The test stops at the XCTest call. |
The default depends on the toolchain and on where the tests come from:
| Situation | Declared swift-tools-version |
Default mode |
|---|---|---|
Toolchain older than Swift 6.4 |
any |
|
Swift 6.4 toolchain, package |
older than 6.4 |
|
Swift 6.4 toolchain, package |
6.4 or newer (as |
|
Xcode 27, test plan created before Xcode 27 |
not applicable |
|
Xcode 27, new project |
not applicable |
|
In Xcode, the mode is a test plan setting under Test Execution in the test plan’s settings (filter for
"interoperability"). For swift test, and to override any default, set the environment variable
SWIFT_TESTING_XCTEST_INTEROP_MODE to the lowercase mode name. Apple doesn’t document the key Xcode 27 writes into
the .xctestplan file for this setting, so change it in the editor rather than by hand.
FieldNotes had one XCTest-era helper that checks for duplicate note identifiers. Called from a Swift Testing test in
limited mode, its XCTFail was only a warning, and a real duplicate went unnoticed. Migrating the helper means
replacing XCTFail with Issue.record, and file:/line: with a SourceLocation; it then fails correctly in
tests from both frameworks:
// Before: FieldNotesTests/Support/NoteAssertions.swift (XCTest)
import XCTest
import FieldNotesModel
func assertUniqueIDs(_ notes: [Note], file: StaticString = #filePath, line: UInt = #line) {
var seen = Set<Note.ID>()
for note in notes where !seen.insert(note.id).inserted {
XCTFail("Duplicate note ID for \"\(note.title)\"", file: file, line: line)
}
}
// After: the same helper with Swift Testing API, usable from XCTestCase and @Test alike
import Testing
import FieldNotesModel
func expectUniqueIDs(_ notes: [Note], sourceLocation: SourceLocation = #_sourceLocation) {
var seen = Set<Note.ID>()
for note in notes where !seen.insert(note.id).inserted {
Issue.record("Duplicate note ID for \"\(note.title)\"", sourceLocation: sourceLocation)
}
}
Once the helpers are migrated, switch the plan to strict: any remaining XCTest call reached from a Swift Testing
test stops at the exact line that needs changing. Use none only temporarily, since it hides real failures.
Interoperability also lets withKnownIssue match XCTest failures, and Test.cancel end an XCTest early in place of
XCTSkip.
When to keep XCTest
-
UI automation:
XCUIApplicationandXCUIElementare XCTest APIs (next page). -
Performance tests:
measure(metrics:)and theXCTMetrictypes (next page). -
App Intents tests with AppIntentsTesting, as shown above.
-
Objective-C: tests that must catch Objective-C exceptions are written in Objective-C with XCTest; see Testing in the Objective-C reference.
-
Working XCTests that nobody needs to touch. Migrate a file when you next change it.
Don’t mix the two frameworks' APIs inside one new test; interoperability exists to make migration safe, not as a way of writing tests.
Running and Filtering Tests
In Xcode, ⌘U runs the scheme’s test plan, and the diamond next to a @Test, a suite or a single test case runs just
that. The Test navigator can filter by tag. From the command line, xcodebuild runs the app-hosted tests and
swift test runs the package’s:
# One suite of the app-hosted tests, on the iPhone simulator
xcodebuild test \
-project FieldNotes.xcodeproj -scheme FieldNotes \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:FieldNotesTests/NoteSortOrderTests
# The package's tests on the Mac (including the exit test), in strict interoperability mode
SWIFT_TESTING_XCTEST_INTEROP_MODE=strict swift test --package-path Packages/FieldNotesKit
# Run the iCloud test too; the runner receives FIELDNOTES_CLOUD_TESTS=1, which its trait reads
TEST_RUNNER_FIELDNOTES_CLOUD_TESTS=1 xcodebuild test -project FieldNotes.xcodeproj -scheme FieldNotes \
-destination 'platform=macOS' -only-testing:FieldNotesTests/NoteStoreTests
xcodebuild passes a shell variable to the test process only when its name starts with TEST_RUNNER_, and strips
that prefix on the way, so the test still reads FIELDNOTES_CLOUD_TESTS; alternatively, set
FIELDNOTES_CLOUD_TESTS under Environment Variables in the scheme’s Test action or a test plan configuration. Test plans, configurations,
tag filters and coverage are on UI Tests, Test Plans,
Performance Tests and Coverage; running all of it in CI is on CI/CD
with Xcode Cloud and the Command Line. Swift Package Manager’s side of swift test is in
Build and Tooling.
Platform availability
Swift Testing ships with the toolchain, not the OS, so most of it depends on the Xcode version. "Yes" means available on every release FieldNotes supports (26 and later).
| API or feature | iOS | iPadOS | macOS | watchOS | visionOS |
|---|---|---|---|---|---|
|
Yes |
Yes |
Yes |
Yes |
Yes |
|
Yes |
Yes |
Yes |
Yes |
Yes |
|
Yes |
Yes |
Yes |
Yes |
Yes |
Exit tests, |
Not available |
Not available |
Yes |
Not available |
Not available |
XCTest interoperability modes (Xcode 27, Swift 6.4 toolchain) |
Yes |
Yes |
Yes |
Yes |
Yes |
AppIntentsTesting (UI test target) |
New in 27 |
New in 27 |
New in 27 |
New in 27 |
Not available (no UI testing for visionOS SDK apps) |
References
-
Apple Developer Documentation — Organizing test functions with suite types
-
Apple Developer Documentation — Expectations and confirmations
-
Apple Developer Documentation — Testing for errors in Swift code
-
Apple Developer Documentation — Implementing parameterized tests
-
Apple Developer Documentation — Enabling and disabling tests
-
Apple Developer Documentation — Limiting the running time of tests
-
Apple Developer Documentation — Running tests serially or in parallel
-
Apple Developer Documentation — Migrating a test from XCTest
-
Apple Developer Documentation -- withObservationTracking(_:onChange:)
-
Apple Developer Documentation — Testing your App Intents code
-
Apple Developer Documentation — Running tests and interpreting results