Skip to content

Introduce offline paywall snapshot testing (purchases-ui-testing + recorder plugin)#3622

Open
skydoves wants to merge 10 commits into
mainfrom
feature/paywall-ui-testing
Open

Introduce offline paywall snapshot testing (purchases-ui-testing + recorder plugin)#3622
skydoves wants to merge 10 commits into
mainfrom
feature/paywall-ui-testing

Conversation

@skydoves

@skydoves skydoves commented Jun 17, 2026

Copy link
Copy Markdown
Member

SDK consumers currently have no way to snapshot test their server driven paywalls (Paywalls V2 "components"). The only public entry point, Paywall(options), needs a configured Purchases instance, a network call for offerings, and Google Play Billing products, so it cannot run in a JVM unit test.

This PR adds a small, supported way to render a consumer's paywall fully offline (no Purchases.configure(), no network, no Billing) so it can be captured with Paparazzi or Roborazzi:

  1. A new published library, purchases-ui-testing, that renders a paywall from a recorded fixture.
  2. A new Gradle plugin that records those fixtures (offerings JSON plus the CDN images) from a public SDK key.

The render path reuses the exact same composable the SDK uses in production (LoadedPaywallComponents), so the snapshot matches what the app renders, with the only fabricated input being prices (which are not part of the offerings response).

There are new modules:

  • :ui:revenuecatui-testing (artifact purchases-ui-testing): the consumer facing API. PaywallFixtures.load(), PaywallFixtureView, PaywallFixtureViewOptions, and PaywallFixturesTestRule.
  • :paywall-fixtures-plugin (plugin id com.revenuecat.purchases.paywallfixtures): registers the recordPaywallFixtures task and, when the consumer applies Paparazzi, wires up the test dependency and testOptions.

Supporting changes:

  • :purchases: FixtureOfferingsFactory (annotated @InternalRevenueCatAPI) builds Offerings from an offerings shaped JSON, substituting TestStoreProducts for real store products. The hardcoded preview products were extracted so the previews and the factory share them.
  • :ui:revenuecatui: a thin internal bridge (ComponentsPaywallForTesting, also @InternalRevenueCatAPI) that runs validate then state then LoadedPaywallComponents with an injected image loader. Promoting these internals as hidden API keeps api.txt unchanged.
  • examples/paywall-tester: a consumer perspective PaywallSnapshotTest plus committed snapshot images, exercising only the kit's public API.

Setup and usage

1. Version catalog

The plugin and the kit are versioned with the SDK, so use the same version as your purchases dependency. Pick a Paparazzi version compatible with your AGP.

# gradle/libs.versions.toml
[versions]
paparazzi = "1.3.5"

[plugins]
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }
paywallfixtures = { id = "com.revenuecat.purchases.paywallfixtures", version = "<your purchases SDK version>" }

[libraries]
revenuecat-ui-testing = { module = "com.revenuecat.purchases:purchases-ui-testing", version = "<your purchases SDK version>" }

2. Module build.gradle.kts

Apply Paparazzi (your chosen version) and the recorder plugin. The kit test dependency and testOptions.unitTests.isIncludeAndroidResources are wired automatically when Paparazzi is present.

plugins {
    alias(libs.plugins.paparazzi)
    alias(libs.plugins.paywallfixtures)
}

3. The test

class PaywallSnapshotTest {
    @get:Rule val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
    @get:Rule val paywallFixturesRule = PaywallFixturesTestRule()

    private val fixtures = PaywallFixtures.load()

    @Test fun defaultPaywall() {
        paparazzi.snapshot { PaywallFixtureView(fixtures.offering()) }
    }
}

4. Recording fixtures (where the API key is used)

recordPaywallFixtures calls the RevenueCat offerings API to download the offerings JSON and the paywall images into src/test/resources/revenuecat-paywall-fixtures. It needs your app's public SDK key, the same key you pass to Purchases.configure (for example goog_... or appl_...). The key is used only at record time. The snapshot tests themselves render offline from the committed fixtures and need no key.

Provide the key without committing it, either via the REVENUECAT_API_KEY environment variable (the default source):

REVENUECAT_API_KEY=<public sdk key> ./gradlew recordPaywallFixtures

or via the extension, for example reading a local Gradle property:

// build.gradle.kts
paywallFixtures {
    apiKey.set(providers.gradleProperty("revenuecatApiKey"))
    // offerings.set(setOf("default"))  // optional: only record specific offerings
}

Then record (or verify) the snapshots:

./gradlew recordPaparazziDebug   # writes golden images
./gradlew verifyPaparazziDebug   # CI: compares against committed goldens

5. Opting out

The plugin is lean: it does not bring its own Paparazzi, so applying it never forces a Paparazzi version onto your classpath. To skip the auto wiring (for example to use Roborazzi, or to manage the test dependency and testOptions yourself), set paywallFixtures { setupSnapshotTesting = false } or pass -Prevenuecat.paywallFixtures.snapshotTesting=false.

Production code changes to review

Two changes touch shipped :ui:revenuecatui code and deserve attention:

  1. Image loader injection moved from the debug source set (which had a no op twin in release) into the main source set, and the consumption sites in RemoteImage and BackgroundStyle now key off "an image loader was injected" rather than LocalInspectionMode. Production behavior is unchanged because nothing in production provides the loader. This is the change that lets the published (release) artifact render fixture images.
  2. PaywallResourceProvider.getXmlFontFamily now returns null instead of crashing when the platform getXml returns null. layoutlib (Paparazzi) returns null where a device throws NotFoundException, which previously surfaced as a Kotlin intrinsic NPE. The kit also forces fonts to a system fallback during offline rendering so tests do not depend on the consumer bundling the dashboard's fonts.

Note

Medium Risk
Touches shipped revenuecatui image and font resolution paths (guarded by injected loader) and adds new published APIs/plugins; production paywall rendering should be unchanged when consumers do not opt into the testing kit.

Overview
Adds offline JVM snapshot testing for Paywalls V2 (components) so apps can golden-test dashboard paywalls without Purchases.configure(), network, or Play Billing.

New consumer surface: published purchases-ui-testing (PaywallFixtures, PaywallFixtureView, PaywallFixturesTestRule) loads recorded offerings.json + mirrored CDN images from test resources and renders through the same LoadedPaywallComponents path as production, with fabricated TestStoreProduct prices. com.revenuecat.purchases.uifixtures registers recordPaywallFixtures (offerings API + asset download) and optionally wires Paparazzi test deps when Paparazzi is applied.

Core SDK support: FixtureOfferingsFactory builds Offerings from offerings-shaped JSON; preview defaults move to TestStoreProductDefaults. ComponentsPaywallForTesting (internal) injects a classpath image loader, fixed date, mock purchases, and font fallback for Paparazzi.

Production revenuecatui tweaks (behavior unchanged when no loader is injected): preview image loading keys off LocalPreviewImageLoader instead of inspection mode (moved to main; release no-ops removed); getXmlFontFamily tolerates layoutlib returning null.

Example: paywall-tester gets parameterized Paparazzi tests; regenerated fixture trees under test resources are gitignored while snapshot images stay committed.

Reviewed by Cursor Bugbot for commit c2b6f2c. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a637a01. Configure here.

}
}
return response.put("offerings", kept)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale current offering after filter

Medium Severity

After filterOfferings drops offerings without paywall_components, the recorded offerings.json still keeps the API’s current_offering_id verbatim. When that id was removed, FixtureOfferingsFactory leaves Offerings.current null, so PaywallFixtures.offering() without an id fails even though valid component offerings were recorded.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a637a01. Configure here.

// image, rendering will surface a clear "fixture image missing" error.
log("Skipping asset (HTTP $responseCode): $url")
return false
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped 404 keeps old fixture image

Low Severity

When asset download gets HTTP 4xx, downloadAsset logs and returns without deleting an existing mirrored file. Re-running recordPaywallFixtures without --refresh can leave a previous image on disk while offerings.json changes, so offline snapshots may show outdated assets instead of failing clearly.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a637a01. Configure here.

@RevenueCat-Danger-Bot

Copy link
Copy Markdown

Danger has errored

[!] Invalid Dangerfile file: undefined method [] for nil:NilClass

    insertions: stats[:insertions],
                     ^^^^^^^^^^^^^. Updating the Danger gem might fix the issue. Your Danger version: 9.5.3, latest Danger version: 9.6.0
 #  from Dangerfile:45
 #  -------------------------------------------
 #  total_changed = prod_code_files.sum do |f|
 >    info = git.info_for_file(f)
 #    info ? info[:insertions] + info[:deletions] : 0
 #  -------------------------------------------

Generated by 🚫 Danger

@skydoves skydoves added the pr:feat A new feature label Jun 17, 2026
@emerge-tools

emerge-tools Bot commented Jun 17, 2026

Copy link
Copy Markdown

📸 Snapshot Test

21 modified, 9 added, 1 removed, 571 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
TestPurchasesUIAndroidCompatibility Paparazzi
com.revenuecat.testpurchasesuiandroidcompatibility.paparazzi
9 1 21 0 236 0 ⏳ Needs approval
TestPurchasesUIAndroidCompatibility
com.revenuecat.testpurchasesuiandroidcompatibility
0 0 0 0 335 0 N/A

🛸 Powered by Emerge Tools

@emerge-tools

emerge-tools Bot commented Jun 17, 2026

Copy link
Copy Markdown

1 build increased size, 1 build decreased size

Name Version Download Change Install Change Approval
TestPurchasesUIAndroidCompatibility
com.revenuecat.testpurchasesuiandroidcompatibility
1.0 (1) 71.1 MB ⬇️ 653.8 kB (-0.91%) 114.8 MB ⬇️ 916.6 kB (-0.79%) N/A
SDKSizeTesting
com.revenuecat.testapps.sdksizetesting
1.0 (1) 12.2 MB ⬆️ 10.7 kB (0.09%) 35.1 MB ⬆️ 20.8 kB (0.06%) N/A

TestPurchasesUIAndroidCompatibility 1.0 (1)
com.revenuecat.testpurchasesuiandroidcompatibility

⚖️ Compare build
⏱️ Analyze build performance

Total install size change: ⬇️ 916.6 kB (-0.79%)
Total download size change: ⬇️ 653.8 kB (-0.91%)

Largest size changes

Item Install Size Change Download Size Change
🗑 1181742_1751450815.heic ⬇️ -465.0 kB ⬇️ -465.0 kB
🗑 1181742_1751450815.webp ⬇️ -157.6 kB ⬇️ -154.9 kB
offerings.json ⬇️ -19.1 kB ⬇️ -17.5 kB
cpu.heic ⬇️ -2.7 kB ⬇️ -2.7 kB
archive.heic ⬇️ -2.5 kB ⬇️ -2.5 kB
View Treemap

Image of diff

SDKSizeTesting 1.0 (1)
com.revenuecat.testapps.sdksizetesting

⚖️ Compare build
⏱️ Analyze build performance

Total install size change: ⬆️ 20.8 kB (0.06%)
Total download size change: ⬆️ 10.7 kB (0.09%)

Largest size changes

Item Install Size Change Download Size Change
📝 com.revenuecat.purchases.ui.revenuecatui.testing.InternalPaywallF... ⬆️ 3.3 kB ⬆️ 1.1 kB
Other ⬆️ 17.5 kB ⬆️ 9.7 kB
View Treemap

Image of diff


🛸 Powered by Emerge Tools

Comment trigger: Size diff threshold of 100.00kB exceeded

@tonidero tonidero left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still reviewing but looking great so far!!

@@ -0,0 +1,56 @@
package com.revenuecat.purchases.paywallfixtures.internal

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Not related to this line]
I've been thinking whether it could make sense to rename this module to something more generic like ui-fixtures-plugin? Mostly thinking about eventually supporting the different screens for workflows...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with this!

val kept = JSONArray()
for (i in 0 until offerings.length()) {
val offering = offerings.getJSONObject(i)
val hasComponentsPaywall = offering.has("paywall_components")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we will need to change this soon, due to workflows (since information about paywalls will come from a separate endpoint) cc @vegaro . Should hopefully be not a huge change 🤞

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants