Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Composable Preview Driven Development with AI: ...

Composable Preview Driven Development with AI: TDD-fying your UI pAInlessly!

So you're already familiar with Compose UI… but have you considered using AI alongside Test-Driven Development (TDD) and Composable Previews to supercharge how you build and refactor UI with confidence?

Allow me to introduce you to “Composable Preview Driven Development”, a modern workflow where Previews, screenshot tests, and AI work hand in hand!

In this tech talk, you will learn…

- How planning your @Previews before AI generation leads to higher code quality

- How to effortlessly turn @Previews into automated tests to reliably refactor UI

- How AI and TDD work together to generate, refine, and evolve UI confidently

- How TDD acts as a reliable guardrail for AI-generated UI, keeping it safe to refactor and evolve

Make sure not to miss this opportunity to explore the powerful combination of AI, Test-Driven Development, and Composable Previews!

No previous TDD knowledge is required!

Avatar for Sergio Sastre Flórez

Sergio Sastre Flórez

October 22, 2026

More Decks by Sergio Sastre Flórez

Other Decks in Programming

Transcript

  1. TDD basics R ED R E F A C TE

    1 ST G R E E N TDD T O RI NG TE ST Write a failing test 2 Write minimal code to make the test pass 3 Refactor code Verify code changes by running tests @Gio_Sastre
  2. CPDD basics 1 2 Write @Preview Write code to make

    the UI look as expected Record UI screenshot 3 P R E F A C CPDD T Refactor code VI E E R O RI W R E C O R D NG Verify UI screenshots @Gio_Sastre
  3. TDD & CPDD R ED R E F A C

    TE P ST G R E E N TDD T O RI NG T T S E R E F A C VI E E R CPDD T O RI W R E C O R D NG @Gio_Sastre
  4. AI mechanisms In CPDD Checkpoints Trusted state that the agent

    can return to or use as a reference Git Commit <code> + Trusted = All checks pass! Guardrails Verification mechanism that prevents the agent from silently moving outside acceptable behaviour @Preview = test Deterministic The faster, the better @Gio_Sastre
  5. Target.png Composables.kt Set the Text inside the bubble to 16

    sp. Place the bubble on top of the tennis bot image (i.e., overlay it). Vertically align the bubble so that its top edge starts at 2/3 of the height of the tennis bot image (measured from the top of the bot)
  6. Target.png Composables.kt Undo the last change. Set the margin between

    the bubble and the list of subscriptions underneath to 16 dp
  7. Target.png Composables.kt Undo the last change. Set the margin between

    the bubble and the list of subscriptions underneath to 16 dp
  8. Target.png Composables.kt @Composable fun SubscriptionScreen ( subscriptions : ImmutableList<SubscriptionItem> ,

    … ){ HeaderAndBubble ( … ) SubscriptionsLazyColumn ( items = subscriptions ) { … } BuySubscriptionButton ( … ) TermsApplyButton ( … ) } Modify SubscriptionScreen Composable to accept Loading state. In Loading State, replace the Subscriptions list under the bubble with 3 Shimmer Rows and disable the Buy Subscription button
  9. Target.png Composables.kt @Composable fun SubscriptionScreen ( isLoading : Boolean =

    true , subscriptions : ImmutableList<SubscriptionItem> = emptyList( ) , … ){ HeaderAndBubble ( … ) if ( isLoading ) { ShimmerRows ( … ) } else { SubscriptionsLazyColumn ( items = subscriptions ) { … } } BuySubscriptionButton ( enabled = ! isLoading ) TermsApplyButton ( … ) }
  10. Target.png Composables.kt @Composable fun SubscriptionScreen ( isLoading : Boolean ,

    subscriptions : ImmutableList<SubscriptionItem> , … ){ HeaderAndBubble ( … ) if ( isLoading ) { ShimmerRows ( … ) } else { SubscriptionsLazyColumn ( items = subscriptions ) { … } } BuySubscriptionButton ( enabled = ! isLoading ) TermsApplyButton ( … ) } Modify SubscriptionScreen Composable to accept Error state. In Error State, do not show ShimmerRows or the Subscriptions list under the bubble and replace the Buy Subscription button with a Retry button to load subscriptions
  11. Target.png Composables.kt @Composable fun SubscriptionScreen ( isError : Boolean =

    true , isLoading : Boolean = false , subscriptions : ImmutableList<SubscriptionItem> = emptyList( ) , … ){ HeaderAndBubble ( … ) if ( isLoading ) { ShimmerRows ( … ) } else if ( ! isError ) { SubscriptionsLazyColumn ( items = subscriptions ) { … } } if ( isError ) { RetryLoadSubscriptionsButton ( … ) } else BuySubscriptionButton ( enabled = ! isLoading ) } TermsApplyButton ( … ) }
  12. Reactive Prompting ? Late Feedback Discover late that previously implemented

    UI states break while iteratively prompting for new ones Complexity Increase Unnecessary complexity increases with each iteration, hurting the codebase and future agent performance Ambiguous Prompting The Agent must guess which UI element to change, causing unintended edits and slower iterations @Gio_Sastre
  13. Reactive Prompting ? vs. CPDD Prompting Late Feedback Fast Feedback

    Complexity Increase Complexity Management Ambiguous Prompting Unambiguous Prompting @Gio_Sastre
  14. Fast Feedback Test Impact Analysis Only run affected test(s) target

    test main.package roborazzi { generateComposePreviewTests { packages = listOf( “main.package” ) target } } package_1 i package_2 A Roborazzi-like plugin for Paparazzi is available in Composable Preview Scanner, under paparazzi-plugin @Gio_Sastre
  15. Fast Feedback Test Impact Analysis Only run affected test(s) target

    test main.package roborazzi { generateComposePreviewTests { packages = listOf( “main.package.target” ) target } } package_1 i package_2 A Roborazzi-like plugin for Paparazzi is available in Composable Preview Scanner, under paparazzi-plugin @Gio_Sastre
  16. Fast Feedback Test Impact Analysis Only run affected test(s) main.package

    roborazzi { generateComposePreviewTests { packages = listOf( “main.package.target” ) target test main.package.target target @Preview @Composable fun TargetPreview ( ) { … } … } … } package_1 i A Roborazzi-like plugin for Paparazzi is available in Composable Preview Scanner, under paparazzi-plugin package_2 @Preview @Composable fun AnotherPreview ( ) { … } @Gio_Sastre
  17. Fast Feedback Test Impact Analysis roborazzi { generateComposePreviewTests { packages

    = listOf( “main.package.target” ) annotationFilter = Only run affected test(s) target test main.package main.package.target target @RoboPreviewInclude @Preview @Composable fun TargetPreview ( ) { … } … AnnotationFilter.Filter.RoboPreviewInclude } … } package_1 i A Roborazzi-like plugin for Paparazzi is available in Composable Preview Scanner, under paparazzi-plugin package_2 @Preview @Composable fun AnotherPreview ( ) { … } @Gio_Sastre
  18. Unambiguous Prompting Set-Of-Mark (Roborazzi only) annotated.png uitree.json { "n": 1,

    "testTag": “login_button", "bounds": [16, 24, 204, 72], ... } @Gio_Sastre
  19. Unambiguous Prompting Set-Of-Mark (Roborazzi only) annotated.png uitree.json { "n": 1,

    "testTag": “login_button", "bounds": [16, 24, 204, 72], ... } Terminal ./gradlew recordRoborazziDebug -Proborazzi.dumpUiTree=true @Gio_Sastre
  20. Target.png Composables.kt gradle.kts roborazzi { generateComposePreviewTests { packages = listOf(

    “subscriptions.ui.screens” ) annotationFilter = AnnotationFilter.Filter.RoboPreviewInclude } }
  21. Target.png Composables.kt gradle.kts roborazzi { generateComposePreviewTests { packages = listOf(

    “subscriptions.ui.screens” ) annotationFilter = AnnotationFilter.Filter.RoboPreviewInclude } } Git Commit gradle.kts
  22. Target.png Composables.kt Preview Create an empty SubscriptionScreen Composable that accepts

    an Immutable List of Subscription Items and a click listener. Create its Preview with 3 subscriptions, 2 with discount, 1 selected. Annotate it with RobopreviewInclude.
  23. Target.png Composables.kt @Composable ( fun SubscriptionScreen Subscription subscriptions : ImmutableList<SubscriptionItem>

    … ){ } @RoboPreviewInclude @Preview @Composable fun Subscription ScreenPreview ( ) { val subscriptions = … AppTheme { SubscriptionScreen ( subscriptions ) } } Preview Create an empty SubscriptionScreen Composable that accepts an Immutable List of Subscription Items and a click listener. Create its Preview with 3 subscriptions, 2 with discount, 1 selected. Annotate it with RobopreviewInclude.
  24. Target.png Composables.kt 5 6 Record Set the padding between UI

    Components 5 and 6 to 16 dp Record screenshot tests with UI Tree Dump screen.annotated screen.uitree.json
  25. Target.png Composables.kt Record Set the padding between UI Components 5

    and 6 to 16 dp Record screenshot tests with UI Tree Dump screen.annotated screen.uitree.json
  26. Target.png Composables.kt @Composable fun Subscription Screen ( subscriptions : ImmutableList<SubscriptionItem>

    … ){ HeaderAndBubble ( … ) SubscriptionsLazyColumn ( items = subscriptions ) { … } BuySubscriptionButton ( … ) TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable fun Subscription ScreenPreview ( ) { val subscriptions = … AppTheme { Subscription Screen ( subscriptions ) } } Preview Copy paste the SubscriptionScreen and its Preview, but rename them to LoadingScreen
  27. Target.png Composables.kt @Composable Loading Screen ( fun Subscription subscriptions :

    ImmutableList<SubscriptionItem> … ){ HeaderAndBubble ( … ) SubscriptionsLazyColumn ( items = subscriptions ) { … } BuySubscriptionButton ( … ) TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable fun Subscription Loading ScreenPreview ( ) { val subscriptions = … AppTheme { Loading Screen ( subscriptions ) Subscription } } Record
  28. Target.png Composables.kt @Composable Loading Screen ( fun Subscription subscriptions :

    ImmutableList<SubscriptionItem> … ){ HeaderAndBubble ( … ) SubscriptionsLazyColumn ( items = subscriptions ) { … } BuySubscriptionButton ( … ) TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable fun Subscription Loading ScreenPreview ( ) { Record val subscriptions = … AppTheme { Loading Screen ( subscriptions ) Subscription } } In LoadingScreen (keep SubscriptionScreen as it is): - Replace SubscriptionsLazyColumn with ShimmerRows - Disable BuySubscriptionButton - Remove subscriptions argument (also in LoadingScreenPreview)
  29. Target.png Composables.kt @Composable Loading Screen ( ) { fun Subscription

    HeaderAndBubble ( … ) ShimmerRows ( … ) BuySubscriptionButton ( enabled = false ) TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable fun Subscription Loading ScreenPreview ( ) { AppTheme { Loading Screen ( subscriptions Subscription ) } }
  30. Target.png Composables.kt @Composable Loading Screen ( ) { fun Subscription

    HeaderAndBubble ( … ) ShimmerRows ( … ) BuySubscriptionButton ( enabled = false ) TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable fun Subscription Loading ScreenPreview ( ) { AppTheme { Loading Screen ( subscriptions Subscription ) } } Git Commit <code> +
  31. Target.png Composables.kt @Composable ErrorScreen ( … ) { fun Subscription

    HeaderAndBubble ( … ) RetryButtonWithHint ( … ) TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable Some prompts later… fun Subscription ErrorScreenPreview ( ) { AppTheme { Error Screen ( … ) } } Subscription Record
  32. Target.png Composables.kt @Composable ErrorScreen ( … ) { fun Subscription

    HeaderAndBubble ( … ) RetryButtonWithHint ( … ) TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { AppTheme { Error Screen ( … ) } } Subscription Record
  33. Target.png Composables.kt @Composable ErrorScreen ( … ) { fun Subscription

    HeaderAndBubble ( … ) RetryButtonWithHint ( … ) TermsApplyButton ( … ) Git Commit } <code> + @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { AppTheme { Error Screen ( … ) } } Subscription Record
  34. Target.png Composables.kt @Composable ErrorScreen ( … ) { fun Subscription

    HeaderAndBubble ( … ) RetryButtonWithHint ( … ) TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { AppTheme { Error Screen ( … ) } } Subscription Record
  35. Target.png Composables.kt Refactor Subscription Create a MainScreen Composable that takes

    a ScreenUiState as param and renders the corresponding Composable (Subscriptions, Loading or Error screen) based on it
  36. Target.png Composables.kt @Composable fun MainScreen ( uiState : UiState, …

    ) { when ( uiState ) { is Content -> SubscriptionsScreen ( uiState.subscriptions, … ) Loading -> LoadingScreen ( ) Error -> ErrorScreen ( … ) } } @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { Subscription AppTheme { ErrorScreen ( … ) } } Refactor Create a MainScreen Composable that takes a ScreenUiState as param and renders the corresponding Composable (Subscriptions, Loading or Error screen) based on it
  37. Target.png Composables.kt @Composable fun MainScreen ( uiState : UiState, …

    ) { when ( uiState ) { is Content -> SubscriptionsScreen ( uiState.subscriptions, … ) Loading -> LoadingScreen ( ) Error -> ErrorScreen ( … ) } } @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { Subscription AppTheme { ErrorScreen ( … ) } } Refactor Use MainScreen in the Subscription, Loading and Error Screen Previews instead of the Composable inside the AppTheme. Validate by verifying the screenshot tests are green
  38. Target.png Composables.kt verify @Composable fun MainScreen ( uiState : UiState,

    … ) { when ( uiState ) { is Content -> SubscriptionsScreen ( uiState.subscriptions, … ) Loading -> LoadingScreen ( ) Error -> ErrorScreen ( … ) } } @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { val uiState = UiState.Error Subscription AppTheme { MainScreen ( uiState, … ) } } Refactor Use MainScreen in the Subscription, Loading and Error Screen Previews instead of the Composable inside the AppTheme. Validate by verifying the screenshot tests are green
  39. Target.png Composables.kt @Composable fun MainScreen ( uiState : UiState, …

    ) { when ( uiState ) { is Content -> SubscriptionsScreen ( uiState.subscriptions, … ) Loading -> LoadingScreen ( ) Error -> ErrorScreen ( … ) } } @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { val uiState = UiState.Error Subscription AppTheme { MainScreen ( uiState, … ) } } Refactor Use MainScreen in the Subscription, Loading and Error Screen Previews instead of the Composable inside the AppTheme. Validate by verifying the screenshot tests are green
  40. Target.png Composables.kt @Composable fun MainScreen ( uiState : UiState, …

    ) { when ( uiState ) { is Content -> SubscriptionsScreen ( uiState.subscriptions, … ) Loading -> LoadingScreen ( ) Error -> ErrorScreen ( … ) } } <code> @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { val uiState = UiState.Error Subscription AppTheme { MainScreen ( uiState, … ) } } Git Commit Refactor
  41. Target.png Composables.kt @Composable fun MainScreen ( uiState : UiState, …

    ) { when ( uiState ) { is Content -> SubscriptionsScreen ( uiState.subscriptions, … ) Loading -> LoadingScreen ( ) Error -> ErrorScreen ( … ) } } @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { val uiState = UiState.Error Subscription AppTheme { MainScreen ( uiState ) } } Refactor The full screen is recomposing on uiState change. Some Composables are common to all 3 states. Change MainScreen only to avoid those recompositions. Then validate by verifying the screenshot tests are green
  42. Target.png Composables.kt @Composable fun MainScreen ( uiState : UiState, …

    ) { HeaderAndBubble ( … ) when ( uiState ) { is Content -> { SubscriptionsLazyColumn ( uiState.subscriptions ) { … } BuySubscriptionButton ( enabled = true ) } Loading -> { ShimmerRows ( … ) } BuySubscriptionButton ( enabled = false ) Error -> RetryButtonWithHint ( … ) } TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { val uiState = UiState.Error Subscription AppTheme { MainScreen ( uiState ) } } Refactor
  43. Target.png Composables.kt @Composable fun MainScreen ( uiState : UiState, …

    ) { HeaderAndBubble ( … ) when ( uiState ) { is Content -> { SubscriptionsLazyColumn ( uiState.subscriptions ) { … } BuySubscriptionButton ( enabled = true ) } Loading -> { ShimmerRows ( … ) } BuySubscriptionButton ( enabled = false ) Error -> RetryButtonWithHint ( … ) } Git Commit <code> TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { val uiState = UiState.Error Subscription AppTheme { MainScreen ( uiState ) } } Refactor
  44. Target.png Composables.kt @Composable fun MainScreen ( uiState : UiState, …

    ) { HeaderAndBubble ( … ) when ( uiState ) { is Content -> { SubscriptionsLazyColumn ( uiState.subscriptions ) { … } BuySubscriptionButton ( enabled = true ) } Loading -> { ShimmerRows ( … ) } BuySubscriptionButton ( enabled = false ) Error -> RetryButtonWithHint ( … ) } TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { val uiState = UiState.Error Subscription AppTheme { MainScreen ( uiState ) } } Refactor
  45. Target.png Composables.kt @Composable fun MainScreen ( uiState : UiState, …

    ) { HeaderAndBubble ( … ) when ( uiState ) { is Content -> { SubscriptionsLazyColumn ( uiState.subscriptions ) { … } BuySubscriptionButton ( enabled = true ) } Loading -> { ShimmerRows ( … ) } BuySubscriptionButton ( enabled = false ) Error -> RetryButtonWithHint ( … ) Git Commit revert gradle.kts } TermsApplyButton ( … ) } @RoboPreviewInclude @Preview @Composable fun Subscription ErrorScreenPreview ( ) { val uiState = UiState.Error Subscription AppTheme { MainScreen ( uiState ) } } Refactor
  46. Checkpoints & Guardrails Subscriptions Screen Loading Screen Error Screen Git

    Commit Git Commit Git Commit <code> + <code> + <code> + @Gio_Sastre
  47. Checkpoints & Guardrails Subscriptions Screen Loading Screen Error Screen Git

    Commit Git Commit Git Commit <code> + <code> + <code> + Main Screen Git Commit <code> @Gio_Sastre
  48. Checkpoints & Guardrails Subscriptions Screen Loading Screen Error Screen Git

    Commit Git Commit Git Commit <code> + <code> + <code> + Main Screen Git Commit <code> @Gio_Sastre
  49. Checkpoints & Guardrails Subscriptions Screen Loading Screen Error Screen Git

    Commit Git Commit Git Commit <code> + <code> + <code> + Main Screen Git Commit <code> @Gio_Sastre
  50. Checkpoints & Guardrails Subscriptions Screen Loading Screen Error Screen Git

    Commit Git Commit Git Commit <code> + <code> + <code> + Optimize Recompositions Main Screen Git Commit Git Commit <code> <code> @Gio_Sastre
  51. Sergio Sastre Flórez GDE for Android Lead Android developer Hashnode

    blogs @Gio_Sastre Linkedin @SergioSastre Github sergio-sastre-florez sergio-sastre