In-app purchase testing has long been a pain point in Android development. Setting up Google Play sandbox environments, managing test accounts, waiting for purchase verification, dealing with cached state… the friction is real. Luckily, RevenueCat’s Test Store is a solution to this problem — offering instant testing without the complexity of real billing systems. But the real benefit of Test Store isn’t just its simplified setup, it’s how it enables true unit testing of purchase flows, with minimal infrastructure.
Read on to explore how to write unit tests for in-app purchases using RevenueCat’s Test Store, examining real test implementations that verify offering fetching, purchase flows, entitlement granting, and error handling. We’ll also deep dive into building reliable, fast unit tests for your monetization code, especially based on Android, but the overall approach will not be much different across platforms. You can see a complete implementation of these tests in this pull request.
Understanding the core abstraction: What makes Test Store special
Test Store is a mock billing backend that behaves exactly like production RevenueCat, but without requiring real payment processing from Google Play Billing or StoreKit. What distinguishes Test Store from Google Play’s sandbox is its adherence to two fundamental properties: instant availability and complete control.
There’s not much setup required beyond enabling your Test Store and getting your Test Store API key from the dashboard. You don’t need to configure test accounts, wait for Google Play sandbox propagation, or deal with payment method requirements.
Complete control means you decide the outcome of every purchase. When the Test Store shows its dialog, you choose: successful purchase, failed purchase, or cancellation. This determinism is what makes unit testing possible — you can reliably test both happy paths and error conditions without flaky network dependencies.
These properties aren’t just conveniences, they’re architectural constraints that enable fast, reliable unit tests. You can run hundreds of purchase flow tests in minutes because there’s no real billing service, no network latency, and no external state to manage.
How to create the Test Store and test products
To enable Test Store, go to the RevenueCat dashboard, and click the Apps & providers menu on the sidebar, then you can create your Test Store like the image below:

Once you click Create Test Store, you’ll receive a Test Store API key. You can use this key just like a regular secret API key when running in a test environment, allowing you to perform in-app purchases through the Test Store instead of the real app stores.
Next, navigate to Product Catalog → Products, and create Test Products under the Test Store section just as you’d create regular products. You can also attach entitlements and configure the test products as needed.

Finally, make sure to add your test product to the offering, within a package. This links it to your Test Store, so you can switch between test and live stores just by changing the API key.

And that’s it! With your Test Store API key, you’ve basically got your own mini app store, like Google Play or the App Store, where you can freely test and run unit tests for in-app purchase flows without any limitations.
The test architecture: Unit tests vs. instrumented tests
When structuring tests for in-app purchases, you’ll need to decide between unit tests and instrumented tests. For Test Store, instrumented tests are required because in-app purchases depend on Android-specific APIs, such as Activity.
Understanding source sets
Android projects typically have two test source sets:
1src/test/kotlin/ # Unit tests (JVM) - Fast, no Android framework
2src/androidTest/kotlin/ # Instrumented tests - Run on device/emulator
- Unit tests: Run on the JVM without the Android framework — they’re fast (milliseconds) but can’t access Android APIs like Context, Activity, or hardware sensors.
- Instrumented tests: Run on an actual Android device or emulator — they have full access to the Android framework but are slower to execute.
The instrumented test requirement
RevenueCat’s SDK requires an Android context for initialization:
1Purchases.configure(
2 PurchasesConfiguration.Builder(context, BuildConfig.REVENUECAT_TEST_API_KEY)
3 .purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT)
4 .diagnosticsEnabled(true)
5 .build()
6)
7
This means you can’t test purchase flows in pure JVM unit tests. You need either:
- Instrumented tests: Run on a device/emulator with real Android framework
- Robolectric tests: Simulate Android framework on the JVM (not covered here)
Instrumented tests provide the most accurate representation of production behavior. The tests run on an actual Android environment, using the real RevenueCat SDK with Test Store backend. This gives you confidence that the integration works correctly, not just that your ‘mocks’ behave as expected.
The test setup: configuration and API key management
Before diving into test implementations, let’s examine the setup required. Every test class needs to configure the RevenueCat SDK before running tests. This happens in a @Before method that runs before each test.
Step 1: Getting the Android context
1@Before
2fun setup() {
3 val context = InstrumentationRegistry.getInstrumentation().targetContext
InstrumentationRegistry provides access to the test environment. The targetContext is the application context of the app being tested, this is what RevenueCat needs for initialization.
Step 2: Initializing the SDK
Next, you should initialize RevenueCat SDK inside the setup function like the below:
1// Configure Purchases SDK with Test Store API key
2 Purchases.logLevel = LogLevel.DEBUG
3 Purchases.configure(
4 PurchasesConfiguration.Builder(context, BuildConfig.REVENUECAT_TEST_API_KEY)
5 .purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT)
6 .build()
7 )
Breaking down each configuration option:
- LogLevel.DEBUG: Enables detailed SDK logging; in production you’d use LogLevel.WARN or LogLevel.ERROR, but for testing, verbose logs help trace issues
- purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT): Tells the SDK that RevenueCat’s backend handles purchase acknowledgment — this is the recommended approach, and what Test Store expects
Step 3: Managing the Test Store API key
The Test Store API key is loaded from BuildConfig, which reads from local.properties:
1# local.properties
2revenuecat.test.api.key=test_YOUR_KEY_HERE
This keeps secrets out of version control. The Gradle build script injects it as a build config field:
1android {
2 defaultConfig {
3 buildConfigField("String", "REVENUECAT_TEST_API_KEY", "\\"${properties['revenuecat.test.api.key'] ?: ''}\\"")
4 }
5}
6
For CI environments, you’d set this via environment variables instead.
Test implementation: verifying SDK connection and offerings
Let’s start with the most basic test: verifying that the SDK can connect to the Test Store and fetch customer info. This test validates that your setup is correct before attempting more complex purchase flows.
The connection test structure
The test uses runTest from kotlinx-coroutines-test:
1@Test
2fun testSDKConnection() = runTest {
3 ..
4}
5
runTest provides a controlled coroutine environment for testing suspend functions. It automatically waits for all launched coroutines to complete and fails the test if any throw exceptions.
Fetching customer info
The core operation is fetching customer info:
1 val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()
awaitCustomerInfo() is a suspend function that returns CustomerInfo, RevenueCat’s representation of a user’s subscription state. This single call does several things:
- Connects to RevenueCat’s servers
- Authenticates with your Test Store API key
- Creates or retrieves an anonymous user
- Returns subscription and entitlement data
If this call succeeds, it means your Test Store configuration is correct.
Validating the response
The test validates specific fields that should always be present:
1 assertNotNull("CustomerInfo should not be null", customerInfo)
2 assertNotNull("User ID should not be null", customerInfo.originalAppUserId)
3 assertFalse("User ID should not be empty", customerInfo.originalAppUserId.isEmpty())
4 assertNotNull("First seen date should not be null", customerInfo.firstSeen)
5 assertNotNull("Entitlements map should not be null", customerInfo.entitlements)
Breaking down what each assertion catches:
- originalAppUserId: The anonymous user ID generated by RevenueCat — if this is null or empty, user tracking won’t work correctly
- firstSeen: Timestamp of when this user was first seen — this should never be null for a valid customer
- entitlements: Map of all entitlements (may be empty for new users, but the map itself should exist)
Fetching offerings: testing product configuration
The next test verifies that offerings can be fetched from the Test Store.
1@Test
2fun testFetchOfferings() = runTest {
3 val offerings = Purchases.sharedInstance.awaitOfferings()
4}
5
awaitOfferings() returns an Offerings object containing all configured offerings. The all property is a map of offering ID to Offering object. Now, you have offerings, you can verify each offering has a valid identifier.
1 offerings.all.forEach { (id, offering) ->
2// Verify offering has required fields
3 assertNotNull("Offering identifier should not be null", offering.identifier)
4 assertEquals("Offering map key should match identifier", id, offering.identifier)
5 assertNotNull("Available packages should not be null", offering.availablePackages)
These assertions verify the data structure integrity:
- Identifier consistency: The map key must match the offering’s identifier — this ensures lookups work correctly
- Packages exist: Every offering must have packages — an offering without packages can’t be purchased
Validating package and product details
1 offering.availablePackages.forEach { pkg ->
2 assertNotNull("Package identifier should not be null", pkg.identifier)
3 assertFalse("Package identifier should not be empty", pkg.identifier.isEmpty())
4 assertNotNull("Product should not be null", pkg.product)
5 assertNotNull("Product ID should not be null", pkg.product.id)
6 assertNotNull("Product price should not be null", pkg.product.price)
7 }
This validates that each product has the fields your UI needs. If pkg.product.price were null, displaying it would crash. This test catches that during development, not in production.
Verifying the current offering
Most apps display a ‘current offering’ to users, aka the primary monetization option. Testing this requires graceful handling when it’s not configured:
1@Test
2fun testCurrentOffering() = runTest {
3 val offerings = Purchases.sharedInstance.awaitOfferings()
4 val currentOffering = offerings.current
5
6 assertTrue(
7 "Current offering should have at least one package",
8 currentOffering.availablePackages.isNotEmpty()
9 )
10}
11
Testing the purchase flow: Espresso UI interaction
The most important tests involve full purchase flows. These tests use Espresso to interact with Test Store’s dialog, simulating user actions like clicking ‘Test valid Purchase’ or ‘Cancel’.
Why purchase tests need an Activity
RevenueCat’s purchase API requires an Activity context:
1suspend fun awaitPurchase(purchaseParams: PurchaseParams): PurchaseResult
The Activity is needed because:
- Google Play Billing shows UI (though Test Store doesn’t use real billing)
- The billing flow needs a lifecycle to attach to
- RevenueCat validates that the Activity is active before starting purchases
Creating a test Activity
For testing, you need a minimal Activity that launches purchases:
1class TestPurchaseActivity : Activity() {
2 private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
3
4 override fun onDestroy() {
5 super.onDestroy()
6 scope.cancel()
7 }
8
9 fun launchPurchase(
10 packageToPurchase: Package,
11 callback: (PurchaseResult?, Throwable?) -> Unit
12 ) {
13 scope.launch {
14 try {
15 val purchaseParams = PurchaseParams.Builder(this@TestPurchaseActivity, packageToPurchase).build()
16 val result = Purchases.sharedInstance.awaitPurchase(purchaseParams)
17 callback(result, null)
18 } catch (e: Exception) {
19 callback(null, e)
20 }
21 }
22 }
23}
24
Breaking down the implementation:
- Coroutine scope: Tied to
Dispatchers.Mainbecause purchase UI must show on the main thread. UsingSupervisorJob()ensures one failed purchase doesn’t cancel other operations - Proper cleanup: The scope is cancelled in
onDestroy()to prevent coroutine leaks when the Activity finishes. - Callback-based API: Tests need to wait for purchase completion. A callback signals when the purchase finishes (successfully or with error), allowing the test thread to synchronize with the purchase operation.
Testing successful purchases with Espresso
A successful purchase test involves several steps: fetching offerings, launching an Activity, initiating a purchase, interacting with Test Store’s dialog, and verifying the results. Let’s break it down step by step.
Step 1: Preparing the purchase
1@Test
2fun testSuccessfulPurchaseFlow() = runBlocking {
3// Fetch offeringsval offerings = Purchases.sharedInstance.awaitOfferings()
4 val testOffering = offerings.all["test-offering"]
5 assertNotNull("test-offering should exist", testOffering)
6
7 val packageToPurchase = testOffering!!.availablePackages.first()
8
9// Get initial customer info to compare laterval initialCustomerInfo = Purchases.sharedInstance.awaitCustomerInfo()
10 val initialActiveEntitlements = initialCustomerInfo.entitlements.active.size
The test uses runBlocking instead of runTest because it needs to interact with UI (Espresso) while waiting for async operations. We fetch the offering we’ll purchase and capture the initial entitlement state to verify changes later.
Step 2: Launching the Activity and initiating purchase
1 activityScenario = ActivityScenario.launch(TestPurchaseActivity::class.java)
2
3 var purchaseResult: PurchaseResult? = null
4 var purchaseError: Throwable? = null
5
6 activityScenario.onActivity { activity ->
7 activity.launchPurchase(packageToPurchase) { result, error ->
8 purchaseResult = result
9 purchaseError = error
10 }
11 }
ActivityScenario.launch() starts the test Activity. The onActivity block executes on the main thread with access to the Activity instance. We call launchPurchase(), which triggers awaitPurchase() in a coroutine. The callback will fire when the purchase completes.
Step 3: Interacting with Test Store’s dialog
1 delay(2000)// Give dialog time to appear
2
3 onView(withText("Test valid Purchase")).perform(click())
After initiating the purchase, Test Store shows a dialog with three options. Espresso’s onView() finds the button by text and perform(click()) simulates a user tap.
The delay(2000) gives the dialog time to appear. This is a pragmatic approach, in production tests you’d use Espresso’s idling resources for more reliable synchronization, but for Test Store the delay is sufficient.
Step 4: Waiting for purchase completion
1 withTimeout(30.seconds) {
2 while (purchaseResult == null && purchaseError == null) {
3 delay(500)
4 }
5 }
This polling loop waits for the callback to fire. The purchase happens asynchronously in the Activity’s coroutine, while the test thread polls the result variables. withTimeout ensures the test fails if the purchase hangs rather than blocking forever.
Step 5: Verifying the results
1 assertNotNull("Purchase should complete without error", purchaseResult)
2
3 val result = purchaseResult!!
4
5// Verify entitlements were granted
6 assertTrue(
7 "Should have active entitlements after purchase",
8 result.customerInfo.entitlements.active.isNotEmpty()
9 )
10
11// Verify transaction details
12 assertTrue(
13 "Transaction should contain purchased product",
14 result.storeTransaction.productIds.contains(packageToPurchase.product.id)
15 )
16}
17
The test verifies three things:
- Purchase completed without error (no exception)
- Entitlements were granted (active entitlements exist)
- Transaction contains the correct product ID
This catches subtle bugs where purchase succeeds but entitlements aren’t granted correctly. Your app relies on entitlements to unlock features — if they’re not granted, premium features won’t work.
Testing purchase cancellation
Cancellation testing verifies that your app handles user-initiated cancellations correctly:
1@Test
2fun testPurchaseCancellation() = runBlocking {
3 val offerings = Purchases.sharedInstance.awaitOfferings()
4 val packageToPurchase = offerings.all["test-offering"]!!.availablePackages.first()
5
6 activityScenario = ActivityScenario.launch(TestPurchaseActivity::class.java)
7
8 var purchaseError: Throwable? = null
9 activityScenario.onActivity { activity ->
10 activity.launchPurchase(packageToPurchase) { _, error ->
11 purchaseError = error
12 }
13 }
14
15 delay(2000)
16 onView(withText("Cancel")).perform(click())
17
18 withTimeout(15.seconds) {
19 while (purchaseError == null) delay(500)
20 }
21
22 assertNotNull("Should have error after cancellation", purchaseError)
23 assertTrue(
24 "Error should indicate cancellation",
25 purchaseError?.message?.contains("cancel", ignoreCase = true) == true
26 )
27}
28
The assertion checks that the error message contains ‘cancel’. This is important; your app needs to distinguish between user cancellation (don’t show error UI) and actual errors (show error message). When a user taps ‘Cancel’, it’s not an error condition, it’s expected behavior that shouldn’t trigger error alerts.
Testing failed purchases
Failed purchase testing verifies that billing errors don’t grant entitlements:
1@Test
2fun testFailedPurchase() = runBlocking {
3 val offerings = Purchases.sharedInstance.awaitOfferings()
4 val packageToPurchase = offerings.all["test-offering"]!!.availablePackages.first()
5
6// Capture initial stateval initialCustomerInfo = Purchases.sharedInstance.awaitCustomerInfo()
7 val initialEntitlements = initialCustomerInfo.entitlements.active.size
8
9 activityScenario = ActivityScenario.launch(TestPurchaseActivity::class.java)
10
11 var purchaseError: Throwable? = null
12 activityScenario.onActivity { activity ->
13 activity.launchPurchase(packageToPurchase) { _, error ->
14 purchaseError = error
15 }
16 }
17
18 delay(2000)
19 onView(withText("Test failed Purchase")).perform(click())
20
21 withTimeout(15.seconds) {
22 while (purchaseError == null) delay(500)
23 }
24
25 assertNotNull("Should have error after failed purchase", purchaseError)
26
27// Verify entitlements unchangedval finalCustomerInfo = Purchases.sharedInstance.awaitCustomerInfo()
28 val finalEntitlements = finalCustomerInfo.entitlements.active.size
29
30 assertFalse(
31 "Failed purchase should not grant entitlements",
32 finalEntitlements > initialEntitlements
33 )
34}
35
The critical assertion is that entitlements don’t increase after a failed purchase. This catches bugs where error handling is incomplete and entitlements are granted even when purchase fails. Your app must handle this correctly, failed purchases shouldn’t unlock premium features.
Running the tests: Gradle commands and CI integration
To run these tests locally, use Gradle’s connected test tasks:
1# Run all instrumented tests in the data module
2./gradlew :core:data:connectedAndroidTest
3
4# Run only Test Store tests
5./gradlew :core:data:connectedAndroidTest --tests "*RevenueCatTestStoreTest"
6./gradlew :core:data:connectedAndroidTest --tests "*TestStorePurchaseFlowTest"
7
8# Run with verbose output
9./gradlew :core:data:connectedAndroidTest --info
The connectedAndroidTest task requires a connected device or running emulator. For CI, you’d typically use an emulator:
1# GitHub Actions example- name: Start emulator
2 uses: reactivecircus/android-emulator-runner@v2
3 with:
4 api-level: 30
5 target: google_apis
6 arch: x86_64
7 script: ./gradlew :core:data:connectedAndroidTest
8
9- name: Upload test results
10 uses: actions/upload-artifact@v3
11 if: always()
12 with:
13 name: test-results
14 path: '**/build/reports/androidTests/'
Now, you can even verify the entire in-app purchases testing flows within your CI machine, which is entirely automated.
Wrapping up
So, we’ve explored how to write comprehensive unit tests for in-app purchases using RevenueCat’s Test Store, automating real test implementations that verify offerings, purchase flows, entitlements, and error handling without requiring real payment processing or Google Play infrastructure. Now, it’s time for you to get testing!
By having unit tests for in-app purchase flows, you’ll have confidence in your monetization code. Whether you’re building a new subscription feature, refactoring purchase flows, or debugging entitlement issues, these tests provide a foundation for reliable, fast verification of your in-app purchase implementation. The key is leveraging Test Store’s deterministic behavior, letting you control success, failure, and cancellation, making it possible to test error paths that are difficult or impossible to test withing real billing systems.
As always, happy coding!

