@Preview Driven Development with Jetpack Compose

@sliskicode
Whatnot Engineering
7 min readAug 29, 2023

--

Necessity is the mother of invention.

― Plato

Every idea comes from a need and this time was no different. During a regular day to day feature development at Whatnot I was struggling to keep up the pace of the business. I had to implement a quite huge feature in a week, making sure that everything works as expected. But how? What corners should have been cut to deliver good quality software? Let me explain it step by step.

Firstly, let’s focus on things that I perceive as a coding pace blockers.

  • Shame on me! Writing unit tests along with implementation of bleeding edge feature that changes every two hours not only from the business perspective but also design point of view is quite painful.
  • Testing complex user paths manually is frustrating and destroys focus.
  • Application build time. Complex codebase can slow down development pace. We all live with that pain!

🏃 Let’s skip unit testing!

Skipping unit tests create a bad side effect of not having an ability to execute and test code in isolation. Building app every minute on device is time consuming and this is how we burn tons of development cycles. Another hidden gotcha is fixing bugs without introducing a new ones. It is almost impossible without a solid regression suite.

I decided that I can live without unit tests during feature development and write them after the product launch. At the same time I put an extra effort to make sure that the code I write is testable and clean. It is not a huge challange as our architecture builders are well established and robust. Guess what! Skipping Test Driven Development saved me tons of time! I was able to write code fast and adjust implementation without any hassle.

Unfortunately testing on device destroyed my focus and I knew that I had to do something with that. Let’s try!

🔗‍💥 Testing on device

Fast Android emulator, incremental compilation and build cache are definitely one of the best things that could happen to Android engineers. It is not a huge pain to run an app every couple of minutes, but what if we need to test a specific validation scenario, error states or empty paths? Those complex user paths can take some time and consume our focus. As I mentioned before, unit testing helps us with that by letting us run code in isolation, setting initial state and get fast feedback. How can we translate this approach into the world where tests simply do not exist?

To answer that question let me briefly introduce our Whatnot Android tech stack. At Whatnot we are building live shopping and marketplace where you can buy, sell and discover products you will love. Along with live streaming we have plenty of seller and buyer features with rich native UI experience. We adopted Jetpack Compose at the early stage and that decision unlocked fast development cycle along with joy and developer satisfaction.

Jetpack Compose is the modern Android UI toolkit that can speed up development massively. Not only the code produced is simpler but also more elegant and concise. We do also use Jetpack ViewModel to feed our Composable UI with correct state. TL;DR MVI onboard. ViewModel is nothing else than a container that glues couple of functions that reduces state with network results and pushes it to the view layer.

🔁⚒️ Fixing fast feedback loop

Now when you know what tools do we use I can start explaining how I came up with a solution to fix broken feedback loop.

My goal was to build an easy solution for running UI along with model layer in isolation. On the top of that I wanted something fast, reliable and predictive. A few questions came to my mind:

  1. How to run Compose code in isolation?
  2. How to run Model logic in isolation?
  3. How to build a set of predefined states that I can use to test regression?

First things first, running @Composable function in isolation is quite easy. Jetpack Compose comes with an additional tooling artifact that allows to render it directly in Android Studio Preview.

debugImplementation("androidx.compose.ui:ui-tooling:VERSION")
implementation("androidx.compose.ui:ui-tooling-preview:VERSION")

This is super handy for creating a set of variants and test them in easy and fast way. Here is an example of using @Preview to visualise all secondary button states in Whatnot Design System.

Next puzzle is a bit harder. How can we use @Preview to run an entire screen or even an entire user flow in isolation? Regular screen written in Compose has more sophisticated function parameters than a button, but is that an issue? It is a challenge!

Let’s start from defining a public API of a screen.

As you can see we can run this as a regular Composable in @Preview by supplying function arguments.

ScreenViewModel is created by hand using fake use cases. We can do even better by using another Compose tooling candy. @PreviewParameter!

That is nice, isn’t it? By using PreviewParameterProvider and @PreviewParameter we can define as many cases as we want. All of them will render in Android Studio Preview automatically. They follow same coding approach as JUnit test params.

So what we achieved so far? We know how to render a screen in isolation along with its model logic. That is great, but is that enough to fix broken feedback loop? Not really, as we still do not know how to use @Preview to RUN and not only RENDER complex user path.

To simplify our complex path, imagine that we have a user flow that presents a list of shipping profiles and gives an ability to add, update and delete each profile. In addition to that, every screen should handle properly empty, error and data states. We also need some advanced form validation to make sure that user knows what to do. As it happens, we have such flow in our Whatnot app so let’s use it as an example.

Whatnot Shipping Profiles User Flow
Whatnot Shipping Profiles User Flow

As you can see, the video shows only a portion of the flow as it starts only from the Profile tab. In practice, testing every use case is more complex as we need to log in, go to the right screen, and make sure that all feature flags and configurations are set correctly. Do not be scared though. It is possible to RUN our flow in isolation using @Preview!

Going into the details it is worth mentioning that we use Compose Navigation which is nothing else than a Jetpack Navigation Component. It makes life way much easier and comes with some handy animations and tooling.

The entry point to the shipping profiles flow looks like this:

Ohh boy, that looks pretty scary at first, but let me explain what is happening here from the higher level point of view.

  1. shippingProfileFlow is an extension function that can be easily connected to any nav graph. This makes flow reusable across the entire codebase.
  2. shippingProfileFlow function defines six different navigation destinations such as list, create, edit, two types of an errors and some cost optimisation info message.
  3. The most important are the last three parameters viewModelStoreOwnerProvider, shippingProfilesViewModelProvider, shippingProfileViewModelProvider. They bring an actual “magic” that allows us to inject fake model providers and run code in isolation.
  4. By default shippingProfilesViewModelProvider has a real implementation that injects a correct object instance based on LocalViewModelStoreOwner.current.
  5. By default shippingProfileViewModelProvider has a real implementation that injects a correct object instance based on viewModelStoreOwnerProvider which is passed as a parameter.

Such technique is nothing different than a well known Dependency Injection. Now, with all of that, we can add @Preview code and RENDER flow in isolation.

Firstly, define @PreviewParameter(s).

Then add the @Preview function.

The most noisy part of this code is the helper functions for creating modalBottomSheetLayout, but I decided not to present it as it does not bring any real value here. Same rule apply to fake substitutions.

Finally! We are able to RENDER our flows in Android Studio Preview.

It works! What about RUNNING them on emulator?

As you can see flows RUN in isolation and we can play with them just after less than a second. I was able to transform “static” @Preview into fully blown @Preview Driven Development tool. It speeds up code pace and allows faking different complex scenarios with just few lines of code in data provider. In addition to that, @Preview cases are great documentation and manual regression set.

🤓 Key takeaways

I know, that might be a lot to digest, but if you already use Compose with Jetpack ViewModel it should not be hard to apply same strategy to your codebase.

Going back to coding pace blockers.

  1. Skipping writing unit tests is not something that I would recommend, but it saves time. Actually time is not saved, but borrowed from the future! After release I wrote full unit test suite to make regression faster. It took me a day as business logic and UX was already settled.
  2. Running complex user paths using @Preview is a game changer to me! Super easy to maintain, reliable and fast!
  3. Incremental build time takes less than two seconds and faking through @PreviewParameter is easy and concise.

To sum up, I really encourage you to start using Compose @Preview tooling if you do not. At Whatnot we are in the middle of implementing Compose Design System and I cannot imagine it without interactive @Preview(s). Stay tuned as I am planning to write about it soon!

If this was interesting to you, leave a comment, give some claps 👏 or let me know on Twitter. We are hiring at Whatnot!

--

--