When I'm trying to write UITests for the app, I've encountered a problem:
when using espresso to click on the element to launch the BottomSheetDialog fragment, the dialog fragment would just not showing up.
when explicitly using launchFragment to launch the dialog fragment, it would throw an error, because in Hilt it requires using launchFragmentInHiltContainer.
However, the dialog fragment is a childFragment with a scoped view model, it can't be launched using launchFragmentInHiltContainer.
Here's the minimal example of the app:
FragmentA:
#AndroidEntryPoint
class FragmentA : Fragment() {
private val vm: ViewModelA by viewModels()
...
// in between the lifecycle of onResume() and onStop() the click listener will launch FragmentB
button.setOnClickListener {
FragmentB.show(childFragmentManager, "")
}
}
Then FragmentB as a dialog fragment, extends BottomSheetDialogFragment
#AndroidEntryPoint
class FragmentB : BottomSheetDialogFragment() {
private val vm: ViewModelA by viewModels({ requireParentFragment() })
...
}
ViewModelA is a singleton and all its constructors fields are injected using Hilt.
When doing UITest
// applied HiltRule
#Test
fun testcaseA() {
// this would success and I can test everything in FragmentA
launchFragmentInHiltContainer<FragmentA>()
// then launch the dialog FragmentB
onView(withId(buttonId))
.perform(click())
// it appears in RootView, the below will pass
onView(withText(bottomSheetDialogTitle)).inRoot(isDialog())
// how should I perform more tests on the elements in Fragment B?
}
What I have tried:
By using UIAutomater to check if the dialog has showed up, and the result is: the dialog did show up, but it is not visible
device.wait(
Until.findObject(
By.text("Title of the BottomSheetDialog")
), 500
)
Also I found this issue: https://github.com/robolectric/robolectric/issues/5158 , but it is reported on Robolectric, seems not related to my problem since I'm not using it.
Stacktrace when trying to launch BottomSheetDialogFragment directly using launchFragmentInHiltContainer:
Fragment is not a child Fragment, it is directly attached to
dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper
Question:
How to launch a BottomSheetDialogFragment(Fragment B) from Fragment A in Espresso to test against FragmentB's UI elements?
You can modify launchFragmentInHiltContainer as below and launch your dialog fragment (Fragment B) individually. Then you can test Fragment B's UI elements.
inline fun <reified T : Fragment> launchFragmentInHiltContainer(
fragmentArgs: Bundle? = null,
#StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
crossinline action: Fragment.() -> Unit = {}
) {
val startActivityIntent = Intent.makeMainActivity(
ComponentName(
ApplicationProvider.getApplicationContext(),
HiltTestActivity::class.java
)
).putExtra(
"androidx.fragment.app.testing.FragmentScenario.EmptyFragmentActivity.THEME_EXTRAS_BUNDLE_KEY",
themeResId
)
ActivityScenario.launch<HiltTestActivity>(startActivityIntent).onActivity { activity ->
val fragment: Fragment = activity.supportFragmentManager.fragmentFactory.instantiate(
Preconditions.checkNotNull(T::class.java.classLoader),
T::class.java.name
)
fragment.arguments = fragmentArgs
if (fragment is DialogFragment){
fragment.show(activity.supportFragmentManager, "")
} else {
activity.supportFragmentManager
.beginTransaction()
.add(android.R.id.content, fragment, "")
.commitNow()
}
fragment.action()
}
}
Related
I'm navigating from a Fragment to a DialogFragment. What I want to do is notice when this DialogFragment is dismissed in my Fragment to do something.
I'm trying doing this updating a LiveData but for some reason,when the DialogFragment is closed, the LiveData value is never true like I'm trying to do.
MyFragment:
private val myViewModel: MyViewModel by viewModel() //Using Koin
btn1.setOnClickListener{
findNavController().navigate(R.id.my_dialog_fragment)
}
myViewModel.dialogFragmentIsClosed.observe(viewLifecycleOwner){isClosed->
if(isClosed)
//do something
}
MyViewModel:
private val _dialogFragmentIsClosed = MutableLiveData(false)
val dialogFragmentIsClosed: LiveData<Boolean> get() = _dialogFragmentIsClosed
fun isDialogFragmentClosed(closed:Boolean){
_dialogFragmentIsClosed.postValue(closed)
}
DialogFragment:
private val myViewModel: MyViewModel by viewModel() //Using Koin
override fun onDismiss(dialog:DialogInterface){
myViewModel.isDialogFragmentClosed(true)
val bundle = bundleOf(Pair("argBoolean",true))
findNavController().navigate(R.id.my_fragment,bundle)
}
First of all, your DialogFragment and MyFragment are not sharing the same instance of MyViewModel.
To achieve your goal, you should check this document:
https://developer.android.com/guide/navigation/navigation-programmatic#additional_considerations
You will open DialogFragment from MyFragment, then observer the result from DialogFragment.
If you want to share the same instance of ViewModel, change to use activityViewModel() ( instead of using viewModel() )
You could use a interface to listen dismiss event. This is a simple way
First, create interface and put it in your dialog fragment:
interface CloseListener {
fun onClose()
}
class YourDialog(private val listener: CloseListener) : DialogFragment() {
override fun onDismiss(dialog:DialogInterface){
listener.onClose()
}
}
And then, in your fragment, calling dialogfragment like this:
val yourDialog = YourDialog(object: CloseListener{
override fun onClose() {
//do something here, such as set value for your viewModel
}
})
yourDialog.show(childFragmentManager, null)
Hope it can help you
I have a viewModel like:
abstract class MyViewModel : ViewModel() {
abstract fun onTextUpdated(text: String)
abstract val liveData: LiveData<String>
}
Which has 2 implementations like:
class MyViewModelOne : MyViewModel() {
override fun onTextUpdated(text: String) { // Do stuff }
override val liveData = MutableLiveData<String>()
}
class MyViewModelTwo : MyViewModel() {
override fun onTextUpdated(text: String) { // Do stuff }
override val liveData = MutableLiveData<String>()
}
Which are each used in their own seperate fragments, let's call them FragmentOne and FragmentTwo for the sake of demonstration.
FragmentOne and FragmentTwo both share the requirement to ask the user to enter some text via a dialog added to the host activity when the user taps a "Add Note" button. Let's call this dialog FragmentThree for the sake of demonstration. The result of this dialog (a string) should be shared with the parent fragment via the onTextUpdated function on either FragmentOne or FragmentTwo respectively, depending upon which route the user took through the app, when the dialog is dismissed.
Obviously, I only want to implement the FragmentThree dialog once, as it will look the same and have the same behaviour for both the FragmentOne and FragmentTwo use cases.
Therefore, I decided in the FragmentThree implementation to make use of Koins by sharedViewModel delegate, to share the viewModel injected in with the parent scope (the activity level). Obviously, because I don't know which implementation of MyViewModel will be there, because either FragmentOne or FragmentTwo could have lead to this point, I am referencing the base abstract class MyViewModel instead, assuming that Koin will be able to resolve this at run time.
However, when I run the code, the app crashes with:
2022-04-12 09:51:52.422 3607-3607/com.example.test E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.example.test, PID: 3607
java.lang.RuntimeException: java.lang.reflect.InvocationTargetException
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:602)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
Caused by: java.lang.reflect.InvocationTargetException
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
Caused by: org.koin.core.error.NoBeanDefFoundException: |- No definition found for class:'com.example.test.MyViewModel'. Check your definitions!
In my Koin module, where I am creating the definitions of what interfaces/abstract classes map to which implementations, I have correctly setup definitions for both of the implementations of MyViewModel, and the FragmentOne and FragmentTwo code which uses this view model via the by sharedViewModel delegate works fine as I also stipulate a qualifier for each of them, in both the module definitions and the fragments themselves, like so:
val appModule = module {
viewModel<MyViewModel>(
named("myViewModelOne")
) {
MyViewModelOne()
}
viewModel<MyViewModel>(
named("myViewModelTwo")
) {
MyViewModelTwo()
}
}
And then, in FragmentOne and FragmentTwo where the implementations are used, similarly:
class FragmentOne : Fragment() {
private val viewModel by sharedViewModel<MyViewModel>(
named("myViewModelOne")
)
...
}
class FragmentTwo : Fragment() {
private val viewModel by sharedViewModel<MyViewModel>(
named("myViewModelTwo")
)
...
}
And finally, in FragmentThree where I am wanting Koin to determine the implementation dynamically based upon the parent fragments view model implementation:
class FragmentThree : DialogFragment() {
private val viewModel by sharedViewModel<MyViewModel>()
...
}
So, my question is; is there someway I can share the view model from either FragmentOne or FragmentTwo via referencing it's base class in the by sharedViewModel delegate in FragmentThree without also qualifying which implementation to use (because I want it to use whichever implementation is currently active on it's parent fragment).
I want to write ui test for my fragment. Now I am using hilt for dependency injection and navigation components .My ui test code is like this.
#HiltAndroidTest
#RunWith(AndroidJUnit4::class)
class WelcomeFragmentTest {
#get:Rule
val hiltRule = HiltAndroidRule(this)
private val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
#Before
fun setUp(){
hiltRule.inject()
}
#Test
fun `testFragmentinits`(){
launchWelcomeFragment()
}
private fun launchWelcomeFragment() {
launchFragmentInHiltContainer<WelcomeFragment> {
navController.setGraph(R.navigation.nav_graph)
navController.setCurrentDestination(R.id.welcomeFragment)
this.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
if (viewLifecycleOwner != null) {
// The fragment’s view has just been created
Navigation.setViewNavController(this.requireView(), navController)
}
}
}
}
}
After runnig test i got this error
It is because you use different activity in your test rather than using your real activity. in your fragment, there is some code that casting the activity that you get in that fragment to your real activity. this doesn't work because you are using different activity in your test that is HiltTestActivity. try to cast it to activity class that is more general like Activity or AppCompatActivity.
My app uses MVVM architecture. I have a ViewModel shared by both an Activity and one of its child fragments. The ViewModel contains a simple string that I want to update from the Activity and observe in the fragment.
My issue is simple: the observe callback is never reached in my fragment after the LiveData updates. For testing, I tried observing the data in MainActivity, but that works fine. Additionally, observing LiveData variables in my fragment declared in other ViewModels works fine too. Only this ViewModel's LiveData seems to pose a problem for my fragment, strangely.
I'm declaring the ViewModel and injecting it into my Activity and Fragment via Koin. What am I doing incorrectly to never get updates in my fragment for this ViewModel's data?
ViewModel
class RFIDTagViewModel: ViewModel() {
private val _rfidTagUUID = MutableLiveData<String>()
val rfidTagUUID: LiveData<String> = _rfidTagUUID
fun tagUUIDScanned(tagUUID: String) {
_rfidTagUUID.postValue(tagUUID)
}
}
Activity
class MainActivity : AppCompatActivity(), Readers.RFIDReaderEventHandler,
RFIDSledEventHandler.TagScanInterface {
private val rfidViewModel: RFIDTagViewModel by viewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
rfidViewModel.rfidTagUUID.observe(this, {
Timber.d("I'm ALWAYS reached")
})
}
override fun onResume() {
rfidViewModel.tagUUIDScanned(uuid) //TODO: data passed in here, never makes it to Fragment observer, only observed by Activity successfully
}
}
Fragment
class PickingItemFragment : Fragment() {
private val rfidViewModel: RFIDTagViewModel by viewModel()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
rfidViewModel.rfidTagUUID.observe(viewLifecycleOwner, { tagUUID ->
Timber.d("I'm NEVER reached")
})
}}
Koin DI Config
val appModule = module {
viewModel { RFIDTagViewModel() }
}
In your Fragment I see you are using viewModels(). viewModels() here will be attached to the Fragment, not to the Activity.
If you want to shareViewModel between Fragment and Activity, then in Fragment you use activityViewModels(). Now, in the Fragment, your shareViewModel will be attached to the Activity containing your Fragment.
Edit as follows:
PickingItemFragment.kt
class PickingItemFragment : Fragment() {
private val rfidViewModel: RFIDTagViewModel by activityViewModels()
}
More information: Communicating with fragments
You need to use the same viewmodel, aka, sharedViewModel, the way you are doing you are using two different instances of the same viewmodel.
To fix it.
On both activity and fragment:
private val rfidViewModel: RFIDTagViewModel by activityViewModels()
https://developer.android.com/topic/libraries/architecture/viewmodel?hl=pt-br
I'm implementing Espresso tests. I'm using a Fragment with a NavGraph scoped ViewModel. The problem is when I try to test the Fragment I got an IllegalStateException because the Fragment does not have a NavController set. How can I fix this problem?
class MyFragment : Fragment(), Injectable {
private val viewModel by navGraphViewModels<MyViewModel>(R.id.scoped_graph){
viewModelFactory
}
#Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
//Other stuff
}
Test class:
class FragmentTest {
class TestMyFragment: MyFragment(){
val navMock = mock<NavController>()
override fun getNavController(): NavController {
return navMock
}
}
#Mock
private lateinit var viewModel: MyViewModel
private lateinit var scenario: FragmentScenario<TestMyFragment>
#Before
fun prepareTest(){
MockitoAnnotations.initMocks(this)
scenario = launchFragmentInContainer<TestMyFragment>(themeResId = R.style.Theme_AppCompat){
TestMyFragment().apply {
viewModelFactory = ViewModelUtil.createFor(viewModel)
}
}
// My test
}
Exception I got:
java.lang.IllegalStateException: View android.widget.ScrollView does not have a NavController setjava.lang.IllegalStateException
As can be seen in docs, here's the suggested approach:
// Create a mock NavController
val mockNavController = mock(NavController::class.java)
scenario = launchFragmentInContainer<TestMyFragment>(themeResId = R.style.Theme_AppCompat) {
TestMyFragment().also { fragment ->
// In addition to returning a new instance of our Fragment,
// get a callback whenever the fragment’s view is created
// or destroyed so that we can set the mock NavController
fragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
if (viewLifecycleOwner != null) {
// The fragment’s view has just been created
Navigation.setViewNavController(fragment.requireView(), mockNavController)
}
}
}
}
Thereafter you can perform verification on mocked mockNavController as such:
verify(mockNavController).navigate(SearchFragmentDirections.showRepo("foo", "bar"))
See architecture components sample for reference.
There exists another approach which is mentioned in docs as well:
// Create a graphical FragmentScenario for the TitleScreen
val titleScenario = launchFragmentInContainer<TitleScreen>()
// Set the NavController property on the fragment
titleScenario.onFragment { fragment ->
Navigation.setViewNavController(fragment.requireView(), mockNavController)
}
This approach won't work in case there happens an interaction with NavController up until onViewCreated() (included). Using this approach onFragment() would set mock NavController too late in the lifecycle, causing the findNavController() call to fail. As a unified approach which will work for all cases I'd suggest using first approach.
You are missing setting the NavController:
testFragmentScenario.onFragment {
Navigation.setViewNavController(it.requireView(), mockNavController)
}