Need help writing tests for PR #6088 - Testing UI flash #6093
Debugging Doc: Writing Tests for PR #6088ContextThe ProblemI need to write automated tests that:
But every test I write passes even without the fix, which means the tests aren't actually catching the bug. What the Bug WasWhen submitting answers in the exploration player, the submitted answer and feedback text would briefly flash the previous card's content before showing the current card's correct content. This happened because:
The flash is very brief - only visible when slowing down the video to 0.2x speed. The FixAdded For feedback items: binding.viewModel = feedbackViewModel
binding.htmlContent = "" // Clear old content
binding.executePendingBindings() // Force immediate update
binding.htmlContent = htmlParserFactory.create(...)For submitted answer items: binding.viewModel = submittedAnswerViewModel
binding.executePendingBindings() // Force immediate update
val userAnswer = submittedAnswerViewModel.submittedUserAnswerThis forces the binding to update immediately, clearing the old recycled view content before the HTML parsing delay. What I've TriedAttempt 1: Direct Text Content VerificationI wrote a test that goes through multiple questions and verifies the submitted answer text doesn't show content from previous questions. Test approach: @Test
fun testStateFragment_multipleQuestions_submittedAnswerDoesNotFlashPreviousQuestionAnswer() {
// State 2: Submit "1/2"
typeFractionText("1/2")
clickSubmitAnswerButton()
scrollToViewType(SUBMITTED_ANSWER)
onView(withId(R.id.submitted_answer_text_view))
.check(matches(withText("1/2")))
// State 3: Submit "Eagle" - RecyclerView recycles the view
selectMultipleChoiceOption(optionPosition = 2, expectedOptionText = "Eagle")
clickSubmitAnswerButton()
scrollToViewType(SUBMITTED_ANSWER)
// Try to verify it does NOT show "1/2" from previous question
onView(withId(R.id.submitted_answer_text_view))
.check(matches(not(withText("1/2"))))
}Expected: Test would fail without Actual: Test passes even without the fix Attempt 2: Checking for Empty/Blank StateI thought maybe I could catch the moment when Problem: Same issue - Espresso waits for idle, so it doesn't see transient states. Attempt 3: Adding IdlingResourceI considered using a custom IdlingResource to monitor the binding state, but I'm not sure how to implement this properly or if it would even help since the flash happens during the binding update process. Why This Is Hard to TestThe core issue is that the flash happens in the tiny window between:
This window is maybe 50-100ms. Standard Espresso testing waits for the UI to stabilize, so it only sees state #3 (final state) - never the intermediate flash. Questions
What I Think Might WorkLooking at the code, maybe I should test that:
But I'm not confident about this approach and I'm not sure how to implement it in the existing test structure. Request for HelpI'd really appreciate guidance on:
I've confirmed the fix works manually (visible in the PR videos), so the code change itself is solid. It's just the automated test coverage that I'm struggling with. |
Replies: 2 comments 7 replies
|
Thanks for this write up @Neer-rn. One important clarification: we aren't actually using Espresso here, we're using Robolectric directly on JVM to run the test. The Espresso assertion and matching APIs are being used, but the actual logical driver under the surface here is Robolectric (which is really important as that directly ties to the threading model and how synchronization works). One unknown here is whether the Espresso APIs are actually synchronizing on the main thread, or if the pending transactions are executing due to our own manual synchronization (we have a utility for this called I suggest that you add a lot more logging lines, specifically in all of the following places:
bazel test //app/src/sharedTest/java/org/oppia/android/app/player/state:StateFragmentTest --test_filter=testStateFragment_multipleQuestions_submittedAnswerDoesNotFlashPreviousQuestionAnswer(Important note: this test should be in Make sure that the lines are very clearly identifying what's being logged so that it's easy to follow along. What we're looking for is specifically when in the test the bindings are being reset. Now, this won't actually tell us the point where synchronization might happen. That could be done if we had a way to spy on private fun getAllFeedbackTexts(activity: Activity): List<String> {
return findAllViewsByIdRecursive(activity.findViewById(android.R.id.content), R.id.feedback_text_view).map { "${it.text}" }
}
private fun findAllViewsByIdRecursive(root: View, id: Int): List<TextView> {
return when {
root.id == id -> listOf(root as TextView)
root is ViewGroup -> (0 until root.childCount).map { root.getChildAt(it) }.flatMap { findAllViewsByIdRecursive(it, id) }
else -> emptyList()
}
}(This may need some imports added, and I didn't test it; I'm hoping that the fragments are correctly linked that this doesn't need any special fragment hopping. A verification is it should start printing empty and eventually become non-empty once the feedback text exists). * If any Espresso functions synchronize specifically the main thread then we will not actually see the invalid state in the test. That's essentially what we're trying to verify here. We want to see the invalid state at some point from |
|
Thank you @BenHenning for your suggestions, it helped me a lot! It took me a bit more time than I imagined to do this, but here is what I got. I did the logging and empirically verified the fix. The bug is observable in Robolectric! Test ResultsWITHOUT the fix (executePendingBindings commented out):State 2 --> State 3 transition (logs): Old text persists in recycled views throughout the binding process. WITH the fix (executePendingBindings active):State 2 --> State 3 transition (logs): The fix successfully clears old content before HTML parsing begins. Key Finding:Critical observation from logs: Setting This pattern repeats consistently at every state transition (State 2-->3, State 3-->4). Automated Testing ChallengeI attempted to write an automated test following patterns from StateFragmentLocalTest.kt: @Test
fun testStateFragment_submittedAnswerAndFeedback_doNotFlashPreviousQuestionContent_automated() {
// Capture old feedback text
var state2FeedbackText = ""
scenario.onActivity { activity ->
state2FeedbackText = getAllFeedbackTexts(activity).firstOrNull().orEmpty()
}
clickContinueNavigationButton()
testCoroutineDispatchers.runCurrent()
// Submit new answer
selectMultipleChoiceOption(optionPosition = 2, expectedOptionText = "Eagle")
clickSubmitAnswerButton()
testCoroutineDispatchers.runCurrent()
// Try to verify old content doesn't appear
scenario.onActivity { activity ->
val currentFeedbackTexts = getAllFeedbackTexts(activity)
assertThat(currentFeedbackTexts).doesNotContain(state2FeedbackText)
}
}Result: Test PASSES both with fix and without the fix. According to me, by the time This confirms that the transient binding state, while observable in logs, is not easily capturable with standard Robolectric test assertions due to synchronization timing. ConclusionFrom above we can see the bug exits and my solution fixes it but the automated tests can't catch the transient state. I really need help to move ahead, what should be done now based on this findings @BenHenning please help me again. |
@Neer-rn the first
bindpathway doesn't use databinding so we wouldn't expect it to be affected by this problem (assuming the original premise that this is an update compatibility issue between databinding &RecyclerViewis actually correct). I suspect the execution line I suggested above is still correct, but if it isn't fixing the underlying problem then more investigation is needed. It may be the case that we have some sort of dynamic layout calculation happening that needs to be completely re-triggered because of the notification of updating everything (which is ultimately why data diffing fixes all flashing issues).I suggest seeing if there are other locations along the data pipelin…