0

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) ?

Piotr Śmietana
  • 393
  • 2
  • 19
  • `TextView`'s `text` and `visibility` in general is something you need to manually save and restore onto Views; that's not something that Views do automatically for you. – ianhanniballake Mar 14 '21 at 22:12
  • And note that moving your Fragment to `CREATED` absolutely should call `onDestroyView()` on your Fragment and moving it back up to `RESUMED` should then call `onCreateView()`. If that wasn't already happening for you, then that would indicate an issue with the older versions of Fragments, not the newer versions. – ianhanniballake Mar 14 '21 at 23:01
  • I didn't expect Views to retain their text and visibility in general case, I've just used text and visibility as easy to check test conditions to indicate that Fragment was recreated when I expected it not to – Piotr Śmietana Mar 15 '21 at 11:07
  • as for CREATED state, https://developer.android.com/reference/androidx/lifecycle/Lifecycle.State says that CREATED is reached not only "after onCreate call" but also "right before onStop call". Therefore my undestanding was that state is not a point in a lifecycle, but rather a range, and FragmentScenario.moveToState() moves to closest boundary of that range, so in case of RESUMED->CREATED it would call onStop and nothing more. I understand it's not correct and moveToState() is expected to always move to "earliest" point of given state ? – Piotr Śmietana Mar 15 '21 at 11:22
  • btw, what's the correct way of testing Fragment being stopped and resumed without view recreation using FragmentScenario ? I've tried going RESUMED->STARTED->RESUMED, but it obviously doesn't even call onStop – Piotr Śmietana Mar 15 '21 at 11:24

1 Answers1

2

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.

Pawel
  • 15,548
  • 3
  • 36
  • 36