Save 37% off PRO during our Black Friday Sale! »

Keeping your Pixels Perfect: Paparazzi 1.0

Keeping your Pixels Perfect: Paparazziย 1.0

Unit tests allow you refactor your code with confidence and -- if architected correctly -- run blazingly fast!

But how do you ensure that your UI looks as expected? Espresso tests are hard to set up, prone to flakiness and require APKs, emulators, dexing, ADB...ugh. Snapshot tests get you closer but also rely on instrumentation tests.

Come find out how we're improving the UI testing loop on Cash App with Paparazzi -- an Android testing library to render your application screens without a physical device or emulator!

Video: coming soon!

6c8b509fe5422470d148c2c4cf2eb4b0?s=128

John Rodriguez

October 21, 2021
Tweet

Transcript

  1. Keeping your Pixels Perfect Paparazzi 1.0

  2. None
  3. None
  4. None
  5. None
  6. None
  7. None
  8. None
  9. None
  10. None
  11. None
  12. None
  13. None
  14. None
  15. None
  16. None
  17. None
  18. None
  19. None
  20. None
  21. None
  22. None
  23. None
  24. None
  25. None
  26. None
  27. None
  28. None
  29. None
  30. None
  31. None
  32. None
  33. None
  34. class LaunchViewTest { @get:Rul e val paparazzi = Paparazzi( )

    @Tes t fun launch() { val view = paparazzi.inflate<LinearLayout>(R.layout.launch ) paparazzi.snapshot(view ) } }
  35. class LaunchViewTest { @get:Rul e val paparazzi = Paparazzi( )

    @Tes t fun launch() { val view = paparazzi.inflate<LinearLayout>(R.layout.launch ) paparazzi.snapshot(view ) } }
  36. class LaunchViewTest { @get:Rul e val paparazzi = Paparazzi( )

    @Tes t fun launch() { val view = paparazzi.inflate<LinearLayout>(R.layout.launch ) paparazzi.snapshot(view ) } }
  37. class LaunchViewTest { @get:Rul e val paparazzi = Paparazzi( )

    @Tes t fun launch() { val view = LaunchView(paparazzi.context ) paparazzi.snapshot(view ) } }
  38. class LaunchViewTest { @get:Rul e val paparazzi = Paparazzi( )

    @Tes t fun launch() { val view = LaunchView(paparazzi.context ) paparazzi.snapshot(view ) } }
  39. $ ./gradlew sample:testDebug

  40. $ ./gradlew sample:recordPaparazziDebug

  41. $ ./gradlew sample:verifyPaparazziDebug

  42. @RunWith(TestParameterInjector::class ) class TestParameterInjectorTest

  43. @RunWith(TestParameterInjector::class ) class TestParameterInjectorTest ( @TestParameter config: Confi g )

    { enum class Config(val deviceConfig: DeviceConfig) {โ€ฆ}a enum class Theme(val themeName: String) {โ€ฆ}a @get:Rul e val paparazzi = Paparazzi(deviceConfig = config.deviceConfig ) @Tes t fun simpleWithTheme(@TestParameter theme: Theme) { val launch = paparazzi.inflate<LinearLayout>(R.layout.launch ) paparazzi.snapshot(launch, theme = theme.themeName ) } }
  44. @RunWith(TestParameterInjector::class ) class TestParameterInjectorTest ( @TestParameter config: Confi g )

    { enum class Config(val deviceConfig: DeviceConfig) { PIXEL_4(deviceConfig =aDeviceConfig.PIXEL_4) , PIXEL_5(deviceConfig =aDeviceConfig.PIXEL_5) , }a enum class Theme(val themeName: String) { LIGHT("android:Theme.Material.Light") , LIGHT_NO_ACTION_BAR("android:Theme.Material.Light.NoActionBar" ) }a @get:Rul e val paparazzi = Paparazzi(deviceConfig = config.deviceConfig ) @Tes t fun simpleWithTheme(@TestParameter theme: Theme) { val launch = paparazzi.inflate<LinearLayout>(R.layout.launch ) )
  45. @RunWith(TestParameterInjector::class ) class TestParameterInjectorTest ( @TestParameter config: Confi g )

    { enum class Config(val deviceConfig: DeviceConfig) {โ€ฆ}a enum class Theme(val themeName: String) {โ€ฆ}a @get:Rul e val paparazzi = Paparazzi(deviceConfig = config.deviceConfig ) @Tes t fun simpleWithTheme(@TestParameter theme: Theme) { val launch = paparazzi.inflate<LinearLayout>(R.layout.launch ) paparazzi.snapshot(launch, theme = theme.themeName ) } }
  46. @RunWith(TestParameterInjector::class ) class TestParameterInjectorTest ( @TestParameter config: Confi g )

    { enum class Config(val deviceConfig: DeviceConfig) {โ€ฆ}a enum class Theme(val themeName: String) {โ€ฆ}a @get:Rul e val paparazzi = Paparazzi(deviceConfig = config.deviceConfig ) @Tes t fun simpleWithTheme(@TestParameter theme: Theme) { val launch = paparazzi.inflate<LinearLayout>(R.layout.launch ) paparazzi.snapshot(launch, theme = theme.themeName ) } }
  47. None
  48. Presenter View Ui<UiModel, UiEvent> Presenter<UiEvent, UiModel>

  49. class CardPreviewView ( context: Context , attrs: AttributeSet? = null

    , ) : FrameLayout(context, attrs) , Ui<CardPreviewViewModel, CardPreviewViewEvent> { override fun setModel(model: CardPreviewViewModel) { title.text = model.titl e description.text = model.descriptio n orderButton.text = model.orde r โ€ฆ cardView.render(model.cardViewModel) } }
  50. class CardPreviewView ( context: Context , attrs: AttributeSet? = null

    , ) : FrameLayout(context, attrs) , Ui<CardPreviewViewModel, CardPreviewViewEvent> { override fun setModel(model: CardPreviewViewModel) { title.text = model.titl e description.text = model.descriptio n orderButton.text = model.orde r โ€ฆ cardView.render(model.cardViewModel) } }
  51. class CardPreviewViewTest { @get:Rul e val paparazzi = Paparazzi (

    theme = "Theme.Cash.Default.Accent" , deviceConfig = DeviceConfig.PIXEL_ 5 ) @Tes t fun cardPreview() { val view = paparazzi.inflate(R.layout.card_preview_view ) view.setModel(CardPreviewViewModel(โ€ฆ) ) paparazzi.snapshot(view ) }
  52. None
  53. None
  54. JUnit

  55. JUnit Paparazzi

  56. JUnit Paparazzi layoutlib

  57. JUnit Paparazzi layoutlib Android Framework

  58. JUnit Paparazzi layoutlib Android Framework *_Delegates

  59. JUnit Paparazzi layoutlib Android Framework *_Delegates

  60. None
  61. None
  62. /* * * Entry point of the Layout Library .

    * / public abstract class Bridge { public boolean init ( Map<String, String> platformProperties , File fontLocation , String nativeLibDirPath , String icuDataPath , Map<String, Map<String, Integer>> enumValueMap , ILayoutLog log) { โ€ฆ } public RenderSession createSession(SessionParams params) { โ€ฆ } }
  63. public class RenderSessionImpl { publicaResultainit(longatimeout)a{โ€ฆ}a

  64. publicaResultainit(longatimeout)a { Result result = super.init(timeout) ; if (!result.isSuccess()) {

    return result ; } else { SessionParams params = (SessionParams)this.getParams() ; BridgeContext context = this.getContext() ; this.mIsAlphaChannelImage = โ€ฆ this.mLayoutBuilder = new Builder(params, context) ; this.mInflater = new BridgeInflater(context, params.getLayoutlibCallback()) ; context.setBridgeInflater(this.mInflater) ; ILayoutPullParser layoutParser = params.getLayoutDescription() ; this.mBlockParser = new BridgeXmlBlockParser ( layoutParser, context, layoutParser.getLayoutNamespace( ) ) ; return Status.SUCCESS.createResult() ; } }a
  65. public Result init(long timeout) { Result result = super.init(timeout) ;

    if (!result.isSuccess()) { return result ; } else { SessionParams params = (SessionParams)this.getParams() ; BridgeContext context = this.getContext() ; this.mIsAlphaChannelImage = โ€ฆ this.mLayoutBuilder = new Builder(params, context) ; this.mInflater = new BridgeInflater(context, params.getLayoutlibCallback()) ; context.setBridgeInflater(this.mInflater) ; ILayoutPullParser layoutParser = params.getLayoutDescription() ; this.mBlockParser = new BridgeXmlBlockParser ( layoutParser, context, layoutParser.getLayoutNamespace( ) ) ; return Status.SUCCESS.createResult() ; } }
  66. public Result init(long timeout) { Result result = super.init(timeout) ;

    if (!result.isSuccess()) { return result ; } else { SessionParams params = (SessionParams)this.getParams() ; BridgeContext context = this.getContext() ; this.mIsAlphaChannelImage = โ€ฆ this.mLayoutBuilder = new Builder(params, context) ; this.mInflater = new BridgeInflater(context, params.getLayoutlibCallback()) ; context.setBridgeInflater(this.mInflater) ; ILayoutPullParser layoutParser = params.getLayoutDescription() ; this.mBlockParser = new BridgeXmlBlockParser ( layoutParser, context, layoutParser.getLayoutNamespace( ) ) ; return Status.SUCCESS.createResult() ; } }
  67. public Result init(long timeout) { Result result = super.init(timeout) ;

    if (!result.isSuccess()) { return result ; } else { SessionParams params = (SessionParams)this.getParams() ; BridgeContext context = this.getContext() ; this.mIsAlphaChannelImage = โ€ฆ this.mLayoutBuilder = new Builder(params, context) ; this.mInflater = new BridgeInflater(context, params.getLayoutlibCallback()) ; context.setBridgeInflater(this.mInflater) ; ILayoutPullParser layoutParser = params.getLayoutDescription() ; this.mBlockParser = new BridgeXmlBlockParser ( layoutParser, context, layoutParser.getLayoutNamespace( ) ) ; return Status.SUCCESS.createResult() ; } }
  68. $ unzip cash-master-internal-debug-11892.apk Archive: cash-master-internal-debug-11892.apk inflating: AndroidManifest.xml inflating: DebugProbesKt.bin inflating:

    LICENSE_OFL inflating: LICENSE_UNICODE inflating: META-INF/services/kotlin.reflect.jvm.internal.impl.builtins.BuiltInsLoader inflating: META-INF/services/kotlin.reflect.jvm.internal.impl.resolve.ExternalOverridabilityCondition inflating: META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler inflating: META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory extracting: assets/appmessage/activity-card-promo-fallback.png extracting: assets/appmessage/activity-card-promo-placeholder.png extracting: assets/appmessage/activity-card-promo.mp4 extracting: assets/appmessage/activity-fullscreen-ad.png extracting: assets/appmessage/bitcoin-activity-fallback.png extracting: assets/appmessage/bitcoin-activity-placeholder.png extracting: assets/appmessage/bitcoin-activity.mp4 extracting: assets/appmessage/bitcoin-full-screen.mp4 extracting: assets/appmessage/cage-banner.jpeg extracting: assets/appmessage/cage-ceo.jpg extracting: assets/appmessage/cage-face-off.jpg extracting: assets/appmessage/cage-feature1.jpg extracting: assets/appmessage/cage-feature2.jpg extracting: assets/appmessage/cage-feature3.jpg extracting: assets/appmessage/cage-handsome.jpg
  69. $ cd res/layout && ls -1 abc_action_bar_title_item.xml abc_action_bar_up_container.xml abc_action_menu_item_layout.xml abc_action_menu_layout.xml

    abc_action_mode_bar.xml abc_action_mode_close_item_material.xml abc_activity_chooser_view.xml abc_activity_chooser_view_list_item.xml abc_alert_dialog_button_bar_material.xml abc_alert_dialog_material.xml abc_alert_dialog_title_material.xml abc_cascading_menu_item_layout.xml abc_dialog_title_material.xml abc_expanded_menu_layout.xml abc_list_menu_item_checkbox.xml abc_list_menu_item_icon.xml abc_list_menu_item_layout.xml abc_list_menu_item_radio.xml abc_popup_menu_header_item_layout.xml abc_popup_menu_item_layout.xml abc_screen_content_include.xml abc_screen_simple.xml abc_screen_simple_overlay_action_mode.xml abc_screen_toolbar.xml
  70. $ less blockers_selection_detail_row.xml ^C^@^H^@ ^D^@^@^A^@^\^@t^A^@^@^P^@^@^@^@^@^@^@^@^A^@^@\^@^@^@^@^@^@^@^@^@^@^@^Q^@^@^@^]^@^@^@'^@^@^@5^@^@^@:^@^@^@G^@^@^@W^@^ @^@f^@^@^@v^@^@^@<86>^@^@^@<91>^@^@^@<9B>^@^@^@<A3>^@^@^@ <E2>^@^@^@^O^A^@^@^N^NtextAppearance^@ textColor^@^G^Ggravity^@^K^Korientation^@^B^Bid^@ paddingTop^@^MpaddingBottom^@^L^Llayout_width^@^Mlayout_height^@^Mlayout_weight^@^H^HTextView^@^G^Gandroid^@^E^Eclas s^@<<com.squareup.cash.blockers.views.SelectionView$DetailRowView^@**http://schemas.android.com/apk/res/

    android^@^D^Dview^@^@^@<80>^A^H^@0^@^@^@4^@^A^A<98>^@^A^A<AF>^@^A^A<C4>^@^A^A<D0>^@^A^A<D7>^@^A^A<D9>^@^A^A<F4>^@^A^ A<F5>^@^A^A<81>^A^A^A^@^A^P^@^X ^@^@^@^B^@^@^@<FF><FF><FF><FF>^K^@^@^@^N^@^@^@^B^A^P^@<9C>^@^@^@^B^@^@^@<FF><FF><FF><FF><FF><FF><FF><FF>^O^@^@^@^T^@ ^T^@^F^@^@^@^F^@^@^@^N^@^@^@^C^@^@^@<FF><FF><FF><FF>^H^@^@^P^@^@^@^@^N^@^@^@^E^@^@^@<FF><FF><FF><FF>^H^@^@^E^A^D^@^@ ^N^@^@^@^F^@^@^@<FF><FF><FF><FF>^H^@^@^E^A^D^@^@^N^@^@^@^G^@^@^@<FF><FF><FF><FF>^H^@^@^P<FF><FF><FF><FF>^N^@^@^@^H^@ ^@^@<FF><FF><FF><FF>^H^@^@^P<FF><FF><FF><FF><FF><FF><FF><FF>^L^@^@^@^M^@^@^@^H^@^@^C^M^@^@^@^B^A^P^@<B0>^@^@^@^K^@^@ ^@<FF><FF><FF><FF><FF><FF><FF><FF> ^@^@^@^T^@^T^@^G^@^D^@^@^@^@^@^N^@^@^@^@^@^@^@<FF><FF><FF><FF>^H^@^@^A<81>^A^S^? ^N^@^@^@^A^@^@^@<FF><FF><FF><FF>^H^@^@^A<CE>^B^F^? ^N^@^@^@^B^@^@^@<FF><FF><FF><FF>^H^@^@^Q^C^@<80>^@^N^@^@^@^D^@^@^@<FF><FF><FF><FF>^H^@^@^A<CE>^B ^?^N^@^@^@^G^@^@^@<FF><FF><FF><FF>^H^@^@^E^A^@^@^@^N^@^@^@^H^@^@^@<FF><FF><FF><FF>^H^@^@^P<FE><FF><FF><FF>^N^@^@^@ ^@^@^@<FF><FF><FF><FF>^H^@^@^D^@^@<80>?^C^A^P^@^X^@^@^@^K^@^@^@ <FF><FF><FF><FF><FF><FF><FF><FF> ^@^@^@^B^A^P^@<B0>^@^@^@^U^@^@^@<FF><FF><FF><FF><FF><FF><FF><FF> ^@^@^@^T^@^T^@^G^@^D^@^@^@^@^@^N^@^@^@^@^@^@^@<FF><FF><FF><FF>^H^@^@^A<81>^A^S^? ^N^@^@^@^A^@^@^@<FF><FF><FF><FF>^H^@^@^A<CE>^B^F^? ^N^@^@^@^B^@^@^@<FF><FF><FF><FF>^H^@^@^Q^E^@<80>^@^N^@^@^@^D^@^@^@<FF><FF><FF><FF>^H^@^@^A<FC>^D
  71. $ less blockers_selection_detail_row.xml ^C^@^H^@ ^D^@^@^A^@^\^@t^A^@^@^P^@^@^@^@^@^@^@^@^A^@^@\^@^@^@^@^@^@^@^@^@^@^@^Q^@^@^@^]^@^@^@'^@^@^@5^@^@^@:^@^@^@G^@^@^@W^@^ @^@f^@^@^@v^@^@^@<86>^@^@^@<91>^@^@^@<9B>^@^@^@<A3>^@^@^@ <E2>^@^@^@^O^A^@^@^N^NtextAppearance^@ textColor^@^G^Ggravity^@^K^Korientation^@^B^Bid^@ paddingTop^@^MpaddingBottom^@^L^Llayout_width^@^Mlayout_height^@^Mlayout_weight^@^H^HTextView^@^G^Gandroid^@^E^Eclas s^@<<com.squareup.cash.blockers.views.SelectionView$DetailRowView^@**http://schemas.android.com/apk/res/

    android^@^D^Dview^@^@^@<80>^A^H^@0^@^@^@4^@^A^A<98>^@^A^A<AF>^@^A^A<C4>^@^A^A<D0>^@^A^A<D7>^@^A^A<D9>^@^A^A<F4>^@^A^ A<F5>^@^A^A<81>^A^A^A^@^A^P^@^X ^@^@^@^B^@^@^@<FF><FF><FF><FF>^K^@^@^@^N^@^@^@^B^A^P^@<9C>^@^@^@^B^@^@^@<FF><FF><FF><FF><FF><FF><FF><FF>^O^@^@^@^T^@ ^T^@^F^@^@^@^F^@^@^@^N^@^@^@^C^@^@^@<FF><FF><FF><FF>^H^@^@^P^@^@^@^@^N^@^@^@^E^@^@^@<FF><FF><FF><FF>^H^@^@^E^A^D^@^@ ^N^@^@^@^F^@^@^@<FF><FF><FF><FF>^H^@^@^E^A^D^@^@^N^@^@^@^G^@^@^@<FF><FF><FF><FF>^H^@^@^P<FF><FF><FF><FF>^N^@^@^@^H^@ ^@^@<FF><FF><FF><FF>^H^@^@^P<FF><FF><FF><FF><FF><FF><FF><FF>^L^@^@^@^M^@^@^@^H^@^@^C^M^@^@^@^B^A^P^@<B0>^@^@^@^K^@^@ ^@<FF><FF><FF><FF><FF><FF><FF><FF> ^@^@^@^T^@^T^@^G^@^D^@^@^@^@^@^N^@^@^@^@^@^@^@<FF><FF><FF><FF>^H^@^@^A<81>^A^S^? ^N^@^@^@^A^@^@^@<FF><FF><FF><FF>^H^@^@^A<CE>^B^F^? ^N^@^@^@^B^@^@^@<FF><FF><FF><FF>^H^@^@^Q^C^@<80>^@^N^@^@^@^D^@^@^@<FF><FF><FF><FF>^H^@^@^A<CE>^B ^?^N^@^@^@^G^@^@^@<FF><FF><FF><FF>^H^@^@^E^A^@^@^@^N^@^@^@^H^@^@^@<FF><FF><FF><FF>^H^@^@^P<FE><FF><FF><FF>^N^@^@^@ ^@^@^@<FF><FF><FF><FF>^H^@^@^D^@^@<80>?^C^A^P^@^X^@^@^@^K^@^@^@ <FF><FF><FF><FF><FF><FF><FF><FF> ^@^@^@^B^A^P^@<B0>^@^@^@^U^@^@^@<FF><FF><FF><FF><FF><FF><FF><FF> ^@^@^@^T^@^T^@^G^@^D^@^@^@^@^@^N^@^@^@^@^@^@^@<FF><FF><FF><FF>^H^@^@^A<81>^A^S^? ^N^@^@^@^A^@^@^@<FF><FF><FF><FF>^H^@^@^A<CE>^B^F^? ^N^@^@^@^B^@^@^@<FF><FF><FF><FF>^H^@^@^Q^E^@<80>^@^N^@^@^@^D^@^@^@<FF><FF><FF><FF>^H^@^@^A<FC>^D
  72. None
  73. JUnit Paparazzi layoutlib Android Framework *_Delegates

  74. JUnit Paparazzi layoutlib Android Framework *_Delegates

  75. None
  76. None
  77. Drawable getDrawable(@DrawableRes int id, Theme theme) { return getDrawableForDensity(id, 0,

    theme); } android.content.res.Resources (android-29)
  78. Drawable getDrawable_Original(@DrawableRes int id, Theme theme) { return this.getDrawableForDensity(id, 0,

    theme) ; } @LayoutlibDelegat e Drawable getDrawable(@DrawableRes int id, Theme theme) { return Resources_Delegate.getDrawable(this, id, theme) ; } android.content.res.Resources (layoutlib)
  79. @LayoutlibDelegat e static Drawable getDrawable(Resources resources, int id, Theme theme)

    { Pair<String, ResourceValue> value = getResourceValue(resources, id) ; โ€ฆ return ResourceHelper.getDrawable ( value.getSecond(), getContext(resources), them e ); } android.content.res.Resources_Delegate (layoutlib)
  80. $ find ~/.gradle/caches -name layoutlib.jar | xargs jar tf |

    grep _Delegate android/animation/PropertyValuesHolder_Delegate.class android/app/Fragment_Delegate.class android/content/res/AssetManager_Delegate.class android/content/res/Resources_Delegate.class android/content/res/Resources_Theme_Delegate.class android/content/res/TypedArray_Delegate.class android/graphics/BaseCanvas_Delegate$1.class android/graphics/BaseCanvas_Delegate.class android/graphics/BitmapFactory_Delegate.class android/graphics/BitmapShader_Delegate$1.class android/graphics/BitmapShader_Delegate$BitmapShaderPaint$BitmapShaderContext.class android/graphics/BitmapShader_Delegate$BitmapShaderPaint.class android/graphics/BitmapShader_Delegate.class android/graphics/Bitmap_Delegate$BitmapCreateFlags.class android/graphics/Bitmap_Delegate.class android/graphics/BlendModeColorFilter_Delegate.class android/graphics/BlurMaskFilter_Delegate.class android/graphics/Canvas_Delegate.class android/graphics/ColorFilter_Delegate.class android/graphics/ColorMatrixColorFilter_Delegate.class android/graphics/ColorSpace_Rgb_Delegate.class android/graphics/Color_Delegate.class
  81. JUnit Paparazzi layoutlib Android Framework *_Delegates

  82. JUnit Paparazzi layoutlib Android Framework Android runtime *_Delegates

  83. None
  84. None
  85. JUnit Paparazzi layoutlib Android Framework Android runtime *_Delegates

  86. JUnit Paparazzi layoutlib Android Framework Android runtime *_Delegates

  87. class Paparazzi ( private val environment: Environment , private val

    deviceConfig: DeviceConfig , private val theme: String , private val appCompatEnabled: Boolean , private val maxPercentDifference: Double , private val snapshotHandler: SnapshotHandler , private val renderExtensions: Set<RenderExtension > ) : TestRule {
  88. class Paparazzi ( private val environment: Environment(= detectEnvironment() , private

    val deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5 , private val theme: String = "android:Theme.Material.NoActionBar.Fullscreen" , private val appCompatEnabled: Boolean = true , private val maxPercentDifference: Double = 0.1 , private val snapshotHandler: SnapshotHandler = determineHandler(maxPercentDifference) , private val renderExtensions: Set<RenderExtension> = setOf( ) ) : TestRule {โ€ฆ}
  89. class Paparazzi ( private val environment: Environment(= detectEnvironment() , private

    val deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5 , private val theme: String = "android:Theme.Material.NoActionBar.Fullscreen" , private val appCompatEnabled: Boolean = true , private val maxPercentDifference: Double = 0.1 , private val snapshotHandler: SnapshotHandler = determineHandler(maxPercentDifference) , private val renderExtensions: Set<RenderExtension> = setOf( ) ) : TestRule {โ€ฆ}
  90. private fun determineHandler ( maxPercentDifference: Doubl e ): SnapshotHandler =

    if (isVerifying) { SnapshotVerifier(maxPercentDifference ) } else { HtmlReportWriter( ) }
  91. class Paparazzi ( private val environment: Environment(= detectEnvironment() , private

    val deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5 , private val theme: String = "android:Theme.Material.NoActionBar.Fullscreen" , private val appCompatEnabled: Boolean = true , private val maxPercentDifference: Double = 0.1 , private val snapshotHandler: SnapshotHandler = determineHandler(maxPercentDifference) , private val renderExtensions: Set<RenderExtension> = setOf( ) ) : TestRule {โ€ฆ}
  92. class Paparazzi ( private val environment: Environment(= detectEnvironment() , private

    val deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5 , private val theme: String = "android:Theme.Material.NoActionBar.Fullscreen" , private val appCompatEnabled: Boolean = true , private val maxPercentDifference: Double = 0.1 , private val snapshotHandler: SnapshotHandler = determineHandler(maxPercentDifference) , private val renderExtensions: Set<RenderExtension> = setOf( ) ) : TestRule {โ€ฆ}
  93. data class Environment ( val platformDir: String , val appTestDir:

    String , val resDir: String , val assetsDir: String , val packageName: String , val compileSdkVersion: Int , val platformDataDir: String , )
  94. fun detectEnvironment(): Environment { val resourcesFile = File(getProperty("paparazzi.test.resources") ) val

    configLines = resourcesFile.readLines( ) val appTestDir = Paths.get(System.getProperty("user.dir") ) val androidHome = Paths.get(androidHome() ) return Environment ( platformDir = androidHome.resolve(configLines[3]).toString() , appTestDir = appTestDir.toString() , resDir = appTestDir.resolve(configLines[1]).toString() , โ€ฆ ) }
  95. fun detectEnvironment(): Environment { val resourcesFile = File(getProperty("paparazzi.test.resources") ) val

    configLines = resourcesFile.readLines( ) val appTestDir = Paths.get(System.getProperty("user.dir") ) val androidHome = Paths.get(androidHome() ) return Environment ( platformDir = androidHome.resolve(configLines[3]).toString() , appTestDir = appTestDir.toString() , resDir = appTestDir.resolve(configLines[1]).toString() , โ€ฆ ) }
  96. None
  97. preparePaparazzi{VARIANT}Resources

  98. Android Gradle Plugin Paparazzi Gradle Plugin merge{VARIANT}Resources preparePaparazzi{VARIANT}Resources compile{VARIANT}Kotlin

  99. Android Gradle Plugin Paparazzi Gradle Plugin merge{VARIANT}Resources preparePaparazzi{VARIANT}Resources compile{VARIANT}Kotlin merge{VARIANT}

    Assets compile{VARIANT}JavaWithJavac
  100. Android Gradle Plugin Paparazzi Gradle Plugin merge{VARIANT}Resources preparePaparazzi{VARIANT}Resources

  101. Android Gradle Plugin Paparazzi Gradle Plugin merge{VARIANT}Resources preparePaparazzi{VARIANT}Resources "build/intermediates/res/merged/debug"

  102. Android Gradle Plugin Paparazzi Gradle Plugin merge{VARIANT}Resources preparePaparazzi{VARIANT}Resources "build/intermediates/res/merged/debug" build/intermediates/paparazzi/resources.txt

  103. Android Gradle Plugin Paparazzi Gradle Plugin merge{VARIANT}Resources preparePaparazzi{VARIANT}Resources test{VARIANT}UnitTest โ€ฆ

  104. Android Gradle Plugin Paparazzi Gradle Plugin merge{VARIANT}Resources preparePaparazzi{VARIANT}Resources test{VARIANT}UnitTest systemProperties["paparazzi.test.resources"]

    = "build/intermediates/paparazzi/resources.txt" โ€ฆ
  105. Android Gradle Plugin Paparazzi Gradle Plugin preparePaparazzi{VARIANT}Resources test{VARIANT}UnitTest โ€ฆ systemProperties["paparazzi.test.record"]

    = "true" systemProperties["paparazzi.test.verify"] = "true"
  106. /PROJECT/build.gradle dependencies { testImplementation "app.cash.paparazzi:paparazzi:${VERSION} " }

  107. /build.gradle buildscript { dependencies { classpath "app.cash.paparazzi:paparazzi-gradle-plugin:${VERSION} " } }

    /PROJECT/build.gradle apply plugin: 'app.cash.paparazzi'
  108. $ tree prebuilts/studio/layoutlib/data/ prebuilts/studio/layoutlib/data/ โ”œโ”€โ”€ layoutlib_native.jar โ”œโ”€โ”€ linux โ”‚ โ””โ”€โ”€

    lib64 โ”‚ โ”œโ”€โ”€ libandroid_runtime.so โ”‚ โ”œโ”€โ”€ libbinder.so โ”‚ โ””โ”€โ”€ libhidlbase.so โ”œโ”€โ”€ mac โ”‚ โ””โ”€โ”€ lib64 โ”‚ โ””โ”€โ”€ libandroid_runtime.dylib โ”œโ”€โ”€ win โ”‚ โ””โ”€โ”€ lib64 โ”‚ โ”œโ”€โ”€ libandroid_runtime.dll โ”‚ โ”œโ”€โ”€ libicuuc-host.dll โ”‚ โ””โ”€โ”€ libicuuc_stubdata.dll โ€ฆ
  109. JUnit Paparazzi layoutlib Android Framework Android runtime *_Delegates

  110. $ tree prebuilts/studio/layoutlib/data/ prebuilts/studio/layoutlib/data/ โ”œโ”€โ”€ layoutlib_native.jar โ”œโ”€โ”€ linux โ”‚ โ””โ”€โ”€

    lib64 โ”‚ โ”œโ”€โ”€ libandroid_runtime.so โ”‚ โ”œโ”€โ”€ libbinder.so โ”‚ โ””โ”€โ”€ libhidlbase.so โ”œโ”€โ”€ mac โ”‚ โ””โ”€โ”€ lib64 โ”‚ โ””โ”€โ”€ libandroid_runtime.dylib โ”œโ”€โ”€ win โ”‚ โ””โ”€โ”€ lib64 โ”‚ โ”œโ”€โ”€ libandroid_runtime.dll โ”‚ โ”œโ”€โ”€ libicuuc-host.dll โ”‚ โ””โ”€โ”€ libicuuc_stubdata.dll โ€ฆ
  111. $ tree prebuilts/studio/layoutlib/data/ prebuilts/studio/layoutlib/data/ โ”œโ”€โ”€ layoutlib_native.jar โ”œโ”€โ”€ linux โ”‚ โ””โ”€โ”€

    lib64 โ”‚ โ”œโ”€โ”€ libandroid_runtime.so โ”‚ โ”œโ”€โ”€ libbinder.so โ”‚ โ””โ”€โ”€ libhidlbase.so โ”œโ”€โ”€ mac โ”‚ โ””โ”€โ”€ lib64 โ”‚ โ””โ”€โ”€ libandroid_runtime.dylib โ”œโ”€โ”€ win โ”‚ โ””โ”€โ”€ lib64 โ”‚ โ”œโ”€โ”€ libandroid_runtime.dll โ”‚ โ”œโ”€โ”€ libicuuc-host.dll โ”‚ โ””โ”€โ”€ libicuuc_stubdata.dll โ€ฆ layoutlib-native-jdk11.jar layoutlib-native-linux.jar layoutlib-native-mac.jar layoutlib-native-win.jar
  112. $ tree ~/.gradle/caches/modules-2/files-2.1/app.cash.paparazzi/ ~/.gradle/caches/modules-2/files-2.1/app.cash.paparazzi/ โ”œโ”€โ”€ layoutlib-native-jdk11 โ”‚ โ””โ”€โ”€ 4.1.0-ae77d65 โ”‚

    โ””โ”€โ”€ 7f954f19f39672bd8802ef030d47bb269ab2ffd2 โ”‚ โ””โ”€โ”€ layoutlib-native-jdk11-4.1.0-ae77d65.jar โ”œโ”€โ”€ layoutlib-native-macosx โ”‚ โ”œโ”€โ”€ 4.1.0-ae77d65 โ”‚ โ”‚ โ””โ”€โ”€ c893f66979493e8f4249de4e825fca5975e906e8 โ”‚ โ”‚ โ””โ”€โ”€ layoutlib-native-macosx-4.1.0-ae77d65.jar โ”œโ”€โ”€ paparazzi โ”‚ โ”œโ”€โ”€ 0.8.0 โ”‚ โ”‚ โ”œโ”€โ”€ d6b8ec9f82ad120e34d7e07d3be3c909f5dd0a4 โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ paparazzi-0.8.0.jar โ”œโ”€โ”€ paparazzi-agent โ”‚ โ”œโ”€โ”€ 0.8.0 โ”‚ โ”‚ โ”œโ”€โ”€ ee13c27af0028005e42e221d576e76fdac6a6ba5 โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ paparazzi-agent-0.8.0.jar โ””โ”€โ”€ paparazzi-gradle-plugin โ”œโ”€โ”€ 0.8.0 โ”‚ โ”œโ”€โ”€ 6f4e990290c5f1c1e190f3c38ab9059785e1432d โ”‚ โ”‚ โ””โ”€โ”€ paparazzi-gradle-plugin-0.8.0.jar
  113. /* * * Entry point of the Layout Library .

    * / public abstract class Bridge { public boolean init ( Map<String, String> platformProperties , File fontLocation , String nativeLibDirPath , String icuDataPath , Map<String, Map<String, Integer>> enumValueMap , ILayoutLog log) { โ€ฆ } public RenderSession createSession(SessionParams params) { โ€ฆ } }
  114. /* * * Entry point of the Layout Library .

    * / public abstract class Bridge { public boolean init ( Map<String, String> platformProperties , File fontLocation , String nativeLibDirPath , String icuDataPath , Map<String, Map<String, Integer>> enumValueMap , ILayoutLog log) { โ€ฆ } public RenderSession createSession(SessionParams params) { โ€ฆ } }
  115. $ find ~/.gradle/caches/transforms-3/ -name layoutlib* ~/.gradle/caches/transforms-3/4a38ac62d09a6fd091f06e54d5f69fe1/transformed/ layoutlib-native-macosx-4.1.0-ae77d65

  116. $ tree ~/.gradle/caches/transforms-3/4a38ac62d09a6fd091f06e54d5f69fe1/ transformed/layoutlib-native-macosx-4.1.0-ae77d65 ~/.gradle/caches/transforms-3/4a38ac62d09a6fd091f06e54d5f69fe1/transformed/ layoutlib-native-macosx-4.1.0-ae77d65 โ””โ”€โ”€ data โ”œโ”€โ”€ fonts

    โ”‚ โ”œโ”€โ”€ AndroidClock.ttf โ”‚ โ”œโ”€โ”€ CarroisGothicSC-Regular.ttf โ”‚ โ”œโ”€โ”€ โ€ฆ โ”œโ”€โ”€ icu โ”‚ โ””โ”€โ”€ icudt63l.dat โ””โ”€โ”€ mac โ””โ”€โ”€ lib64 โ”œโ”€โ”€ libandroid_runtime.dylib โ”œโ”€โ”€ libc++.dylib โ””โ”€โ”€ liblog.dylib
  117. None
  118. ?

  119. ? Contributions welcome!

  120. None
  121. @RunWith(TestParameterInjector::class ) class DirectDepositSetupViewTest ( @TestParameter private val theme: DesignTheme

    , @TestParameter private val a11yTextSize: AccessibilityTextSize , ) { @get:Rule val paparazzi = Paparazzi(โ€ฆ ) @Tes t fun default() { val context = paparazzi.contex t .wrapWithTheme(theme.provide ) .scaledFontSizeBy(accessibilityTextSize.scale ) val view = DirectDepositSetupView(context ) view.setModel(โ€ฆ ) paparazzi.scrollingSnapshot(view ) } }
  122. @RunWith(TestParameterInjector::class ) class DirectDepositSetupViewTest ( @TestParameter private val theme: DesignTheme

    , @TestParameter private val a11yTextSize: AccessibilityTextSize , ) { @get:Rule val paparazzi = Paparazzi(โ€ฆ ) @Tes t fun default() { val context = paparazzi.contex t .wrapWithTheme(theme.provide ) .scaledFontSizeBy(accessibilityTextSize.scale ) val view = DirectDepositSetupView(context ) view.setModel(โ€ฆ ) paparazzi.scrollingSnapshot(view ) } } enum class DesignTheme(val provide: Context.() -> ThemeInfo) { Light({ moonCakeLight() }) , Dark({ moonCakeDark() } ) } enum class AccessibilityTextSize(val scale: Float) { NORMAL(scale = 1f) , LARGE(scale = 2f) , }
  123. @RunWith(TestParameterInjector::class ) class DirectDepositSetupViewTest ( @TestParameter private val theme: DesignTheme

    , @TestParameter private val a11yTextSize: AccessibilityTextSize , ) { @get:Rule val paparazzi = Paparazzi(โ€ฆ ) @Tes t fun default() { val context = paparazzi.contex t .wrapWithTheme(theme.provide ) .scaledFontSizeBy(accessibilityTextSize.scale ) val view = DirectDepositSetupView(context ) view.setModel(โ€ฆ ) paparazzi.scrollingSnapshot(view ) } }
  124. @RunWith(TestParameterInjector::class ) class DirectDepositSetupViewTest ( @TestParameter private val theme: DesignTheme

    , @TestParameter private val a11yTextSize: AccessibilityTextSize , ) { @get:Rule val paparazzi = Paparazzi(โ€ฆ ) @Tes t fun default() { val context = paparazzi.contex t .wrapWithTheme(theme.provide ) .scaledFontSizeBy(accessibilityTextSize.scale ) val view = DirectDepositSetupView(context ) view.setModel(โ€ฆ ) paparazzi.scrollingSnapshot(view ) } }
  125. fun Paparazzi.scrollingSnapshot ( view: View , scrollableView: View = vie

    w ) { var scrollCount = 0 snapshot(view, scrollCount.toString() ) while (scrollableView.canScrollVertically(1)) { scrollableView.scrollBy(0, scrollableView.height ) scrollCount+ + check(scrollCount < 10) { "This view has been scrolled 10 times! Bad input? " } snapshot(view, name = scrollCount.toString() ) } }
  126. $ ./gradlew banking:views:testDebug --tests=com.squareup.cash.banking.views.DirectDepositSetupViewTest

  127. class Paparazzi ( private val environment: Environment(= detectEnvironment() , private

    val deviceConfig: DeviceConfig , private val theme: String , private val appCompatEnabled: Boolean , private val maxPercentDifference: Double , private val snapshotHandler: SnapshotHandler , private val renderExtensions: Set<RenderExtension > ) : TestRule {โ€ฆ}
  128. class Paparazzi ( private val environment: Environment(= detectEnvironment() , private

    val deviceConfig: DeviceConfig , private val theme: String , private val appCompatEnabled: Boolean , private val maxPercentDifference: Double , private val snapshotHandler: SnapshotHandler , private val renderExtensions: Set <โ€‹ RenderExtensio nโ€‹ > ) : TestRulea{โ€ฆ}a
  129. /* * * An extension for overlaying additional information on

    top of * each rendered frame . * / interface RenderExtensio nโ€‹ { fun render ( snapshot: Snapshot, view: View, image: BufferedImag e ): BufferedImag e }
  130. /* * * An extension for overlaying additional information on

    top of * each rendered frame . * / interface RenderExtension { fun render ( snapshot: Snapshot, view: View, image: BufferedImag e ): BufferedImag e } frameHandler.handle(scaleImage(bridgeRenderSession.image))
  131. /* * * An extension for overlaying additional information on

    top of * each rendered frame . * / interface RenderExtension { fun render ( snapshot: Snapshot, view: View, image: BufferedImag e ): BufferedImag e } var image = bridgeRenderSession.imag e renderExtensions.forEach { image = it.render(snapshot, view, image ) } frameHandler.handle(scaleImage(image))
  132. None
  133. None
  134. None
  135. class AccessibilityTest { @get:Rul e val paparazzi = Paparazzi()a @Tes

    t fun test() { val view = buildView(paparazzi.context ) paparazzi.snapshot(view ) } }
  136. class AccessibilityTest { @get:Rul e val paparazzi = Paparazzi (

    renderExtensions = setOf(AccessibilityRenderExtension() ) )a @Tes t fun test() { val view = buildView(paparazzi.context ) paparazzi.snapshot(view ) } }
  137. None
  138. None
  139. None
  140. fun registerMatrixMultiplyInterception() { val matrixClass = Class.forName("android.opengl.Matrix" ) InterceptorRegistrar.addMethodInterceptors (

    matrixClass , setOf ( "multiplyMM" to MatrixMatrixMultiplicationInterceptor::class.java , "multiplyMV" to MatrixVectorMultiplicationInterceptor::class.jav a ) ) }
  141. fun registerMatrixMultiplyInterception() { val matrixClass = Class.forName("android.opengl.Matrix" ) InterceptorRegistrar.addMethodInterceptors (

    matrixClass , setOf ( "multiplyMM" to MatrixMatrixMultiplicationInterceptor::class.java , "multiplyMV" to MatrixVectorMultiplicationInterceptor::class.jav a ) ) }
  142. fun registerMatrixMultiplyInterception() { val matrixClass = Class.forName("android.opengl.Matrix" ) InterceptorRegistrar.addMethodInterceptors (

    matrixClass , setOf ( "multiplyMM" to MatrixMatrixMultiplicationIntercepto rโ€‹ ::class.java , "multiplyMV" to MatrixVectorMultiplicationInterceptor::class.jav a ) ) }
  143. object MatrixMatrixMultiplicationIntercepto rโ€‹ { fun intercept ( result: FloatArray, resultOffset:

    Int , lhs: FloatArray, lhsOffset: Int , rhs: FloatArray, rhsOffset: In t ) { for (i in 0..3) { val rhs_i0 = rhs[I(i, 0, rhsOffset) ] var ri0 = lhs[I(0, 0, lhsOffset)] * rhs_i 0 โ€ฆ for (j in 1..3) { val rhs_ij = rhs[I(i, j, rhsOffset) ] ri0 += lhs[I(j, 0, lhsOffset)] * rhs_i j โ€ฆ } result[I(i, 0, resultOffset)] = ri 0 โ€ฆ } } }
  144. /* * * See : * https://github.com/cashapp/paparazzi/issues/11 9 * https://issuetracker.google.com/issues/15606547

    2 * / private fun registerFontLookupInterceptionIfResourceCompatDetected() { val clz = Class.forName("androidx.core.content.res.ResourcesCompat" ) InterceptorRegistrar.addMethodInterceptor ( clz, "getFont", ResourcesInterceptor::class.jav a ) }
  145. /* * * See : * https://github.com/cashapp/paparazzi/issues/11 9 * https://issuetracker.google.com/issues/15606547

    2 * / private fun registerFontLookupInterceptionIfResourceCompatDetected() { val clz = Class.forName("androidx.core.content.res.ResourcesCompat" ) InterceptorRegistrar.addMethodInterceptor ( clz, "getFont", ResourcesInterceptor::class.jav a ) } private fun registerViewEditModeInterception() { val clz = Class.forName("android.view.View" ) InterceptorRegistrar.addMethodInterceptor ( clz, "isInEditMode", EditModeInterceptor::class.jav a ) }
  146. Coming soon!

  147. None
  148. None
  149. Keeping your Pixels Perfect Paparazzi 1.0 @jrodbx