Night Thoughts on Testing iOS In-App Purchases

What’s the business model for an iOS app? If you aren’t just distributing an app as a freebie out of the goodness of your heart (several of my apps do work that way), and if you aren’t being paid directly by a client to create or maintain the app in the first place, and if your company is not separately in the business of selling physical goods and services, you have three chief ways of trying to make money through an iOS app:

  • You can charge the user up front for the right to download the app in the first place.

  • You can use some third-party income source permitted by Apple’s App Store rules, such as a mobile advertising network.

  • You can make the app free to download initially, and then offer some additional benefit for an additional fee from within the app — an in-app purchase.

In-app purchases can be one of the most important aspects of an iOS app’s business model. Charging up front runs the risk that the user will never download the app in the first place. But if the user downloads a free app and likes it, goodwill and the desire for an improved experience may lead to a willingness to spend actual money.

There are three basic types of in-app purchase:

  • Nonconsumable. The user pays to flip a switch that stays flipped forever. For example, the app might display ads, but for a one-time fee, the ads go away.

  • Consumable. The user buys a component that is eventually used up. That’s the strategy of many game apps such as Candy Crush Saga: for a fee, you can add to your stock of lives, boosters, and so forth, but eventually you’ll use them, depleting your stock once again.

  • Subscription. The app charges a recurring payment for an ongoing service, such as the ability to watch certain streaming content.

A couple of my own apps offer in-app purchases — though I must admit, it’s not a great business model for me, as I’m almost entirely unable to induce any users to make the purchases, even though they are cheap, one-time nonconsumable purchases that vastly improve the app experience.

The subject of my Night Thoughts on this topic, however, is not why my in-app purchases don’t succeed. Rather, it’s how excruciatingly difficult they are to test!

Clearly, in trying your own app to see how it works, you’d like to play the role of a prospective user and pass through the actual steps and dialogs that this user would see when trying to perform an in-app purchase. Yet that turns out to be extraordinarily hard to do. In fact, I’ve never found a perfect way to do it. I can make my app enact some form of simulated in-app purchase, but it always differs in some crucial aspect from what the user would actually experience.

Quite frankly, I’ve never been able to understand this difficulty. How can something so basic to your app be so nondeterministic? This is the heart of how your app hopes to make any money — possibly its entire raison d’être — and yet you can’t readily learn whether your app is actually usable for that purpose? What on earth is Apple thinking here?

Things have gotten a bit better recently in this regard; new in Xcode 12, you can actually simulate the behavior of an in-app purchase directly while debugging in Xcode — at long last, and about time too! But all the same, reliable information about testing in-app purchases remains extraordinarily thin on the ground. Every time I release a new version of my apps that use them, I find the tools for testing in-app purchases mystifying and frustrating all over again.

Hence this article, summarizing my latest findings. I’m going to confine my remarks to a single simple nonconsumable purchase, because that’s all I have experience of.

Log Like Crazy

When the user elects, within in your app’s interface, to request information about an in-app purchase, your app talks asynchronously over the network to Apple’s App Store servers to obtain that information. When the user actually asks to perform a purchase, the runtime, in addition to talking to the App Store servers, puts up a sequence of dialogs, outside of your app’s own process, confirming the user’s desire to make the purchase, identifying the user, and informing the user of whether the purchase succeeded.

While all this stuff is happening asynchronously outside your app, your own code is occasionally called back. Reliably determining whether and when your own code is getting the expected messages and is responding correctly is extraordinarily tricky, and I would suggest that the only really good way to do so is through good old-fashioned caveman debugging, also known as logging.

As I said in an earlier article, the most powerful way to log is to use OSLog — or, in iOS 14, the Logger class. This has the advantage, among other things, that it works even when your app is running without any connection to Xcode. You’re going to want that! So start by instrumenting your code, such as your paymentQueue(_:updatedTransactions:) implementation, very thoroughly with logging calls.

For instance, eventually you’ll want to make a TestFlight version of the app, download it, and run it to try out your in-app purchase. (I’ll talk more about that at the end of this article.) When you do, you’ll open the Console on your Mac and configure it to show the logging messages from your app. This is extraordinarily useful for revealing what your code is doing under the hood.

Mocking the Store

Xcode 12 introduces a new feature that lets you pretend that Xcode itself is Apple’s App Store server. To get started with this, choose File > New > File and ask for a StoreKit Configuration File. Then, in your project’s Scheme, in the Run action, go to the Options pane and choose this file from the StoreKit Configuration pop-up menu.

Now edit the StoreKit Configuration file to configure the purchases that are supposed to exist for this app at the App Store. This is all happening locally, so Apple doesn’t even need to have a real purchase associated with this app! For a nonconsumable, you just make up a ProductID that you’ll reference in your app’s code, along with other information to be displayed in your app’s interface when the user asks to learn about the purchase. Note that some settings are made directly in the StoreKit Configuration file editor pane, while others are made by way of the Editor menu.

Your app probably has code that checks some persistent condition, such as information saved into the keychain, to determine whether the user has already made the purchase. So in order to persuade your app to offer the user a chance to enact the purchase, you might have to perform some hanky-panky within your app itself. For example, you might disable that check, either by changing your code temporarily or by injecting some launch argument or environment variable as a flag by way of the Scheme.

For example, let’s say I’ve injected a forceStore launch argument in my Scheme. In my code, I can check for that argument by fetching UserDefaults.standard.bool(forKey: "forceStore"). If that value is true, I allow presentation of the dialog where the user requests the purchase. There’s no danger of a Scheme-based launch argument wandering into the released app; it is present only when we run direct from Xcode. So this is a safe testing technique.

The point is that when your app does offer you, as the user, the opportunity to perform the purchase, you will be able to perform it and see the runtime out-of-process dialogs (as well as your own logging). And, if you’re using a StoreKit Configuration file, all of this is happening without any networking or any kind of contact with Apple’s App Store servers; the server behavior is being emulated by Xcode itself.

In real life, when the user has made a purchase, the App Store server maintains a memory of that fact. Xcode simulates that as well. So you can arrange to launch your app and see what happens when you perform a purchase that you’ve already performed, or ask to restore an existing purchase. Xcode is remembering that the purchase has been made, just as the real App Store would. To see for yourself what Xcode is remembering, choose Debug > StoreKit > Manage Transactions in Xcode. This opens a Transactions window where you can delete an existing purchase; after that, you can turn right around and enact performing the purchase again from scratch.

False Identity

Xcode’s ability to mock the store without networking is great, but obviously a real test is going to involve networking. You’re going to want to run your app and actually talk to Apple’s App Store servers during the in-app purchase transaction. For that, you’ll want two things:

  • A device. In-app purchase transactions with real networking don’t work well on the Simulator. In fact, in my experience they don’t work at all; there’s always an error alert saying “Unable to contact the App Store.”

  • A false identity. You wouldn’t want to perform an in-app purchase using your real Apple ID, because if you did that, you’d end up paying real money for the privilege of making the test. (I know this from experience, because I’ve actually made that mistake — once.)

Apple’s solution to the problem of supplying a false identity is a sandbox tester. The first step in using a sandbox tester is to create a sandbox account. At the App Store Connect web site, go to Users and Access. On the left, under Sandbox, click Testers. You’ll wind up at https://appstoreconnect.apple.com/access/testers.

On that page, there’s a Plus button. You’re being invited to make a Sandbox Apple ID. Click the Plus button and you’ll see a New Tester form that you have to fill out.

Now, before filling out this form, take a deep breath and accept the following reality: This is one of the buggiest online forms you’re ever going to encounter. It’s completely unreliable. The fields are very picky about what you can enter, but there’s no information as to what they expect. And at the lower right there’s an Invite button that you have to click in order to create the user — but half the time, this button will be unclickable (it is grayed out and says Inviting, and a spinner spins endlessly); in that situation, click Cancel and reload the whole page, and then click Testers and the Plus button all over again. Your goal is to see a grayed-out Invite button, which will become clickable when the form is correctly filled out.

Here are some clues about the filling out the fields of the form:

  • First Name, Last Name. Put anything you like. There’s no reality check.

  • Email. You may see a claim in some tutorials about making a sandbox tester that this needs to be a real email address. That claim is false. This email address can be completely fake. No email is going to be sent to it; in spite of the button that says Invite at the lower right, no one is actually going to be invited. There is, however, a requirement that the address should be unique, in the sense that it has not already been associated with any Apple ID. For example, I tried it with mickey@mouse.com and got an error; someone must have used that one already. But then I tried matt@mattNeuburgRocks.com and it worked fine, even though I am fairly certain that there is no such domain.

  • Password. Must be eight characters or longer. Must include letters, at least one capital letter, and at least one number. Remember the password! You’re going to need it later. In fact, you may end up entering it quite a lot, so it should be something easy to type.

  • Secret Question and Answer. Must be six characters or longer. I don’t know what this is for; it never arises during the transaction.

  • Date of Birth. Put anything you like. Again, I don’t know what it’s for.

  • App Store Country or Region. This is the actual App Store server you’re going to be talking to, so it’s meaningful.

If all has gone well and you’ve lived a clean life, when you click Invite, your creation of a sandbox tester will succeed.

Now you need to sign in to your sandbox tester as a sandbox tester on your device. Do not sign out of your normal Apple ID! Instead, in the Settings app, go to App Store. At the bottom, you’ll see Sandbox Account. (I’m pretty sure you’ll see that only if this device has ever been used for development; I’m assuming that it has.) Tap Sign In and enter the email and password for the sandbox tester account your created at App Store Connect. You will then be offered a chance to turn on two-factor authentication; roll your eyes and tap Other Options and then Don’t Upgrade. You will then see that you are signed in.

The outcome is that when you run a Debug build of your app on the device, directly from Xcode or not, when you perform the in-app purchase, your app will talk to the real App Store server — meaning that there must be a real in-app purchase associated with your app at the App Store, and the network must be available — but the sandbox tester account will be used automatically (though you will have to enter the corresponding password manually), and no money will change hands.

WARNING: If you were using a StoreKit Configuration file, be sure to unlink it from your scheme before building to your device from Xcode!

Unfortunately, in my experience, this approach does not result in an accurate emulation of the system dialogs. In particular, the three-dialog sequence of dialogs may appear twice (and so you’ll end up entering the password twice). Do not be alarmed! Your code is still being called correctly — as you can prove to yourself by observing the logging with which you instrumented it earlier.

Another big problem with a sandbox tester is this: unlike the Xcode 12 StoreKit emulation mode, where there’s a Transactions window that you can edit, you have no control over the transactions being remembered by the App Store server. This means you can only test performing an initial in-app purchase once per sandbox tester account. After that, this sandbox tester has already performed the purchase, and the server remembers it; so if you perform the purchase again, you’ll get a different set of dialogs, telling you that you are attempting to purchase something you already have.

As a result, after you’ve tested your app’s in-app purchase once from scratch, if you want to test again from scratch, you’ll need to make another sandbox tester with an email address you’ve never used before. (You can delete a sandbox tester, but you can never reuse that same email address.) This is just another example of the silliness — and inconvenience — associated with developing an in-app purchase.

Real Download

Now let’s talk about TestFlight. In my opinion, acquiring TestFlight from Burstly in 2014 is one of the best moves Apple ever made. It is an absolute boon to developers. With TestFlight, you can create a build that’s completely identical to something that users would be able to download from the App Store, but instead, authorized test users can download it for free using the TestFlight app on their devices, for testing purposes. In fact, when you’ve created a TestFlight build that you deem ready for release on the App Store, you can (and should) release the very same binary on the Store, without recompiling on Xcode at all. In this way, you are assured that what a test user will be testing is identically what real customers will obtain from the App Store, and that the behavior of the app on the tester’s device is exactly the behavior of the app on a customer’s device.

One eligible test user is you, the developer. So when you want to assure yourself of how your app really behaves when built in a Release configuration and sent into the wild for distribution, completely independent of Xcode, it’s a simple matter to pass a build up to App Store Connect via Xcode and down to your device via the TestFlight app. In fact, I test my own apps this way very frequently. It takes only a few minutes to round-trip the app through Apple’s server. The longest part of the delay is waiting for the app to finish “processing” at Apple’s end. (After that finishes, you have to go to the App Store Connect web site and deal with your build’s “missing compliance.” That’s certainly annoying, but it takes only a moment.) A TestFlight app does not needed to be vetted and approved by Apple for testing by an “internal” tester, which is what you are.

This approach, it turns out, has implications for in-app purchases. Even though the underlying binary is identically a potential App Store release binary, your device knows that this app was downloaded through TestFlight, and allows a test user to perform an in-app purchase with a normal Apple ID for free. This means your testers can help you ensure that your app behaves correctly before, during, and after an in-app purchase transaction.

But there’s a downside — the same downside I already described for sandbox testing. Apple’s server is remembering when a user has performed a purchase by way of a given Apple ID. Therefore, a user can only test the behavior of your app when performing a purchase transaction from scratch once, because after the first time, it isn’t “from scratch” any more; even if the app allows the user to try the purchase again, the user is shown a different set of dialogs, because this is a purchase that the user already owns. There is no way to tell Apple’s server to “forget” the existing TestFlight purchases.

The good news, if you can call it good, is that on your device, where you are probably signed in to a sandbox tester account, the device will use that account, and not your real Apple ID. (This means that the text in the Settings app, which I showed in an earlier screenshot, is wrong. The text asserts that “your existing App Store account,” and not the sandbox account, “will be used for TestFlight apps.” That’s false.) So you can do what I described in the previous section: at the App Store Connect web site, make a new sandbox tester, and then, on your device, sign in with that sandbox account. Now you can delete the app, reinstall it, and test from scratch once again.

As far as I can tell, TestFlight testing of an in-app purchase also suffers from the same issue I described in the previous section, where the device unaccountably makes you pass through the system dialogs (and enter your password) twice. This is unnerving, because your entire goal here is to see exactly what a real customer would see when performing the in-app purchase after obtaining the app from the App Store — and you can’t. The business of trying out your own app’s in-app purchase is still, after all these years, filled with indeterminacy and uncertainty, and there’s nothing you can do about it.

Final Note: Everything I just said about a TestFlight download is true also for an Ad Hoc distribution. It’s very easy nowadays to export an Ad Hoc build of your app from an archive in Xcode to the Finder and then install it directly onto your device. So you might prefer that approach, instead of round-tripping the app through Apple’s server with TestFlight. Still, I believe that a TestFlight download gives an even more realistic sense of your app’s behavior than an Ad Hoc build does, so I prefer TestFlight when possible.

You Might Also Like…

Little Swift Tricks: Boxing Multiple Types

Here’s a little Swift language trick I sometimes use — more often than you might suppose, actually. As I’m sure you know, Swift is very strict about the types of its objects. You have to declare clearly what type a reference is, and from then on, you have to stick to that. That’s one of …

Little Swift Tricks: Boxing Multiple Types Read More »

    Sign Up

    Get more articles and exclusive content that takes your iOS skills to the next level.