androidandroid-fragmentsfragment-lifecycleinstrumented-testandroid-fragmentscenario

testing androidx.fragment lifecycle by stopping and resuming with FragmentScenario, onCreateView() called twice but this bug is already fixed in 1.3.1


I'm writing instrumented tests for my app using androidx.fragment:fragment-testing. One of test cases is to check if all underlying logic behaves correctly when Fragment is stopped and resumed, to simulate app being minimized (home button) and brought back again. These tests utilize FragmentScenario.moveToState(). First, I wrote my tests using androidx.fragment:fragment-testing:1.2.5, and they all passed. But when I've updated androidx.fragment:fragment-testing to 1.3.1, aforementioned tests started to fail.

I've checked what's wrong and it turned out that Fragment.onCreateView() is called again during lifecycle change, even if it shouldn't (in case of going back to CREATED and back to RESUMED), causing views to 'reset' to initial state declared in layout. I've looked this up and found a bug with description mentioning "onCreateView() lifecycle method gets called twice" https://issuetracker.google.com/issues/143915710 (it's also mentioned in https://medium.com/androiddevelopers/fragments-rebuilding-the-internals-61913f8bf48e). The problem is that it's already fixed in Fragment 1.3.0-alpha08, so it shouldn't happen in 1.3.1. This means that something must be wrong with my project configuration.

Here's an example code which reproduces the issue. It shows that Views don't retain their text nor visibility on lifecycle change RESUMED -> CREATED -> RESUMED. Manual testing doesn't reproduce this issue, it's affecting instrumented tests only.

class LifecycleBugFragment : Fragment() {

    lateinit var textView: TextView
    lateinit var editText: EditText
    lateinit var button: Button

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        val view =  inflater.inflate(R.layout.fragment_lifecycle_bug, container, false)
        textView = view.findViewById<TextView>(R.id.textView)
        textView.setOnClickListener { textView.text = "I was clicked" }
        editText = view.findViewById<EditText>(R.id.editText)
        button = view.findViewById<Button>(R.id.button)
        button.setOnClickListener { button.visibility = View.GONE }
        return view
    }
}

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".fragmenttesting.LifecycleBugFragment">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="default text" />

    <EditText
        android:id="@+id/editText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:inputType="text"
        />

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="click to hide me"
        />
</LinearLayout>

const val TYPED_TEXT = "some example text"
const val DEFAULT_TEXT = "default text"
const val CLICKED_TEXT = "I was clicked"

class LifecycleBugFragmentTest {

    lateinit var fragmentScenario: FragmentScenario<LifecycleBugFragment>

    @Before
    fun setUp() {
        fragmentScenario = FragmentScenario.launchInContainer(LifecycleBugFragment::class.java)
    }

    @Test
    fun whenTextViewclickedAndFragmentLifecycleStoppedAndResumed_ThenTextViewTextIsStillChanged() {
        onView(withId(R.id.textView)).check(matches(withText(DEFAULT_TEXT)))
        onView(withId(R.id.textView)).perform(click())
        onView(withId(R.id.textView)).check(matches(withText(CLICKED_TEXT)))
        stopAndResumeFragment()
        onView(withId(R.id.textView)).check(matches(withText(CLICKED_TEXT)))
    }

    // this test passes, others fail
    @Test
    fun whenEditTextIsEditedAndFragmentLifecycleStoppedAndResumed_ThenEditTextTextIsStillChanged() {
        onView(withId(R.id.editText)).perform(typeText(TYPED_TEXT))
        stopAndResumeFragment()
        onView(withId(R.id.editText)).check(matches(withText(TYPED_TEXT)))
    }

    @Test
    fun whenButtonIsClickedAndFragmentLifecycleStoppedAndResumed_ThenButtonISStillNotVisible() {
        onView(withId(R.id.button)).perform(click())
        onView(withId(R.id.button)).check(matches(not(isDisplayed())))
        stopAndResumeFragment()
        onView(withId(R.id.button)).check(matches(not(isDisplayed())))
    }

    private fun stopAndResumeFragment() {
        fragmentScenario.moveToState(Lifecycle.State.CREATED)
        fragmentScenario.moveToState(Lifecycle.State.RESUMED)
    }
}

dependencies {
    implementation fileTree(dir: "libs", include: ["*.jar"])
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    implementation "org.jetbrains.kotlin:kotlin-stdlib:1.4.31"
    implementation 'androidx.legacy:legacy-support-v4:1.0.0'
    testImplementation 'junit:junit:4.13'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
    androidTestImplementation "androidx.test:runner:1.3.0"
    androidTestImplementation "androidx.test:core:1.3.0"
    androidTestImplementation "androidx.test.ext:junit:1.1.2"
    androidTestImplementation "androidx.test:rules:1.3.0"

    implementation "androidx.navigation:navigation-fragment-ktx:2.3.4"
    implementation "androidx.navigation:navigation-ui-ktx:2.3.4"
    androidTestImplementation "androidx.navigation:navigation-testing:2.3.4"
    debugImplementation "androidx.fragment:fragment-testing:1.3.1"

    implementation "androidx.navigation:navigation-compose:1.0.0-alpha09"

    // other dependencies unrelated to issue skipped for clarity
}

Since I'm not declaring androidx.fragment:fragment directly, it comes as a transitive dependecy, so I've wondered if maybe it gets resolved to 1.3.0-alpha lesser than 8, thus not containing the fix. I've added dependency constraints to ensure that 1.3.1 is resolved

constraints {
    implementation('androidx.fragment:fragment:1.3.1') {
        because 'avoid bug'
    }
    implementation('androidx.fragment:fragment-ktx:1.3.1') {
        because 'avoid bug'
    }
}

but it didn't helped, so that's not the case

What else could be wrong with my code (most probably gradle dependencies) ?


Solution

  • By forcing fragment into CREATED state you're testing how it behaves when detached which by design does destroy its view hierarchy.

    While moving back to RESUMED (fragment reattached) view is recreated and its state is restored. Note: views are NOT being restored with savedInstanceState, fragment actually holds saved view state internally.

    EditText does save its state that's why it doesn't fail but TextViews and Buttons are not saving anything.

    You can force TextView to save its text by adding android:saveEnabled="true" to its XML but for visibility you will need to store the state in fragments field (or even save/restore it through savedInstanceState) and use it in onViewCreated.