-
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.
Recursos
- Implementing offer codes in your app
- Learn more about setting up offer codes
- Reducing Involuntary Subscriber Churn
- Testing In-App Purchases with sandbox
- Setting up StoreKit Testing in Xcode
- Handling Subscriptions Billing
- Auto-renewable subscriptions overview
Videos relacionados
WWDC23
- Explore testing in-app purchases
- Meet StoreKit for SwiftUI
- What’s new in StoreKit 2 and StoreKit Testing in Xcode
WWDC22
WWDC21
WWDC20
-
Buscar este video…
-
-
6:58 - Subscription option view
VStack(alignment: .leading) { Text(subscription.displayName) .font(.headline.weight(.semibold)) Text(subscription.description) } -
11:18 - Refund view
struct RefundView: View { @State private var selectedTransactionID: UInt64? @State private var refundSheetIsPresented = false @Environment(\.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() } } } } -
12:33 - 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#> } <#...#> } -
14:21 - Offer code view
struct SubscriptionPurchaseView: View { @State private var redeemSheetIsPresented = false var body: some View { Button("Redeem an offer") { redeemSheetIsPresented = true } .buttonStyle(.borderless) .frame(maxWidth: .infinity) .padding(.vertical) .offerCodeRedeemSheet(isPresented: $redeemSheetIsPresented) } } -
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#> } -
16:31 - 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 } <#...#> } -
16:49 - 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 } <#...#> } -
18:45 - 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) } } } -
20:53 - 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#> } <#...#> } -
21:19 - 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#> -
24:57 - 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#> } <#...#> } -
25:27 - 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 @Environment(\.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")!) } } } } } } -
25:41 - Current entitlement APIs will account for grace period
for await entitlement in Transaction.currentEntitlements { <#Grant access to product#> } -
25:50 - 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#>
-