Migrate One XCTest File to Swift Testing
Swift Testing is easiest to adopt when you do not treat it like a weekend rewrite of your entire test target. Pick one XCTest file, convert the tests that are a natural fit, run both frameworks side-by-side for a while, and keep moving only when the new shape is clearer than the old one.
This post uses a small receipt validation test as the example. It is not tied to StoreKit specifically. The point is the migration pattern: XCTestCase becomes a suite, test... methods become @Test functions, XCTAssert... calls become #expect, required setup becomes #require, and repeated cases become parameterized tests.
Availability notes
Swift Testing is included with Xcode 16 and later. Import Testing from test targets or test-only helper libraries, not from an app target or a library that ships in production. Apple also notes that Swift Testing and XCTest can be in the same test target, and even in the same source file, but you should not mix their APIs inside the same test.
Swift Testing is for unit-style tests. Keep using XCTest for UI tests that use XCUIAutomation and for performance tests. If a test touches main-thread-only APIs, mark the test or suite with @MainActor; Swift Testing runs test functions on an arbitrary task unless you isolate them.
Platform-specific APIs still need normal availability checks. If a test requires a newer OS or Swift language version, put @available(...) on the @Test function. Do not put availability on the suite type; Swift Testing requires suite types to be always available.
The XCTest file
Here is a familiar XCTest shape. There is shared setup, one async success path, one throwing path, and one small table of cases stuffed into a loop:
import Foundation
import XCTest
@testable import Subscriptions
final class ReceiptValidatorTests: XCTestCase {
private var loader: ReceiptFixtureLoader!
private var validator: ReceiptValidator!
override func setUp() {
super.setUp()
loader = ReceiptFixtureLoader(bundle: .module)
validator = ReceiptValidator(
clock: .fixed(Date(timeIntervalSince1970: 1_739_491_200))
)
}
override func tearDown() {
loader = nil
validator = nil
super.tearDown()
}
func testValidReceiptUnlocksPremium() async throws {
let receipt = try await loader.load("premium-active")
let result = try await validator.validate(receipt)
XCTAssertTrue(result.isPremium)
XCTAssertEqual(result.productID, "pro.monthly")
XCTAssertNotNil(result.expirationDate)
}
func testMissingSignatureThrows() async throws {
let receipt = try await loader.load("missing-signature")
do {
_ = try await validator.validate(receipt)
XCTFail("Expected the validator to reject the receipt")
} catch ReceiptValidationError.missingSignature {
// Expected.
} catch {
XCTFail("Unexpected error: \(error)")
}
}
func testTrialEligibility() {
let cases = [
(purchaseCount: 0, usedTrial: false, expected: true),
(purchaseCount: 1, usedTrial: false, expected: false),
(purchaseCount: 0, usedTrial: true, expected: false)
]
for testCase in cases {
XCTAssertEqual(
validator.isEligibleForTrial(
purchaseCount: testCase.purchaseCount,
hasUsedTrial: testCase.usedTrial
),
testCase.expected
)
}
}
}
This file is not terrible. That is important. A migration does not have to prove XCTest was bad. Swift Testing just gives us better language-native tools for this kind of test.
Convert the class into a suite
Swift Testing does not require tests to live in an XCTestCase subclass. A type that contains @Test functions is a suite, and you can make that explicit with @Suite when you want a display name or traits.
import Foundation
import Testing
@testable import Subscriptions
extension Tag {
@Tag static var receipts: Self
}
@Suite("Receipt validator", .tags(.receipts))
struct ReceiptValidatorTests {
private let loader = ReceiptFixtureLoader(bundle: .module)
private let validator = ReceiptValidator(
clock: .fixed(Date(timeIntervalSince1970: 1_739_491_200))
)
}
A struct is usually a nice default because it keeps accidental shared state out of the picture. Swift Testing creates a fresh instance of the suite for each instance test function, so the stored properties above replace most ordinary setUp() code. If you need teardown, use a class or actor suite and put cleanup in deinit. A suite initializer can be async or throws, but an explicit helper is often clearer when setup is expensive.
The tag is declared as a static member on Tag, then applied with .tags(.receipts). Putting it on the suite means every test in the suite inherits it. That makes it easier to filter receipt tests in Xcode or in a test plan without depending on function names.
Convert assertions to expectations
Now move the success test over. @Test identifies the function as a test. The function name no longer has to start with test, so use the clearest name you can.
@Suite("Receipt validator", .tags(.receipts))
struct ReceiptValidatorTests {
private let loader = ReceiptFixtureLoader(bundle: .module)
private let validator = ReceiptValidator(
clock: .fixed(Date(timeIntervalSince1970: 1_739_491_200))
)
@Test("Valid receipt unlocks premium")
func validReceiptUnlocksPremium() async throws {
let receipt = try await loader.load("premium-active")
let result = try await validator.validate(receipt)
#expect(result.isPremium)
#expect(result.productID == "pro.monthly")
let expirationDate = try #require(result.expirationDate)
#expect(expirationDate > Date(timeIntervalSince1970: 1_739_491_200))
}
}
#expect accepts normal Swift expressions. That is the part I like most in day-to-day use. You do not have to pick from a long list of assertion functions just to compare two values or check a boolean.
Use #require when the rest of the test does not make sense unless a value exists. It is also the replacement for XCTUnwrap. In this example, there is no useful expiration-date comparison to run if expirationDate is nil, so the required unwrap keeps the failure close to the actual problem.
Convert throwing tests
The throwing test gets shorter because Swift Testing has a dedicated #expect(throws:) form:
@Test("Missing signature is rejected")
func missingSignatureThrows() async throws {
let receipt = try await loader.load("missing-signature")
await #expect(throws: ReceiptValidationError.missingSignature) {
try await validator.validate(receipt)
}
}
If you only care that a specific error type is thrown, use the type form:
await #expect(throws: ReceiptValidationError.self) {
try await validator.validate(receipt)
}
If the code should not throw, the simplest test is usually just to call it with try. A thrown error fails the test.
Replace loops with arguments
The table-driven XCTest loop works, but it has two annoyances: the whole loop is one reported test, and debugging one failed row usually means setting a breakpoint and stepping until the right case arrives. Parameterized tests make each argument show up as its own test case.
struct TrialEligibilityCase: CustomTestStringConvertible, Sendable {
let purchaseCount: Int
let hasUsedTrial: Bool
let expected: Bool
var testDescription: String {
"purchases: \(purchaseCount), used trial: \(hasUsedTrial)"
}
}
@Test("Trial eligibility", arguments: [
TrialEligibilityCase(
purchaseCount: 0,
hasUsedTrial: false,
expected: true
),
TrialEligibilityCase(
purchaseCount: 1,
hasUsedTrial: false,
expected: false
),
TrialEligibilityCase(
purchaseCount: 0,
hasUsedTrial: true,
expected: false
)
])
func trialEligibility(_ testCase: TrialEligibilityCase) {
let isEligible = validator.isEligibleForTrial(
purchaseCount: testCase.purchaseCount,
hasUsedTrial: testCase.hasUsedTrial
)
#expect(isEligible == testCase.expected)
}
The small CustomTestStringConvertible conformance is optional, but it makes the test output nicer. Instead of seeing a default struct dump, you get a readable case description. The explicit Sendable conformance matches the parameterized-test constraint that argument values must be safe to pass into test cases.
This is also a good time to ask whether the test data should be a tuple, a struct, or an enum. I like a small struct once there are three or more fields because the labels keep the cases honest.
Use traits carefully
Tags are only one kind of trait. You can also use traits to control whether a test runs, add a display name, associate bugs, serialize execution, or apply a time limit.
@Test(
"Refreshes receipt from the sandbox",
.enabled(if: ProcessInfo.processInfo.environment["RUN_SANDBOX_TESTS"] == "1"),
.tags(.receipts)
)
func refreshesReceiptFromSandbox() async throws {
let receipt = try await loader.load("sandbox-renewal")
let result = try await validator.validate(receipt)
#expect(result.isPremium)
}
Prefer a condition trait over an early return because the test result can show that the case was skipped. If tests use global mutable state or a shared external resource, fix the isolation when you can. If you cannot fix it yet, .serialized on a suite is a useful migration step because Swift Testing runs tests in parallel by default.
Coexist while you migrate
You do not need to convert every file before committing the first one. A test target can contain old XCTest files and new Swift Testing files. A single source file can import both modules while you are moving code around, but keep each individual test in one world: XCTAssert... inside XCTest methods, #expect and #require inside Swift Testing functions.
I would start with pure unit tests that already use async and throws, have minimal inheritance, and do not depend on addTeardownBlock, performance measurement, or UI automation. Leave UI tests in XCTest. Leave complicated fixture lifetime tests alone until you are comfortable with suites and parallel execution.
The end state for this file is smaller, but the real win is sharper feedback. A missing optional stops at the unwrap, repeated cases are visible as separate arguments, and the suite can be filtered by a tag instead of a naming convention. That is a good migration.
Further reading: