Created
December 1, 2021 10:03
-
-
Save realdadfish/83ecbc9157a6bfeb0c7da137278898f5 to your computer and use it in GitHub Desktop.
Workaround until https://issuetracker.google.com/issues/159104191 is implemented
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class HiltFragmentScenario<F : Fragment> private constructor( | |
private val fragmentClass: Class<F>, | |
val activityScenario: ActivityScenario<TestHiltActivity> | |
) { | |
@Suppress("UNCHECKED_CAST") | |
val fragment: F? | |
get() = activityScenario.getActivity()?.supportFragmentManager?.findFragmentByTag(FRAGMENT_TAG) as? F? | |
/** | |
* Moves Fragment state to a new state. | |
* | |
* If a new state and current state are the same, this method does nothing. It accepts | |
* [CREATED][Lifecycle.State.CREATED], [STARTED][Lifecycle.State.STARTED], | |
* [RESUMED][Lifecycle.State.RESUMED], and [DESTROYED][Lifecycle.State.DESTROYED]. | |
* [DESTROYED][Lifecycle.State.DESTROYED] is a terminal state. | |
* You cannot move to any other state after the Fragment reaches that state. | |
* | |
* This method cannot be called from the main thread. | |
*/ | |
fun moveToState(newState: Lifecycle.State): HiltFragmentScenario<F> { | |
if (newState == Lifecycle.State.DESTROYED) { | |
activityScenario.onActivity { activity -> | |
val fragment = activity.supportFragmentManager | |
.findFragmentByTag(FRAGMENT_TAG) | |
// Null means the fragment has been destroyed already. | |
if (fragment != null) { | |
activity.supportFragmentManager.commitNow { | |
remove(fragment) | |
} | |
} | |
} | |
} else { | |
activityScenario.onActivity { activity -> | |
val fragment = requireNotNull( | |
activity.supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) | |
) { | |
"The fragment has been removed from the FragmentManager already." | |
} | |
activity.supportFragmentManager.commitNow { | |
setMaxLifecycle(fragment, newState) | |
} | |
} | |
} | |
return this | |
} | |
/** | |
* FragmentAction interface should be implemented by any class whose instances are intended to | |
* be executed by the main thread. A Fragment that is instrumented by the FragmentScenario is | |
* passed to [FragmentAction.perform] method. | |
* | |
* You should never keep the Fragment reference as it will lead to unpredictable behaviour. | |
* It should only be accessed in [FragmentAction.perform] scope. | |
*/ | |
fun interface FragmentAction<F : Fragment> { | |
/** | |
* This method is invoked on the main thread with the reference to the Fragment. | |
* | |
* @param fragment a Fragment instrumented by the FragmentScenario. | |
*/ | |
fun perform(fragment: F) | |
} | |
/** | |
* Runs a given [action] on the current Activity's main thread. | |
* | |
* Note that you should never keep Fragment reference passed into your [action] | |
* because it can be recreated at anytime during state transitions. | |
* | |
* Throwing an exception from [action] makes the host Activity crash. You can | |
* inspect the exception in logcat outputs. | |
* | |
* This method cannot be called from the main thread. | |
*/ | |
fun onFragment(action: FragmentAction<F>): HiltFragmentScenario<F> { | |
activityScenario.onActivity { activity -> | |
val fragment = requireNotNull( | |
activity.supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) | |
) { | |
"The fragment has been removed from the FragmentManager already." | |
} | |
check(fragmentClass.isInstance(fragment)) | |
action.perform(requireNotNull(fragmentClass.cast(fragment))) | |
} | |
return this | |
} | |
/** | |
* Recreates the host Activity. | |
* | |
* After this method call, it is ensured that the Fragment state goes back to the same state | |
* as its previous state. | |
* | |
* This method cannot be called from the main thread. | |
*/ | |
fun recreate(): HiltFragmentScenario<F> { | |
activityScenario.recreate() | |
return this | |
} | |
companion object { | |
private const val FRAGMENT_TAG = "FragmentScenario_Fragment_Tag" | |
fun <F : Fragment> launchFragmentInHiltContainer( | |
fragmentClass: KClass<F>, | |
fragmentArgs: Bundle? = null, | |
@StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme, | |
initialState: Lifecycle.State = Lifecycle.State.RESUMED, | |
@IdRes containerViewId: Int = android.R.id.content | |
): HiltFragmentScenario<F> { | |
require(initialState != Lifecycle.State.DESTROYED) { | |
"Cannot set initial Lifecycle state to $initialState for FragmentScenario" | |
} | |
val startActivityIntent = TestHiltActivity.createIntent( | |
ApplicationProvider.getApplicationContext(), | |
themeResId | |
) | |
val scenario = HiltFragmentScenario( | |
fragmentClass.java, | |
ActivityScenario.launch( | |
startActivityIntent | |
) | |
) | |
scenario.activityScenario.onActivity { activity -> | |
val fragment = activity.supportFragmentManager.fragmentFactory | |
.instantiate( | |
requireNotNull(fragmentClass.java.classLoader), | |
fragmentClass.java.name | |
) | |
fragment.arguments = fragmentArgs | |
activity.supportFragmentManager.commitNow { | |
add(containerViewId, fragment, FRAGMENT_TAG) | |
setMaxLifecycle(fragment, initialState) | |
} | |
} | |
return scenario | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class HiltFragmentScenarioRule<F : Fragment>(private val fragmentClass: KClass<F>) : | |
ExternalResource() { | |
var fragmentScenario: HiltFragmentScenario<F>? = null | |
private val postLaunchCallbacks = mutableListOf<(F) -> Unit>() | |
/** | |
* Launches the fragment in [Lifecycle.State.INITIALIZED] state with the given arguments. | |
* Ensure you set your application theme as [themeId] to prevent runtime resource issues. | |
*/ | |
fun launchFragment( | |
@StyleRes themeId: Int, | |
args: Bundle? = null, | |
initialState: Lifecycle.State = Lifecycle.State.INITIALIZED | |
): HiltFragmentScenario<F> = | |
HiltFragmentScenario.launchFragmentInHiltContainer( | |
fragmentClass = fragmentClass, | |
fragmentArgs = args, | |
themeResId = themeId, | |
initialState = initialState | |
).apply { postLaunchActions() } | |
/** | |
* Launches the dialog fragment in [Lifecycle.State.INITIALIZED] state with the given arguments. | |
* Ensure you set your application theme as [themeId] to prevent runtime resource issues. | |
*/ | |
fun launchDialogFragment( | |
args: Bundle? = null, | |
@StyleRes themeId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme, | |
initialState: Lifecycle.State = Lifecycle.State.INITIALIZED | |
): HiltFragmentScenario<F> = | |
HiltFragmentScenario.launchFragmentInHiltContainer( | |
fragmentClass = fragmentClass, | |
fragmentArgs = args, | |
themeResId = themeId, | |
initialState = initialState, | |
containerViewId = 0 | |
).apply { postLaunchActions() } | |
private fun HiltFragmentScenario<F>.postLaunchActions() { | |
fragmentScenario?.tearDown() | |
fragmentScenario = this | |
postLaunchCallbacks.forEach { callback -> callback(fragment ?: error("fragment not launched")) } | |
} | |
/** | |
* Registers a callback that is executed right after the Fragment is initially launched. | |
* | |
* This is useful for stubbing that has to happen before the Fragment is actually started. Be sure to | |
* launch the fragment in the proper initial state, such as [Lifecycle.State.INITIALIZED] | |
*/ | |
fun registerPostLaunchCallback(callback: (F) -> Unit) { | |
require(fragmentScenario == null) { "Fragment was already launched" } | |
postLaunchCallbacks.add(callback) | |
} | |
/** | |
* Returns an Dagger Hilt entry point to retrieve a dependency that is available in Hilt's | |
* FragmentComponent or above. Usage: | |
* | |
* ``` | |
* @EntryPoint | |
* @InstallIn(FragmentComponent::class) | |
* internal interface SomeDepEntryPoint { | |
* val dep: SomeDep | |
* } | |
* ... | |
* scenarioRule.getFragmentEntryPoint<SomeDepEntryPoint>().dep | |
* ``` | |
*/ | |
inline fun <reified E : Any> getFragmentEntryPoint(): E = | |
EntryPoints.get( | |
fragmentScenario?.fragment ?: error("fragment not launched"), | |
E::class.java | |
) | |
/** | |
* Use this method to run this rule's after() code manually when JUnit rule execution is not possible | |
*/ | |
fun tearDown() { | |
after() | |
} | |
override fun after() { | |
fragmentScenario?.tearDown() | |
fragmentScenario = null | |
// inline mocking in Mockito is prone to leak memory, so we clean out big mocked objects in UI tests | |
// see https://github.com/mockito/mockito/pull/1619 for a complete discussion | |
Mockito.framework().clearInlineMocks() | |
} | |
private fun HiltFragmentScenario<*>.tearDown() { | |
moveToState(Lifecycle.State.DESTROYED) | |
activityScenario.close() | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@AndroidEntryPoint(AppCompatActivity::class) | |
class TestHiltActivity : Hilt_TestHiltActivity() { | |
override fun onCreate(savedInstanceState: Bundle?) { | |
val themeRes = intent.getIntExtra(THEME_EXTRAS_BUNDLE_KEY, 0) | |
require(themeRes != 0) { "No theme configured for ${this.javaClass}" } | |
setTheme(themeRes) | |
super.onCreate(savedInstanceState) | |
} | |
companion object { | |
private const val THEME_EXTRAS_BUNDLE_KEY = "theme-extra-bundle-key" | |
fun createIntent(context: Context, @StyleRes themeResId: Int): Intent { | |
val componentName = ComponentName(context, TestHiltActivity::class.java) | |
return Intent.makeMainActivity(componentName) | |
.putExtra(THEME_EXTRAS_BUNDLE_KEY, themeResId) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment