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