2022 App Store, Distribution & Marketing
WWDC22 · 35 min · App Store, Distribution & Marketing
What’s new in StoreKit testing
Discover the latest tools to help you test your in-app purchases and subscriptions. We’ll show you how to bring your products from App Store Connect into StoreKit Testing in Xcode, learn about improvements to the transaction manager, and explore your in-app purchase flow in Xcode Previews. We’ll also take you through best practices when setting up an Apple ID for the sandbox environment, and show you how to create tests for refund requests, price increase consent, billing retry, and much more.
Watch at developer.apple.com ↗Code shown on screen · 14 snippets
Subscription option view
VStack(alignment: .leading) {
Text(subscription.displayName)
.font(.headline.weight(.semibold))
Text(subscription.description)
} Refund view
struct RefundView: View {
private var selectedTransactionID: UInt64?
private var refundSheetIsPresented = false
(\.dismiss) private var dismiss
var body: some View {
Button {
refundSheetIsPresented = true
} label: {
Text("Request a refund")
.bold()
.padding(.vertical, 5)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding([.horizontal, .bottom])
.disabled(selectedTransactionID == nil)
.refundRequestSheet(
for: selectedTransactionID ?? 0,
isPresented: $refundSheetIsPresented
) { result in
if case .success(.success) = result {
dismiss()
}
}
}
} Refunds emit an updated value from the transaction updates sequence
for await update in Transaction.updates {
let transaction = try update.payloadValue
if let revocationDate = transaction.revocationDate,
let revocationReason = transaction.revocationReason {
print("\(transaction.productID) revoked on \(revocationDate)")
switch revocationReason {
case .developerIssue: <#Handle developer issue#>
case .other: <#Handle other issue#>
default: <#Handle unknown reason#>
}
<#Revoke access to the product#>
}
<#...#>
} Offer code view
struct SubscriptionPurchaseView: View {
private var redeemSheetIsPresented = false
var body: some View {
Button("Redeem an offer") {
redeemSheetIsPresented = true
}
.buttonStyle(.borderless)
.frame(maxWidth: .infinity)
.padding(.vertical)
.offerCodeRedeemSheet(isPresented: $redeemSheetIsPresented)
}
} Offer redemptions emit updated values from Transaction.updates and Product.SubscriptionInfo.Status.updates
for await verificationResult in Transaction.updates {
guard case .verified(let transaction) = verificationResult else {
<#Handle failed verification#>
}
<#Handle updated transaction#>
}
for await updatedStatus in Product.SubscriptionInfo.Status.updates {
guard case .verified(let renewalInfo) = updatedStatus.renewalInfo else {
<#Handle failed verification#>
}
<#Handle updated status#>
} Check the active offer on the transaction value
for await status in Product.SubscriptionInfo.Status.updates {
let transaction = try status.transaction.payloadValue
let renewalInfo = try status.renewalInfo.payloadValue
if let currentOfferType = transaction.offerType {
switch currentType {
case .introductory: <#Handle introductory offer#>
case .promotional: <#Handle promotional offer#>
case .code: <#Handle offer for codes#>
default: <#Handle unknown offer type#>
}
self.hasCurrentOffer = true
}
<#...#>
} Check the next pending offer on the renewal info value
for await status in Product.SubscriptionInfo.Status.updates {
let transaction = try status.transaction.payloadValue
let renewalInfo = try status.renewalInfo.payloadValue
<#Check active current offer#>
if let nextOfferType = renewalInfo.offerType {
switch currentType {
case .introductory: <#Handle introductory offer#>
case .promotional: <#Handle promotional offer#>
case .code:
print("Customer has \(renewalInfo.offerID) queued")
<#Handle offer for codes#>
default: <#Handle unknown offer type#>
}
self.hasQueuedOffer = true
}
<#...#>
} Messages updates loop
private var pendingMessages: [Message] = []
private func updatesLoop() {
for await message in Message.messages {
if <#Check if sensitive view is presented#>,
let display: DisplayMessageAction = <#Get display message action#> {
try? display(message)
}
else {
pendingMessages.append(message)
}
}
} Price increase changes emit an updated value from the status updates sequence
for await status in Product.SubscriptionInfo.Status.updates {
let renewalInfo = try status.renewalInfo.payloadValue
if renewalInfo.priceIncreaseStatus == .agreed {
print("Customer consented to price increase")
<#Handle consented to price increase#>
}
if renewalInfo.expirationReason == .didNotConsentToPriceIncrease {
print("Customer did not consent to price increase")
<#Handle expired due to not consenting to price increase#>
}
<#...#>
} Unit testing price increases
let session: SKTestSession = try SKTestSession(configurationFileNamed: "<#Configuration name#>")
session.disableDialogs = true
<#Purchase a subscription#>
var transaction: SKTestTransaction! = session.allTransactions().first
session.requestPriceIncreaseConsentForTransaction(identifier: transaction.identifier)
transaction = session.allTransactions().first
XCTAssertTrue(transaction.isPendingPriceIncreaseConsent)
<#Assert app updates for pending price increase#>
// Write a test case for consenting and cancelling due to price increase:
session.consentToPriceIncreaseForTransaction(identifier: transaction.identifier)
// OR
session.declinePriceIncreaseForTransaction(identifier: transaction.identifier)
session.expireSubscription(productIdentifier: "<#Product ID#>")
<#Assert app updates for finished price increase#> Billing retry and grace period status changes emit an updated value from the status updates sequence
for await status in Product.SubscriptionInfo.Status.updates {
let renewalInfo = try status.renewalInfo.payloadValue
if let gracePeriodExpirationDate = renewalInfo.gracePeriodExpirationDate,
gracePeriodExpirationDate < .now {
print("In grace period until \(gracePeriodExpirationDate)”)
<#Allow access to subscription#>
}
else if renewalInfo.isInBillingRetry {
<#Handle billing retry#>
}
<#...#>
} Using the state property of a status value to check for billing retry states
struct SubscriptionStatusView: View {
let currentSubscription: Product
let status: Product.SubscriptionInfo.Status
(\.openURL) var openURL
var body: some View {
Section("Your Subscription") {
<#...#>
if status.state == .inBillingRetryPeriod || status.state == .inGracePeriod {
VStack {
Text("""
There was a problem renewing your subscription. Open the App Store to
update your payment information.
""")
Button("Open the App Store") {
openURL(URL(string: "https://apps.apple.com/account/billing")!)
}
}
}
}
}
} Current entitlement APIs will account for grace period
for await entitlement in Transaction.currentEntitlements {
<#Grant access to product#>
} Unit testing billing retry and grace period
let session: SKTestSession = try SKTestSession(configurationFileNamed: "<#Configuration name#>")
session.billingGracePeriodIsEnabled = true
session.shouldEnterBillingRetryOnRenewal = true
<#Purchase a subscription#>
wait(for: [<#XCTExpectation#>], timeout: 60)
let transaction: SKTestTransaction! = session.allTransactions().first
XCTAssertTrue(transaction.hasPurchaseIssue)
<#Assert app still allows access to subscription due to grace period#>
wait(for: [<#XCTExpectation#>], timeout: 60)
<#Assert app detects billing retry and no longer allows access to subscription#>
session.resolveIssueForTransaction(identifier: transaction.identifier)
<#Assert app allows access to subscription#> Resources
Related sessions
-
37 min -
24 min -
20 min -
48 min -
20 min -
38 min -
34 min -
25 min