Skip to main content

Command Palette

Search for a command to run...

Introducing Dejavu: Recomposition Testing for Jetpack Compose

Wait... didn't we just compose this?

Published
4 min read
Introducing Dejavu: Recomposition Testing for Jetpack Compose
M
Android GDE - Open to opportunities (he/him)

Where This Idea Came From

At Square, most of the app is built on Workflow. There is an internal testing framework that lets you assert exactly how many render passes are triggered for a given interaction which is then enforced by CI.

I led the team that optimized the Select Payment Method screen in the Square Point of Sale app. We traced through the render tree, cut unnecessary render passes, and reduced overall latency by 30%! Each render was doing extra work, so consolidating effort and lowering the render count directly contributed to better performance.

The bigger win was what happened after. We wrote tests that asserted the the new and improved render counts we'd achieved. If a future change caused extra renders to show up, CI would fail and our performance gains couldn't silently regress.

The Problem with Compose

Workflows have renders. Compose has recompositions.

There's only a few ways to get insights into how Composables are performing.

Neither gives you a testable contract enforceable on every PR.

Introducing Dejavu

Dejavu turns recomposition behavior into test assertions. No production code changes beyond Modifier.testTag(). (which you are probably using already)

Setup

// Create a Recomposition Tracking Rule
@get:Rule
val composeTestRule = createRecompositionTrackingRule()

@Test
fun incrementCounter_onlyValueRecomposes() {
  // Perform an action
  composeTestRule.onNodeWithTag("inc_button")
    .performClick()

  // Assert that Composables change like you expect
  composeTestRule.onNodeWithTag("counter_value")
    .assertRecompositions(exactly = 1)

  // Or assert that they remain stable
  composeTestRule.onNodeWithTag("counter_title")
    .assertStable() // asserts recompositions = 0
}

Same pattern as any Compose UI test.

  • Find a node by tag

  • Perform an action

  • Assert your expected recomposition count!

When a test fails, you get diagnostics that tell you why:

dejavu.UnexpectedRecompositionsError: Recomposition assertion failed for testTag='product_header'
  Composable: demo.app.ui.ProductHeader (ProductList.kt:29)
  Expected: exactly 0 recomposition(s)
  Actual: 1 recomposition(s)

  All tracked composables:
    ProductListScreen = 1
    ProductHeader    = 1  <-- FAILED
    ProductItem      = 1

  Recomposition timeline:
    #1 at +0ms — param slots changed: [1] | parent: ProductListScreen

  Possible cause:
    1 state change(s) of type Int
    Parameter/parent change detected (dirty bits set)

See the Error Messages Guide for more information.

What Makes Dejavu Different

Dejavu hooks into CompositionTracer so there's no compiler plugin, bytecode manipulation, or Gradle plugin.

Causality analysis is a best effort attempt to explain why we had a recomposition. It tracks Snapshot state changes and maps dirty bits back to parameter slots.

All Composable are tracked by default. testTag only required for the assertion API with onNodeWithTag("x").

The tracer itself tracks every composable that gets traced via CompositionTracer, regardless of whether it has a testTag. The tag mapping just bridges between testTag and function name so assertions can find the right counter.

An AI Agent's Blind Spot

AI agents are writing Compose code. They refactor screens, hoist state, extract components, and they're mostly good at it. There's currently no way for an Agent to deterministically know it negatively (or positively) affected recompositions.

We humans are relying on the model to get it right as the ever growing avalanche of PR reviews comes barreling towards us. UI tests could pass and look fine in the CI recordings, but users start leaving negative reviews about jank.

Dejavu gives you, your CI, and your Agents the ability to run UI tests and validate that there are no unexpected changes.

The error messages work for both audiences. A human or an agent can read the failure and knows what to fix. And if an agent regresses recomposition CI fails and the PR doesn't merge.

You can also stream events in real time with Dejavu.enable(app, logToLogcat = true). An agent running adb logcat -s Dejavu gets a live feed of per-instance composition state while iterating on a running app.

Get Started

androidTestImplementation("me.mmckenna.dejavu:dejavu:0.1.2")

Full documentation · Getting Started · Examples · API Reference · GitHub

If you've ever stared at Layout Inspector wondering why a composable recomposed, or wished you could just assert on it in a test, give this a try and please let me know how it goes!

Whats next?

Lots of real testing! I added a pretty expansive test suite to measure correctness, but Compose is so expressive and adaptable I'm sure there are things I missed. If you find bugs I'd love to know what they are.


Footnotes

  1. At Square we were beginning to have weekly discussions about the value of an IDE in today's AI enabled world. Whats your take?