Skip to content

Instantly share code, notes, and snippets.

@realdadfish
Created December 1, 2021 10:03
Show Gist options
  • Save realdadfish/83ecbc9157a6bfeb0c7da137278898f5 to your computer and use it in GitHub Desktop.
Save realdadfish/83ecbc9157a6bfeb0c7da137278898f5 to your computer and use it in GitHub Desktop.
Workaround until https://issuetracker.google.com/issues/159104191 is implemented
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
}
}
}
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()
}
}
@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