takahirom / roborazzi

Make JVM Android integration test visible 🤖📸
https://takahirom.github.io/roborazzi/
Apache License 2.0
695 stars 28 forks source link

Issue on ConstraintsLayout's layout_width #473

Open hellohj opened 2 weeks ago

hellohj commented 2 weeks ago

We're migrating Paparazzi snapshot testing to Roborazzi for legacy View-based UI.

During the migration, I encountered an issue where Roborazzi fails to capture anything when the TextView's width is set to 0dp and we set text from a test. Paparazzi doesn't seem to have any issues with the layout, but Roborazzi doesn't show anything (screenshot attached at the bottom). Changing layout_width=0dp to layout_width=wrap_content resolves the issue, but I don't think that's a desirable solution.

Can anyone provide insights into the cause of this issue? Any thoughts or suggestions would be greatly appreciated.

Example of simplified snapshot_test.xml

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:background="@color/android_green"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/title"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:ellipsize="end"
        android:maxLines="3"
        android:textIsSelectable="false"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toTopOf="@id/subtitle"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="Steve Avocado, Steve Burgess, and all the other Steves" />

    <TextView
        android:id="@+id/subtitle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@id/title"
        tools:text="Subtitle" />

</androidx.constraintlayout.widget.ConstraintLayout>

Example of test

@Config(sdk = [33])
class ViewBasedRoborazziTest : RoborazziBaseTest() {
  private lateinit var binding: SnapshotTestBinding

  @Before
  fun setup() {
    scenario.onActivity { activity ->
      binding = SnapshotTestBinding.inflate(LayoutInflater.from(activity))
    }
  }

  @Test
  fun testView() {
    with(binding) {
      title.text = "test test test test test test test title"
      subtitle.text = "subtitle subtitle subtitle subtitle subtitle"
      root.snapshot() // basically, `captureRoboImage()` call
    }
  }
}

ViewBasedRoborazziTest testView

takahirom commented 2 weeks ago

Thanks. I'm also not sure why it happens. However, 0dp in ConstraintLayout is used as match_constraint. I have some options to try. I think you can try specifying qualifiers like RobolectricDeviceQualifiers.Pixel5. Additionally, I think you can try adding a wrapper with a fixed width or calling the layout method of the View.

hellohj commented 2 weeks ago

Thanks for taking a look. RoborazziBaseTest from the above code already has these. I'll try other suggestions.

@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel5)
abstract class RoborazziBaseTest {...}
hellohj commented 2 weeks ago

Setting title.layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT in a test shows text so I don't need to touch our complex UI components from the design system, but constraints with surrounding UI elements are slightly off due to wrap_content. I'm at least unblocked and see if Roborazzi can do something here. Thank you.

takahirom commented 2 weeks ago

I thought that if you adjust the size of the outer View without altering the content, the inner part might change accordingly. Do you know how the layout on the receiving side of the following binding is set up? I suspect it might be set to wrap content.

scenario.onActivity { activity ->
      binding = 
hellohj commented 2 weeks ago

I'm using a blank activity for the snapshot testing only. Also, tried ComponentActivity for ActivityScenario. Still the same issue.

class TestRoborazziActivity : AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.snapshot_roborazzi_activity)
  }
}
--------
// snapshot_roborazzi_activity.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

-------
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel5)
abstract class RoborazziBaseTest {

  @get:Rule
  val roborazziRule =
    RoborazziRule(options = RoborazziRule.Options(outputDirectoryPath = OUTPUT_DIRECTORY_PATH))

  // For legacy view-based screens.
  val scenario = ActivityScenario.launch(TestRoborazziActivity::class.java)

  /** Capture snapshot for View-based legacy screen. */
  protected fun View.snapshot() {
    captureRoboImage()
  }

  @After
  fun tearDown() {
    scenario.close()
  }
}
takahirom commented 2 weeks ago

Thank you! I understand the situation. That's certainly weird.

takahirom commented 1 week ago

I would like to know what happens if we set layout_width="400dp" here. Could you check it out?

// snapshot_roborazzi_activity.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
hellohj commented 1 week ago

Interesting question. It makes a blank snapshot with a width of 418px. This is wild.

takahirom commented 1 week ago

@hellohj I forgot about this, but could you try using @Config(sdk = [33])? https://github.com/takahirom/roborazzi?tab=readme-ov-file#q-the-images-taken-from-roborazzi-seem-broken

takahirom commented 1 week ago

And I think you can also try using test.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware". https://github.com/DroidKaigi/conference-app-2024/blob/4c70202b90fc9597e3dc539546caecf1f29f9b96/build-logic/src/main/kotlin/io/github/droidkaigi/confsched/primitive/AndroidRoborazziPlugin.kt#L27

hellohj commented 1 week ago

Thanks for suggestions. sdk 33 is already specified from my code example so it didn't help. For the hardware render mode option, it's already defined in my gradle file and it also didn't help. 😢

takahirom commented 1 week ago

I'm busy with DroidKaigi and won't be able to work on this for two weeks. If you could provide a sample for reproduction, that would be great.

hellohj commented 19 hours ago

@takahirom No worries. Sorry, it took long time to provide you a sample. Here is my sample project. With this setup, my test even fails with this error. The layout is shown properly in the design view and the app runs properly. 🤔

width and height must be > 0
java.lang.IllegalArgumentException: width and height must be > 0
    at android.graphics.Bitmap.createBitmap(Bitmap.java:1111)
    at android.graphics.Bitmap.createBitmap(Bitmap.java:1078)
    at android.graphics.Bitmap.createBitmap(Bitmap.java:1028)
    at android.graphics.Bitmap.createBitmap(Bitmap.java:989)
    at androidx.core.view.ViewKt.drawToBitmap(View.kt:232)
    at androidx.core.view.ViewKt.drawToBitmap$default(View.kt:228)
    at com.github.takahirom.roborazzi.RoborazziKt.captureRoboImage(Roborazzi.kt:107)
    at com.github.takahirom.roborazzi.RoborazziKt.captureRoboImage(Roborazzi.kt:66)
    at com.github.takahirom.roborazzi.RoborazziKt.captureRoboImage$default(Roborazzi.kt:61)
    at com.example.myapplication.RoborazziMainTest.testBasic$lambda$1(RoborazziMainTest.kt:37)
    at androidx.test.core.app.ActivityScenario.lambda$onActivity$2(ActivityScenario.java:794)
    at androidx.test.core.app.ActivityScenario.onActivity(ActivityScenario.java:804)
    at com.example.myapplication.RoborazziMainTest.testBasic(RoborazziMainTest.kt:32)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
    at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
    at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
    at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
    at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
    at com.github.takahirom.roborazzi.RoborazziRule$runTest$evaluate$1.invoke(RoborazziRule.kt:150)
    at com.github.takahirom.roborazzi.RoborazziRule$runTest$evaluate$1.invoke(RoborazziRule.kt:148)
    at com.github.takahirom.roborazzi.RoborazziRule.runTest(RoborazziRule.kt:174)
    at com.github.takahirom.roborazzi.RoborazziRule.access$runTest(RoborazziRule.kt:27)
    at com.github.takahirom.roborazzi.RoborazziRule$apply$1.evaluate(RoborazziRule.kt:132)
    at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
takahirom commented 10 hours ago

Thank you for creating the reproducing repository. I was able to check it. The reason you are encountering an error is that your view is not attached to the activity you launched.

If we attach the view to MainActivity, it works. However, the view is added to MainActivity, resulting in a duplicated view.

     @Test
     fun testBasic() {
         val scenario = ActivityScenario.launch(MainActivity::class.java)

         scenario.onActivity { activity ->
-            binding = ActivityMainBinding.inflate(LayoutInflater.from(activity))
-            with (binding) {
+            binding = ActivityMainBinding.inflate(
+                LayoutInflater.from(activity),
+                activity.findViewById(android.R.id.content),
+                true
+            )
+            with(binding) {
                 title.text = "This is a title text"
                 subtitle.text = "This is a subtitle"
                 root.captureRoboImage()

com example myapplication RoborazziMainTest testBasic

In this sample, it seems that the issue with ConstraintLayout is not being reproduced. To better replicate the situation as it occurs in your repository, you might consider adjusting the setup or configurations to match the conditions under which the problem arises. This could involve using the same version of the library, similar layout complexities, or specific configurations that are present in your environment.

takahirom commented 10 hours ago

I reviewed your code, and it seems that the issue might be that your View is not included in the Activity.