Welcome to the RevenueCat Test Store setup guide for Android!
Testing in-app purchases has always been challenging. You need to configure sandbox accounts, create test products, wait for app review, and deal with flaky network conditions. RevenueCat's Test Store eliminates these pain points by providing a deterministic, fast, and reliable testing environment.
Test Store is RevenueCat's built-in testing environment that allows you to test in-app purchase flows without connecting to Google Play Store. It's automatically provisioned with every new RevenueCat project and gives you complete control over purchase outcomes.
This codelab will guide you through the complete process of setting up and using RevenueCat's Test Store for Android development. You'll start by enabling Test Store in your RevenueCat dashboard and configuring your Android application to use Test Store API keys. Then, you'll learn how to test purchase flows with deterministic outcomes, write automated unit tests for your in-app purchase logic, and integrate these tests into your CI/CD pipeline using GitHub Actions.
Test Store revolutionizes how you test in-app purchases. Unlike traditional testing methods that require you to wait for Google Play configuration or app approval, Test Store provides instant testing capabilities. You get complete deterministic control over purchase outcomes: whether a transaction succeeds, fails, or gets cancelled is entirely up to you. This means no more dealing with flaky network connections or unreliable sandbox environments.
The real power comes when you start writing automated tests. With Test Store, you can build reliable unit tests for your purchase logic that run consistently in any environment, including CI/CD systems like GitHub Actions. This enables you to iterate quickly on your purchase flows, testing changes in seconds rather than minutes or hours.

Before you start, ensure you have:
The first step is to enable Test Store in your RevenueCat dashboard and obtain your Test Store API key.
Start by logging into your RevenueCat dashboard and navigating to the Apps & providers section in the left sidebar menu. This is where you'll find all your connected apps and available store integrations.
In the Apps & providers section, look for the Test Store option among the available providers. Test Store is automatically provisioned for every RevenueCat project, so you'll simply need to activate it by clicking Create Test Store or Enable Test Store. The setup is instant with no waiting for approvals or configuration sync.
Once Test Store is enabled, click on the Test Store entry in your apps list to view its details. Here you'll find your Test Store API Key, which starts with the prefix test_. This key functions just like a regular RevenueCat API key, but with one crucial difference: it routes all purchase requests to Test Store instead of Google Play, giving you full control over the testing environment.
Understanding API Key Separation: Test Store API keys are completely separate from your production and sandbox keys. This separation is intentional and important for security. Never use Test Store keys in production builds; they should only be used in debug and test environments. You can create multiple Test Store configurations per project if needed for different testing scenarios.

Now that you have your Test Store API key, you're ready to configure your Android application to use Test Store for testing.
Now let's configure your Android application to use Test Store. The key is to use your Test Store API key instead of your production API key when running tests.
Add Test Store API key configuration to your app's build.gradle.kts:
android {
defaultConfig {
// Your existing configuration
// Add Test Store API key for debug builds
buildConfigField("String", "REVENUECAT_TEST_STORE_API_KEY", "\"test_YOUR_KEY_HERE\"")
}
buildTypes {
debug {
// Use Test Store for debug builds
buildConfigField("String", "REVENUECAT_API_KEY", "\"test_YOUR_KEY_HERE\"")
}
release {
// Use production API key for release builds
buildConfigField("String", "REVENUECAT_API_KEY", "\"goog_YOUR_PRODUCTION_KEY\"")
}
}
}
Update your Application class to use the appropriate API key based on build type:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize RevenueCat SDK
val apiKey = BuildConfig.REVENUECAT_API_KEY
val builder = PurchasesConfiguration.Builder(this, apiKey)
Purchases.configure(
builder
.purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT)
.appUserID(null)
.diagnosticsEnabled(true)
.build()
)
// Enable debug logs for test builds
if (BuildConfig.DEBUG) {
Purchases.logLevel = LogLevel.DEBUG
}
}
}
For more advanced setups, you can create a configuration class:
object RevenueCatConfig {
fun getApiKey(context: Context): String {
return if (isTestEnvironment()) {
BuildConfig.REVENUECAT_TEST_STORE_API_KEY
} else {
BuildConfig.REVENUECAT_API_KEY
}
}
private fun isTestEnvironment(): Boolean {
// Check if running in test environment
return try {
Class.forName("androidx.test.espresso.Espresso")
true
} catch (e: ClassNotFoundException) {
false
} || BuildConfig.DEBUG
}
}
Then use it in your initialization:
val apiKey = RevenueCatConfig.getApiKey(this)
Purchases.configure(
PurchasesConfiguration.Builder(this, apiKey)
.purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT)
.build()
)
To verify Test Store is working:
"Purchases SDK initialized with Test Store"
With Test Store enabled, you can now test purchase flows with complete control over outcomes. When Test Store shows its purchase dialog, you decide the result.
When you initiate a purchase with Test Store, you'll see a custom Test Store dialog instead of the standard Google Play purchase sheet. This dialog displays the product details and price just like a real purchase dialog, but with one key difference: you get to control the outcome. The dialog presents three clear options: Successful Purchase to simulate a completed transaction, Failed Purchase to test payment failures, and Cancel to simulate user cancellation. This deterministic behavior is what makes Test Store so powerful for testing.
Let's test a successful purchase flow:
suspend fun purchaseProduct(activity: Activity) {
try {
// Fetch products from Test Store
val products = Purchases.sharedInstance.awaitGetProducts(
productIds = listOf("premium_monthly")
)
// Initiate purchase
val purchaseResult = Purchases.sharedInstance.awaitPurchase(
purchaseParams = PurchaseParams.Builder(
activity = activity,
storeProduct = products.first()
).build()
)
// Check the result
val customerInfo = purchaseResult.customerInfo
val isPremium = customerInfo.entitlements["premium"]?.isActive == true
if (isPremium) {
// Purchase successful!
showPremiumContent()
}
} catch (e: PurchasesException) {
// Handle error
handlePurchaseError(e)
}
}
Testing the Happy Path: Run your app and trigger the purchase flow as you normally would. When the Test Store dialog appears, tap "Successful Purchase". Your app should immediately grant premium access, and you can verify that the entitlements are properly updated. This lets you quickly validate that your success flow works correctly without needing a real payment method or sandbox account.
Now test how your app handles failures:
fun handlePurchaseError(error: PurchasesException) {
when (error.code) {
PurchasesErrorCode.PURCHASE_CANCELLED_ERROR -> {
// User cancelled - don't show error
Log.d("Purchase", "User cancelled purchase")
}
PurchasesErrorCode.PURCHASE_INVALID_ERROR -> {
// Invalid purchase
showError("This purchase is not available")
}
PurchasesErrorCode.PAYMENT_PENDING_ERROR -> {
// Payment pending (e.g., awaiting parental approval)
showPendingMessage()
}
else -> {
// Other errors
showError("Purchase failed: ${error.message}")
}
}
}
Testing Error Handling: Trigger the purchase flow again, but this time when the Test Store dialog appears, tap "Failed Purchase". Your app should gracefully handle the error, showing an appropriate error message to the user. Confirm that the user doesn't receive premium access and that your app's state remains consistent. This is crucial for ensuring your error handling logic works correctly in production.
@Composable
fun PaywallScreen(onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
var isPurchasing by remember { mutableStateOf(false) }
Button(
onClick = {
scope.launch {
isPurchasing = true
try {
purchaseProduct(LocalContext.current as Activity)
} catch (e: PurchasesException) {
if (e.code == PurchasesErrorCode.PURCHASE_CANCELLED_ERROR) {
// User cancelled - just dismiss
onDismiss()
}
}
isPurchasing = false
}
},
enabled = !isPurchasing
) {
Text(if (isPurchasing) "Processing..." else "Subscribe")
}
}
Testing User Cancellation: Open your paywall and tap the subscribe button. When the Test Store dialog appears, tap "Cancel" to simulate a user backing out of the purchase. Your paywall should dismiss cleanly without showing any error messages (cancellation is a normal user action, not an error state). Verify that no purchase was recorded and your app's state is unchanged.
The beauty of Test Store lies in its deterministic control. You decide exactly what happens with each purchase attempt. This means you can thoroughly test all scenarios (success, failure, and cancellation) in a matter of minutes, without needing real payment methods or dealing with the delays and complexities of sandbox accounts. By the time you're ready to integrate with Google Play, you'll have confidence that your purchase handling logic is rock-solid.

One of Test Store's most powerful features is enabling automated unit testing of your in-app purchase logic. Let's write tests that run reliably in CI/CD.
Add the following to your build.gradle.kts:
dependencies {
// RevenueCat SDK
implementation("com.revenuecat.purchases:purchases:8.20.0")
// Testing dependencies
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("io.mockk:mockk:1.13.8")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test:runner:1.5.2")
}
First, let's create a testable purchase repository:
interface PurchaseRepository {
suspend fun getProducts(productIds: List<String>): List<StoreProduct>
suspend fun purchaseProduct(activity: Activity, product: StoreProduct): CustomerInfo
suspend fun getCustomerInfo(): CustomerInfo
}
class PurchaseRepositoryImpl(
private val purchases: Purchases = Purchases.sharedInstance
) : PurchaseRepository {
override suspend fun getProducts(productIds: List<String>): List<StoreProduct> {
return purchases.awaitGetProducts(productIds)
}
override suspend fun purchaseProduct(
activity: Activity,
product: StoreProduct
): CustomerInfo {
val result = purchases.awaitPurchase(
PurchaseParams.Builder(activity, product).build()
)
return result.customerInfo
}
override suspend fun getCustomerInfo(): CustomerInfo {
return purchases.awaitCustomerInfo()
}
}
class PaywallViewModel(
private val repository: PurchaseRepository
) : ViewModel() {
private val _state = MutableStateFlow<PaywallState>(PaywallState.Loading)
val state: StateFlow<PaywallState> = _state.asStateFlow()
fun loadProducts() {
viewModelScope.launch {
try {
val products = repository.getProducts(listOf("premium_monthly"))
_state.value = PaywallState.Success(products)
} catch (e: Exception) {
_state.value = PaywallState.Error(e.message ?: "Unknown error")
}
}
}
fun purchaseProduct(activity: Activity, product: StoreProduct) {
viewModelScope.launch {
_state.value = PaywallState.Purchasing
try {
val customerInfo = repository.purchaseProduct(activity, product)
val isPremium = customerInfo.entitlements["premium"]?.isActive == true
if (isPremium) {
_state.value = PaywallState.PurchaseSuccess
} else {
_state.value = PaywallState.Error("Purchase completed but entitlement not active")
}
} catch (e: PurchasesException) {
when (e.code) {
PurchasesErrorCode.PURCHASE_CANCELLED_ERROR -> {
_state.value = PaywallState.PurchaseCancelled
}
else -> {
_state.value = PaywallState.Error(e.message)
}
}
}
}
}
}
sealed class PaywallState {
object Loading : PaywallState()
data class Success(val products: List<StoreProduct>) : PaywallState()
object Purchasing : PaywallState()
object PurchaseSuccess : PaywallState()
object PurchaseCancelled : PaywallState()
data class Error(val message: String) : PaywallState()
}
Now create unit tests using Test Store:
@RunWith(AndroidJUnit4::class)
class PaywallViewModelTest {
private lateinit var repository: PurchaseRepository
private lateinit var viewModel: PaywallViewModel
@Before
fun setup() {
// Initialize RevenueCat with Test Store API key
Purchases.configure(
PurchasesConfiguration.Builder(
ApplicationProvider.getApplicationContext(),
"test_YOUR_KEY_HERE"
).build()
)
repository = PurchaseRepositoryImpl()
viewModel = PaywallViewModel(repository)
}
@Test
fun testLoadProducts_Success() = runTest {
// Given: ViewModel is initialized
// When: Loading products
viewModel.loadProducts()
// Wait for state to update
advanceUntilIdle()
// Then: Products should be loaded successfully
val state = viewModel.state.value
assertTrue(state is PaywallState.Success)
assertFalse((state as PaywallState.Success).products.isEmpty())
}
@Test
fun testPurchaseProduct_Success() = runTest {
// Given: Products are loaded
viewModel.loadProducts()
advanceUntilIdle()
val state = viewModel.state.value as PaywallState.Success
val product = state.products.first()
// When: User purchases product and Test Store dialog shows "Successful Purchase"
// Note: In actual test, you'll need to interact with Test Store dialog
viewModel.purchaseProduct(mockActivity, product)
advanceUntilIdle()
// Then: Purchase should succeed and entitlement should be active
val finalState = viewModel.state.value
assertTrue(finalState is PaywallState.PurchaseSuccess)
}
@Test
fun testPurchaseProduct_Cancelled() = runTest {
// Given: Products are loaded
viewModel.loadProducts()
advanceUntilIdle()
val state = viewModel.state.value as PaywallState.Success
val product = state.products.first()
// When: User cancels purchase in Test Store dialog
viewModel.purchaseProduct(mockActivity, product)
advanceUntilIdle()
// Then: State should indicate cancellation
val finalState = viewModel.state.value
assertTrue(finalState is PaywallState.PurchaseCancelled)
}
}
For even more control, you can mock the repository:
class PaywallViewModelMockTest {
private lateinit var mockRepository: PurchaseRepository
private lateinit var viewModel: PaywallViewModel
@Before
fun setup() {
mockRepository = mockk()
viewModel = PaywallViewModel(mockRepository)
}
@Test
fun testPurchaseSuccess() = runTest {
// Given: Mock successful purchase
val mockProduct = mockk<StoreProduct>()
val mockCustomerInfo = mockk<CustomerInfo> {
every { entitlements["premium"]?.isActive } returns true
}
coEvery {
mockRepository.purchaseProduct(any(), mockProduct)
} returns mockCustomerInfo
// When: Purchase is initiated
viewModel.purchaseProduct(mockk(), mockProduct)
advanceUntilIdle()
// Then: State should be success
assertTrue(viewModel.state.value is PaywallState.PurchaseSuccess)
}
@Test
fun testPurchaseFailed() = runTest {
// Given: Mock failed purchase
val mockProduct = mockk<StoreProduct>()
coEvery {
mockRepository.purchaseProduct(any(), mockProduct)
} throws PurchasesException(
PurchasesErrorCode.PAYMENT_PENDING_ERROR,
"Payment is pending"
)
// When: Purchase is initiated
viewModel.purchaseProduct(mockk(), mockProduct)
advanceUntilIdle()
// Then: State should show error
val state = viewModel.state.value
assertTrue(state is PaywallState.Error)
assertTrue((state as PaywallState.Error).message.contains("pending"))
}
}
This testing approach eliminates the traditional pain points of testing in-app purchases. Because Test Store has no network dependencies, your tests run reliably every single time. No more flaky failures due to connection issues. Tests execute in seconds rather than minutes, giving you rapid feedback during development. You can achieve complete test coverage across all scenarios, including edge cases that would be difficult or time-consuming to reproduce with real payment systems.
The architecture we've built (with the repository pattern separating business logic from the SDK) makes these tests both fast and maintainable. You can run them in any CI/CD environment like GitHub Actions, catching bugs before they ever reach production.
Now let's configure your tests to run automatically in GitHub Actions, ensuring your purchase logic stays reliable with every code change.
Create .github/workflows/android-tests.yml:
name: Android Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run unit tests
env:
REVENUECAT_TEST_STORE_API_KEY: ${{ secrets.REVENUECAT_TEST_STORE_API_KEY }}
run: ./gradlew testDebugUnitTest
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: app/build/test-results/
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v3
with:
name: test-reports
path: app/build/reports/tests/
REVENUECAT_TEST_STORE_API_KEYtest_xxxxx)Update your build.gradle.kts to use the environment variable:
android {
defaultConfig {
// Get Test Store API key from environment or use placeholder
val testStoreApiKey = System.getenv("REVENUECAT_TEST_STORE_API_KEY")
?: "test_placeholder"
buildConfigField("String", "REVENUECAT_TEST_STORE_API_KEY", "\"$testStoreApiKey\"")
}
}
For more advanced testing, add instrumented tests:
instrumented-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Enable KVM (for faster emulator)
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run instrumented tests
uses: reactivecircus/android-emulator-runner@v2
env:
REVENUECAT_TEST_STORE_API_KEY: ${{ secrets.REVENUECAT_TEST_STORE_API_KEY }}
with:
api-level: 29
target: default
arch: x86_64
profile: Nexus 6
script: ./gradlew connectedDebugAndroidTest
- name: Upload instrumented test results
if: always()
uses: actions/upload-artifact@v3
with:
name: instrumented-test-results
path: app/build/outputs/androidTest-results/
Commit and push your changes to trigger the workflow. Head over to the GitHub Actions tab in your repository to watch your tests run automatically. You'll see real-time progress as the workflow checks out your code, sets up the environment, and runs your test suite. Within a few minutes, you'll know whether all tests pass in the CI environment.
Once this is set up, every pull request gets validated automatically before it can be merged. You'll get immediate feedback if any changes break your purchase logic, without having to manually test every scenario. Test Store ensures that your tests produce consistent results across every run, eliminating the frustration of flaky tests. This creates a quality gate that prevents buggy code from reaching your main branch, while saving countless hours that would otherwise be spent on repetitive manual testing.
Now that you've set up Test Store, let's cover best practices and how to transition between Test Store and production environments.
Always keep Test Store and production keys separate:
object RevenueCatKeys {
// Test Store - for testing only
const val TEST_STORE_KEY = "test_xxxxx"
// Google Play - for production
const val GOOGLE_PLAY_KEY = "goog_xxxxx"
fun getApiKey(isTestMode: Boolean): String {
return if (isTestMode) TEST_STORE_KEY else GOOGLE_PLAY_KEY
}
}
Use build variants to automatically switch between environments:
android {
buildTypes {
debug {
buildConfigField("String", "RC_API_KEY", "\"test_xxxxx\"")
buildConfigField("Boolean", "USE_TEST_STORE", "true")
}
release {
buildConfigField("String", "RC_API_KEY", "\"goog_xxxxx\"")
buildConfigField("Boolean", "USE_TEST_STORE", "false")
}
}
flavorDimensions += "environment"
productFlavors {
create("dev") {
dimension = "environment"
buildConfigField("String", "RC_API_KEY", "\"test_xxxxx\"")
}
create("staging") {
dimension = "environment"
buildConfigField("String", "RC_API_KEY", "\"test_xxxxx\"")
}
create("prod") {
dimension = "environment"
buildConfigField("String", "RC_API_KEY", "\"goog_xxxxx\"")
}
}
}
A robust testing strategy for in-app purchases should follow a pyramid approach. Start with unit tests using Test Store to validate your purchase logic in isolation. These run fast and catch most issues early. Build on that foundation with integration tests, also using Test Store, to verify complete purchase flows end-to-end. Once you're confident in your implementation, move to manual testing with Google Play Sandbox to verify platform integration. Finally, do a small-scale production test with real payments to ensure everything works in the live environment.
During initial development and iteration, Test Store is your best friend. It's perfect for rapid prototyping of purchase flows, writing unit and integration tests, and testing error handling and edge cases. The instant feedback loop makes it ideal for CI/CD automated testing where you need reliable, fast results.
Switch to Google Play Sandbox when you need to test platform-specific features that Test Store doesn't simulate. This includes pending transactions (like those awaiting parental approval), receipt validation specifics, subscription renewal cycles, and region-specific pricing. Think of Sandbox as your pre-production validation environment. Use it after your core logic is solid and you need to verify Google Play integration.
Never expose your Test Store API key in production:
// ❌ BAD: Hardcoded keys
val apiKey = "test_xxxxx"
// ✅ GOOD: Environment-based configuration
val apiKey = if (BuildConfig.DEBUG) {
BuildConfig.TEST_STORE_API_KEY
} else {
BuildConfig.GOOGLE_PLAY_API_KEY
}
// ✅ BETTER: Check for test environment
val apiKey = when {
isRunningInTests() -> BuildConfig.TEST_STORE_API_KEY
BuildConfig.DEBUG -> BuildConfig.TEST_STORE_API_KEY
else -> BuildConfig.GOOGLE_PLAY_API_KEY
}
Enable appropriate logging levels:
if (BuildConfig.USE_TEST_STORE) {
Purchases.logLevel = LogLevel.VERBOSE
Log.d("RevenueCat", "Using Test Store for testing")
} else if (BuildConfig.DEBUG) {
Purchases.logLevel = LogLevel.DEBUG
} else {
Purchases.logLevel = LogLevel.INFO
}
Before shipping your app, ensure you've completed a thorough checklist. All your tests should pass with Test Store, and you should have manually verified the integration with Google Play Sandbox. Double-check that your build variants are properly configured to use the right API keys in each environment. Production API keys must be secured and never committed to version control. Verify that Test Store keys are completely removed from release builds; they should only exist in debug configurations. Make sure logging is appropriately configured (verbose in debug, minimal in production), and confirm that your error handling has been tested across all scenarios.
Congratulations! 🎉 You've successfully set up RevenueCat's Test Store for Android. Throughout this codelab, you've learned how to enable Test Store in your RevenueCat dashboard, configure Test Store API keys in your Android application, test purchase flows with deterministic outcomes, write automated unit tests for your purchase logic, run those tests in CI/CD environments like GitHub Actions, and follow best practices for environment separation.
Test Store fundamentally changes how you approach in-app purchase testing. Gone are the days of wrestling with sandbox accounts and dealing with flaky network-dependent tests. With deterministic control over purchase outcomes, you can write reliable tests that give you true confidence in your implementation. The CI/CD integration means your purchase logic is continuously validated with every code change, enabling you to iterate on purchase flows in seconds instead of hours. Most importantly, you'll catch purchase-related bugs before they ever reach production, protecting both your revenue and user experience.
Now that you have Test Store set up, it's time to build on this foundation. Start by writing comprehensive tests covering all your purchase scenarios. Don't just test the happy path. Integrate these tests into your CI/CD pipeline so they run automatically on every pull request. Thoroughly test your error handling with different failure modes to ensure your app degrades gracefully. When you're ready, transition to Google Play Sandbox for platform-specific testing to verify things like subscription renewals. Finally, ship with confidence, knowing that your purchase logic has been thoroughly validated at every step.
Happy testing! 🚀