---
title: "Simplify in-app purchase unit testing with RevenueCat’s Test Store"
description: "You'll deep dive into building reliable, automated unit tests for your in-app purchases logic, especially based on Android and Kotlin"
language: "en"
publishedAt: "2025-11-12T01:04:36Z"
updatedAt: "2025-11-12T01:04:36Z"
authors:
  - name: "Jaewoong Eum"
    url: "https://www.revenuecat.com/blog/author/jaewoong-eum"
category: "Engineering"
categoryUrl: "https://www.revenuecat.com/blog/engineering"
readingTime: 10
canonical: "https://www.revenuecat.com/blog/engineering/testing-test-store"
---

# Simplify in-app purchase unit testing with RevenueCat’s Test Store

You'll deep dive into building reliable, automated unit tests for your in-app purchases logic, especially based on Android and Kotlin

## Table of contents

- [Understanding the core abstraction: What makes Test Store special](#understanding-the-core-abstraction-what-makes-test-store-special)
- [How to create the Test Store and test products](#how-to-create-the-test-store-and-test-products)
- [The test architecture: Unit tests vs. instrumented tests](#the-test-architecture-unit-tests-vs-instrumented-tests)
  - [Understanding source sets](#understanding-source-sets)
  - [The instrumented test requirement](#the-instrumented-test-requirement)
  - [The test setup: configuration and API key management](#the-test-setup-configuration-and-api-key-management)
  - [Step 1: Getting the Android context](#step-1-getting-the-android-context)
  - [Step 2: Initializing the SDK](#step-2-initializing-the-sdk)
  - [Step 3: Managing the Test Store API key](#step-3-managing-the-test-store-api-key)
- [Test implementation: verifying SDK connection and offerings](#test-implementation-verifying-sdk-connection-and-offerings)
  - [The connection test structure](#the-connection-test-structure)
  - [Fetching customer info](#fetching-customer-info)
  - [Validating the response](#validating-the-response)
  - [Fetching offerings: testing product configuration](#fetching-offerings-testing-product-configuration)
  - [Validating package and product details](#validating-package-and-product-details)
  - [Verifying the current offering](#verifying-the-current-offering)
- [Testing the purchase flow: Espresso UI interaction](#testing-the-purchase-flow-espresso-ui-interaction)
  - [Why purchase tests need an Activity](#why-purchase-tests-need-an-activity)
  - [Creating a test Activity](#creating-a-test-activity)
  - [Testing successful purchases with Espresso](#testing-successful-purchases-with-espresso)
  - [Step 1: Preparing the purchase](#step-1-preparing-the-purchase)
  - [Step 2: Launching the Activity and initiating purchase](#step-2-launching-the-activity-and-initiating-purchase)
  - [Step 3: Interacting with Test Store’s dialog](#step-3-interacting-with-test-stores-dialog)
  - [Step 4: Waiting for purchase completion](#step-4-waiting-for-purchase-completion)
  - [Testing purchase cancellation](#testing-purchase-cancellation)
  - [Testing failed purchases](#testing-failed-purchases)
- [Running the tests: Gradle commands and CI integration](#running-the-tests-gradle-commands-and-ci-integration)
- [Wrapping up](#wrapping-up)

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](https://www.revenuecat.com/docs/getting-started/configuring-sdk#testing-with-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](https://github.com/RevenueCat/cat-paywall-compose/pull/19).

## **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](https://app.revenuecat.com/), and click the **Apps & providers** menu on the sidebar, then you can create your Test Store like the image below:

![](https://cdn.sanity.io/images/c3qnx9b0/production/6d73e6505cf9d19b1cd2ce1f7f0a1dafd73cbf7a-911x267.png)

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.

![](https://cdn.sanity.io/images/c3qnx9b0/production/ee8e9fe2265f897809b8053f0f371ea47411c05b-675x196.png)

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.

![](https://cdn.sanity.io/images/c3qnx9b0/production/da5680da69527b66c8e2a3d027e93bb6dbfce45c-751x428.png)

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:

```kotlin
src\/test\/kotlin\/             # Unit tests (JVM) - Fast, no Android framework
src\/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:

```kotlin
Purchases.configure(
  PurchasesConfiguration.Builder(context, BuildConfig.REVENUECAT_TEST_API_KEY)
    .purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT)
    .diagnosticsEnabled(true)
    .build()
)

```

This means you can’t test purchase flows in pure JVM unit tests. You need either:

1. **Instrumented tests:** Run on a device/emulator with real Android framework
1. **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**

```kotlin
@Before
fun setup() {
  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:

```kotlin
\/\/ Configure Purchases SDK with Test Store API key
  Purchases.logLevel = LogLevel.DEBUG
  Purchases.configure(
    PurchasesConfiguration.Builder(context, BuildConfig.REVENUECAT_TEST_API_KEY)
      .purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT)
      .build()
  )
```

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:

```kotlin
# local.properties
revenuecat.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:

```kotlin
android {
  defaultConfig {
    buildConfigField("String", "REVENUECAT_TEST_API_KEY", "\\"${properties['revenuecat.test.api.key'] ?: ''}\\"")
  }
}

```

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](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/):

```kotlin
@Test
fun testSDKConnection() = runTest {
  ..
}

```

`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:

```kotlin
  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:

1. Connects to RevenueCat’s servers
1. Authenticates with your Test Store API key
1. Creates or retrieves an anonymous user
1. 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:

```kotlin
  assertNotNull("CustomerInfo should not be null", customerInfo)
  assertNotNull("User ID should not be null", customerInfo.originalAppUserId)
  assertFalse("User ID should not be empty", customerInfo.originalAppUserId.isEmpty())
  assertNotNull("First seen date should not be null", customerInfo.firstSeen)
  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.

```kotlin
@Test
fun testFetchOfferings() = runTest {
  val offerings = Purchases.sharedInstance.awaitOfferings()
}

```

`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.

```kotlin
  offerings.all.forEach { (id, offering) ->
\/\/ Verify offering has required fields
    assertNotNull("Offering identifier should not be null", offering.identifier)
    assertEquals("Offering map key should match identifier", id, offering.identifier)
    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**

```kotlin
    offering.availablePackages.forEach { pkg ->
      assertNotNull("Package identifier should not be null", pkg.identifier)
      assertFalse("Package identifier should not be empty", pkg.identifier.isEmpty())
      assertNotNull("Product should not be null", pkg.product)
      assertNotNull("Product ID should not be null", pkg.product.id)
      assertNotNull("Product price should not be null", pkg.product.price)
    }
```

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:

```kotlin
@Test
fun testCurrentOffering() = runTest {
  val offerings = Purchases.sharedInstance.awaitOfferings()
  val currentOffering = offerings.current

  assertTrue(
    "Current offering should have at least one package",
    currentOffering.availablePackages.isNotEmpty()
  )
}

```

## **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:

```kotlin
suspend fun awaitPurchase(purchaseParams: PurchaseParams): PurchaseResult
```

The Activity is needed because:

1. Google Play Billing shows UI (though Test Store doesn’t use real billing)
1. The billing flow needs a lifecycle to attach to
1. RevenueCat validates that the Activity is active before starting purchases
### **Creating a test Activity**

For testing, you need a minimal Activity that launches purchases:

```kotlin
class TestPurchaseActivity : Activity() {
  private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())

  override fun onDestroy() {
    super.onDestroy()
    scope.cancel()
  }

  fun launchPurchase(
    packageToPurchase: Package,
    callback: (PurchaseResult?, Throwable?) -> Unit
  ) {
    scope.launch {
      try {
        val purchaseParams = PurchaseParams.Builder(this@TestPurchaseActivity, packageToPurchase).build()
        val result = Purchases.sharedInstance.awaitPurchase(purchaseParams)
        callback(result, null)
      } catch (e: Exception) {
        callback(null, e)
      }
    }
  }
}

```

Breaking down the implementation:

- **Coroutine scope:** Tied to `Dispatchers.Main` because purchase UI must show on the main thread. Using `SupervisorJob()` 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**

```kotlin
@Test
fun testSuccessfulPurchaseFlow() = runBlocking {
\/\/ Fetch offeringsval offerings = Purchases.sharedInstance.awaitOfferings()
  val testOffering = offerings.all["test-offering"]
  assertNotNull("test-offering should exist", testOffering)

  val packageToPurchase = testOffering!!.availablePackages.first()

\/\/ Get initial customer info to compare laterval initialCustomerInfo = Purchases.sharedInstance.awaitCustomerInfo()
  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**

```kotlin
  activityScenario = ActivityScenario.launch(TestPurchaseActivity::class.java)

  var purchaseResult: PurchaseResult? = null
  var purchaseError: Throwable? = null

  activityScenario.onActivity { activity ->
    activity.launchPurchase(packageToPurchase) { result, error ->
      purchaseResult = result
      purchaseError = error
    }
  }
```

`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**

```kotlin
  delay(2000)\/\/ Give dialog time to appear

  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**

```kotlin
  withTimeout(30.seconds) {
    while (purchaseResult == null && purchaseError == null) {
      delay(500)
    }
  }
```

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**

```kotlin
  assertNotNull("Purchase should complete without error", purchaseResult)

  val result = purchaseResult!!

\/\/ Verify entitlements were granted
  assertTrue(
    "Should have active entitlements after purchase",
    result.customerInfo.entitlements.active.isNotEmpty()
  )

\/\/ Verify transaction details
  assertTrue(
    "Transaction should contain purchased product",
    result.storeTransaction.productIds.contains(packageToPurchase.product.id)
  )
}

```

The test verifies three things:

1. Purchase completed without error (no exception)
1. Entitlements were granted (active entitlements exist)
1. 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:

```kotlin
@Test
fun testPurchaseCancellation() = runBlocking {
  val offerings = Purchases.sharedInstance.awaitOfferings()
  val packageToPurchase = offerings.all["test-offering"]!!.availablePackages.first()

  activityScenario = ActivityScenario.launch(TestPurchaseActivity::class.java)

  var purchaseError: Throwable? = null
  activityScenario.onActivity { activity ->
    activity.launchPurchase(packageToPurchase) { _, error ->
      purchaseError = error
    }
  }

  delay(2000)
  onView(withText("Cancel")).perform(click())

  withTimeout(15.seconds) {
    while (purchaseError == null) delay(500)
  }

  assertNotNull("Should have error after cancellation", purchaseError)
  assertTrue(
    "Error should indicate cancellation",
    purchaseError?.message?.contains("cancel", ignoreCase = true) == true
  )
}

```

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:

```kotlin
@Test
fun testFailedPurchase() = runBlocking {
  val offerings = Purchases.sharedInstance.awaitOfferings()
  val packageToPurchase = offerings.all["test-offering"]!!.availablePackages.first()

\/\/ Capture initial stateval initialCustomerInfo = Purchases.sharedInstance.awaitCustomerInfo()
  val initialEntitlements = initialCustomerInfo.entitlements.active.size

  activityScenario = ActivityScenario.launch(TestPurchaseActivity::class.java)

  var purchaseError: Throwable? = null
  activityScenario.onActivity { activity ->
    activity.launchPurchase(packageToPurchase) { _, error ->
      purchaseError = error
    }
  }

  delay(2000)
  onView(withText("Test failed Purchase")).perform(click())

  withTimeout(15.seconds) {
    while (purchaseError == null) delay(500)
  }

  assertNotNull("Should have error after failed purchase", purchaseError)

\/\/ Verify entitlements unchangedval finalCustomerInfo = Purchases.sharedInstance.awaitCustomerInfo()
  val finalEntitlements = finalCustomerInfo.entitlements.active.size

  assertFalse(
    "Failed purchase should not grant entitlements",
    finalEntitlements > initialEntitlements
  )
}

```

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:

```kotlin
# Run all instrumented tests in the data module
.\/gradlew :core:data:connectedAndroidTest

# Run only Test Store tests
.\/gradlew :core:data:connectedAndroidTest --tests "*RevenueCatTestStoreTest"
.\/gradlew :core:data:connectedAndroidTest --tests "*TestStorePurchaseFlowTest"

# Run with verbose output
.\/gradlew :core:data:connectedAndroidTest --info
```

The `connectedAndroidTest` task requires a connected device or running emulator. For CI, you’d typically use an emulator:

```kotlin
# GitHub Actions example- name: Start emulator
  uses: reactivecircus\/android-emulator-runner@v2
  with:
    api-level: 30
    target: google_apis
    arch: x86_64
    script: .\/gradlew :core:data:connectedAndroidTest

- name: Upload test results
  uses: actions\/upload-artifact@v3
  if: always()
  with:
    name: test-results
    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!

---

## Related posts

- [remember vs rememberSaveable: deep dive into state management and recomposition in Jetpack Compose](https://www.revenuecat.com/blog/engineering/remember-vs-remembersaveable)
- [Understanding the internal of Flow, StateFlow, and SharedFlow](https://www.revenuecat.com/blog/engineering/flow-internals)
- [Understanding Google Play subscription proration: a developer’s guide](https://www.revenuecat.com/blog/engineering/google-proration)
