How do you unit-test the purchase and entitlement logic when Product and Transaction cannot be created in a test?

28.7.6 · L3 · Interview

Predict first

Do three tests of the store logic against a fake backend pass in a real Swift package?

// === Package.swift ===
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "StoreCore", targets: [
    .target(name: "StoreCore"),
    .testTarget(name: "StoreCoreTests", dependencies: ["StoreCore"]),
])
// === Sources/StoreCore/StoreCore.swift ===
public struct Offer: Equatable, Sendable { public let id: String; public let price: Int
    public init(id: String, price: Int) { self.id = id; self.price = price } }
public protocol StoreBackend: Sendable {
    func offers(for ids: [String]) async throws -> [Offer]
    func entitled() async -> Set<String>
}
public struct StoreFailure: Error, Equatable { public init() {} }
public actor StoreModel {
    public private(set) var offers: [Offer] = []
    public private(set) var missing: [String] = []
    public private(set) var entitled: Set<String> = []
    public private(set) var loadFailed = false
    private let ids: [String]; private let backend: StoreBackend
    public init(ids: [String], backend: StoreBackend) { self.ids = ids; self.backend = backend }
    public func load() async {
        do {
            let found = try await backend.offers(for: ids)
            offers = found.sorted { $0.price < $1.price }
            missing = ids.filter { id in !found.contains { $0.id == id } }
            loadFailed = false
        } catch { offers = []; missing = ids; loadFailed = true }
        entitled = await backend.entitled()
    }
    public func isUnlocked(_ id: String) -> Bool { entitled.contains(id) }
}
// === Tests/StoreCoreTests/StoreCoreTests.swift ===
import Testing
@testable import StoreCore

struct Fake: StoreBackend {
    var found: [Offer] = []; var owned: Set<String> = []; var fail = false
    func offers(for ids: [String]) async throws -> [Offer] { if fail { throw StoreFailure() }; return found }
    func entitled() async -> Set<String> { owned }
}
@Test func sortsAndReportsMissingIDs() async {
    let model = StoreModel(ids: ["a", "b", "c"], backend: Fake(found: [Offer(id: "b", price: 9), Offer(id: "a", price: 3)]))
    await model.load()
    #expect(await model.offers.map(\.id) == ["a", "b"])
    #expect(await model.missing == ["c"])
}
@Test func unlocksOnlyOwnedProducts() async {
    let model = StoreModel(ids: ["pro"], backend: Fake(found: [Offer(id: "pro", price: 5)], owned: ["pro"]))
    await model.load()
    #expect(await model.isUnlocked("pro"))
    #expect(await model.isUnlocked("other") == false)
}
@Test func aFailedLoadIsVisibleNotSilent() async {
    let model = StoreModel(ids: ["pro"], backend: Fake(fail: true))
    await model.load()
    #expect(await model.loadFailed)
    #expect(await model.offers.isEmpty)
}
  • Yes: all three tests pass
  • No: actors cannot be tested
  • The missing-id test fails

Mental model. You rehearse the cash handling with a toy till and play money (a fake backend), then rehearse the real till in the training room (StoreKit testing), and only finally open for one real customer (sandbox and TestFlight).

Product, Transaction and VerificationResult come from the system and cannot be constructed in a test, so test your logic against a seam you own. The store object takes a small backend protocol (offers(for:), entitled()) and keeps the decisions (sorting, missing ids, load failure, unlocked or not) in plain Swift. The live implementation of the protocol is a thin wrapper over StoreKit that you exercise with a StoreKit configuration file in the simulator and with the sandbox, not in unit tests. The check runs a real Swift package with three tests against a fake backend: the catalogue is sorted and a missing id is reported, only owned products are unlocked, and a failed load is visible (loadFailed) rather than silently empty.

Layer your confidence. Unit tests (fast, no store): the decision logic, with fakes for found, missing, owned, revoked and failing cases. StoreKit testing in Xcode (SKTestSession with a .storekit file, in an XCTest host): purchase, renewal, refund, Ask to Buy, billing retry and price changes driven by the test session, so you can test your handling of every transaction state without waiting for real renewals. Sandbox and TestFlight: the real signing chain and the server notifications. Write server-side tests on recorded signed payloads (a verified sample for each notification type, including duplicates and out-of-order delivery, Q1007). Keep one end-to-end checklist for the things nothing automated proves (a real purchase, restore on a second device, a refund), and run it before each release that touches billing.

The decision logic is plain Swift behind a protocol, so a fake backend exercises it without the App Store.

Common traps

  • Testing only against the live store. The tests are slow, flaky and cannot produce a refund or a billing retry on demand.
  • Putting the decisions inside the StoreKit wrapper. Then there is nothing left to unit-test.

They might follow up with

  • Can I run SKTestSession from a command-line tool? It needs an XCTest host (an app or test bundle), so run it in the Xcode test target, not in a script.
  • What do I assert for a refund? That the product is no longer in the entitled set after the revocation is delivered through the updates path.

Related questions

  • 28.7.1 How do you structure a StoreKit 2 integration: which object owns products, entitlements, the updates listener…
  • 28.7.5 How do you tie a purchase to your own user with appAccountToken, and why derive the token instead of storing…
  • 28.5.2 How do you test purchases: StoreKit configuration files, sandbox accounts and TestFlight, and what can each…
  • 28.4.3 How do App Store Server Notifications V2 behave, and how do you process them idempotently and out of order?

← How do you tie a purchase to your own user with appAccountToken, and…   What must be true in App Store Connect and in the app before you ship… →