-
Notifications
You must be signed in to change notification settings - Fork 500
MvRx Mocking System
Available from 2.0.0-alpha2 release
MvRx has an integrated mocking system to declare test data for screens and leverage that data with various testing tools. For a high level overview of this system, see this article series.
Mocks are a set of predefined values for your ViewModel states, and are defined on a per view basis, inside each MvRxView
. To define mocks, have your Fragment (or equivalent) extend MockableMvRxView
instead of MvRxView
. Then override the provideMocks
function.
class MyFragment : Fragment(), MockableMvRxView {
override fun provideMocks() = ...
}
Then use one of the functions mockSingleViewModel
, mockTwoViewModels
, or mockThreeViewModels
, depending on how many view models your fragment has. All view models must be mocked. If your Fragment has no view models you can use mockNoViewModels
.
override fun provideMocks() = mockSingleViewModel
(
viewModelReference = MyFragment::viewModel,
defaultState = myDefaultState,
defaultArgs = myDefaultArgs
) {
// Mock variants are defined here
}
Each of these mock methods have a similar structure - you must pass references to the view models you are mocking, as well as a default canonical state for each view model. There is a utility to help you generate this default state.
Lastly, if your Fragment accepts arguments you must pass a mocked instance of those arguments. Similar to the default state, these default arguments should represent the canonical version of your fragment and other mocks will be based off of them. If your fragment does not have arguments you should pass null.
When a mocked fragment is created it is initialized with the mock arguments (if any) and then its view models are forced to contain only the mocked state.
The default state argument passed to the top level mock function should be thought of as the main representation for all data on that page. Generally, no properties in it should be null, have an empty List or Collection, be undefined, be in a loading or error state, etc.
A default mock (named “Default state”) is created for you automatically based on the default state you pass in. Additionally, if default args are provided, a “Default initialization” mock is also automatically created.
The mocks you manually define should be slight variations on the default mock.
Advantages of defining a complete default state are:
- Allows us to easily look up a screenshot of the canonical version of each fragment
- Makes it simpler to define mocked variations based off the default
- Only have to maintain one large mock state object, other mocks are copied from the default with slight changes
- If your fragment has multiple view models it is easier to change just one view model, while letting the other fallback to default state.
Once default state has been set up, you can declare mock variations to your arguments or state. Each variation should be thought of as a test - and like most tests, it should target one specific feature in your fragment.
Ideally the mock variations will test all realistic data permutations that a user might encounter. For complex data sets it is not realistic or helpful to define variations for all possible data permutations - instead, try to target cases users will encounter, especially edge cases such as error states, loading, or nullable properties missing.
If you make a change to your fragment's code, your mocks should be comprehensive enough so that you feel comfortable shipping the change if all tests pass.
Mock variations are defined via a Kotlin DSL with the state function:
val defaultState = MyState(...)
override fun provideMocks() = mockSingleViewModel(MyFragment::MyViewModel, defaultState) {
// Each mock is defined with the "state" function.
// The name should describe the variation, and
// the state it represents should be returned from the lambda
state(name = "Null user") {
MyState(user = null)
}
}
Generally, since State objects are complex we don't want to create a new one for each variation. Instead, we use Kotlin's data class copy function to modify the default state with the change we want.
The default state is the receiver of the state lambda, so we can call copy directly in the lambda
val defaultState = MyState(...)
override fun provideMocks() = mockSingleViewModel(MyFragment::MyViewModel, defaultState) {
state(name = "Null user") {
// The receiver, or "this", is the defaultState from mockSingleViewModel
copy(user = null)
}
}
Complex state objects often have deeply nested data, which can be tedious to change using the copy function.
val state = MyState(
reservation = Reservation(
user = User(
name = "Dave"
)
)
)
// Set user name to null... gross :(
state.copy(reservation = state.reservation.copy(user = state.reservation.user.copy(name = null)))
As a simpler alternative you can use the set
function, which is a DSL tool that exists only within this mocking context
val defaultState = MyState(
reservation = Reservation(
user = User(
name = "Dave"
)
)
)
override fun provideMocks() = mockSingleViewModel(MyFragment::MyViewModel, defaultState) {
state(name = "Null user name") {
// This DSL says that we want to set the nested property 'name'
// to be null
set { ::reservation { ::user { ::name } } }.with { null }
}
}
This DSL for setting a property works by specifying one nested property along with the value it should be set to. The properties use the property reference syntax to specify which property in the object should be modified. Each lambda block represents another nesting layer in the object hierarchy.
Note that this ONLY works for Kotlin data classes. Also, since our data is immutable it doesn't modify the original state, but copies with the specified property updated - the new object is returned.
If you need to change multiple properties you can chain set calls:
state("Reservation canceled and void") {
set { ::reservation { ::isCanceled } }.with { true }
.set { ::reservation { ::isVoid } }.with { true }
}
There are a few variations on the set
DSL to help with common cases.
-
setNull
to set any property value to null -
setTrue
orsetFalse
to change a Boolean value -
setEmpty
to set a List property to an empty list -
setZero
to set a number property to zero
Add the success block to represent an Async property in the Success state.
setTrue { ::listingDetails { success { ::isNewListing } } }
This is useful for creating two mocks, for loading and failure, in a single short line
stateForLoadingAndFailure { ::listingDetails }
Note that this only works for Async properties at the top level of the State object.
If you are mocking two view models you can instead use viewModel1StateForLoadingAndFailure
and viewModel2StateForLoadingAndFailure
Alternatively you can individually modify loading or error state:
state("Loading") {
setLoading { ::reservation }
}
state("Failed") {
setNetworkFailure { ::reservation }
}
If your fragment takes arguments, then your mock function must define default arguments:
mockSingleViewModel(MyFragment::MyViewModel, defaultState, defaultArgs)
These arguments are provided to every mock variation, so that when the mocked fragment is created it is initialized with the arguments, and then has the mocked state overlaid via the view model.
MvRx view models automatically create initial state from fragment arguments, and this is tested for you as well. A dedicated initialization mock is automatically created using the defaultArgs you provide.
If you would like to test other argument initializations for your fragment you can do that with the args function:
mockSingleViewModel(MyFragment::MyViewModel, defaultState, defaultArgs) {
args("null id") {
setNull { ::id }
}
}
This operates very similarly to mocks declared with the state function.
If your fragment accesses arguments directly (instead of just using them to initialize it's MvRxState) - then you may want to test interactions between specific arguments and state. You can do that by passing arguments to a state mock function.
mockSingleViewModel(MyFragment::MyViewModel, defaultState, defaultArgs) {
state(
name = "null host name and args missing listing id",
args = { setNull { ::listingId } }
) {
setNull { ::listing { ::host { ::name } } }
}
}
If args are not provided to a state variation then the default args are used.
Defining mock state variations for multiple view models is very similar to the case with a single view model. The only difference is that for each state we need to define a specific view model.
For this you can use the functions mockTwoViewModels
and mockThreeViewModels
mockTwoViewModels(
viewModel1Reference = SearchFragment::resultsViewModel,
defaultState1 = resultsState,
viewModel2Reference = SearchFragment::reviewsViewModel,
defaultState2 =reviewsState,
defaultArgs = SearchArguments(query = "Hawaii")
) {
state(name = "no results, no reviews") {
viewModel1 {
setNull { ::results }
}
viewModel2 {
setNull { ::reviews }
}
}
This is a hypothetical search screen example. We have two view models, one for the main search results, and one for review data. We first specify references to the two view models, as well as a default state for each one.
Then, in our state variation we can specify changes to the default state for each view model.
state(name = "no query") {
viewModel2 {
setNull { ::currentQuery }
}
// Default state for viewmodel1 is used since it isn't specified
}
If your Fragment has many mocks, or there are different default States or Arguments that your mocks are tested with, you can split your mocks into groups using the combineMocks
function.
override fun provideMocks() = combineMocks(
"configurationA" to configurationAMocks(),
"configurationB" to configurationBMocks(),
)
In this example we can imagine a screen has very different configurations, A and B. We can have a separate function that defines each of those configurations. This allows us to conceptually group each set of mock states by configuration, and makes it easier to define variants for each configuration since each is based off of that default state for that configuration.
There is a utility for automatically generating mock data for each view model, so that you don't have to do it manually. It is highly recommended to use this for creating your mocks!
To allow mocks to work, make sure you have enabled them:
MvRxMocks.install(applicationContext)
Mocks are leveraged by testing systems, such as the MvRxLauncher.
They can also be used for unit testing, screenshot testing, and other sorts of integration testing.
If you would like to manually access the mocks that are defined for a Fragment you can use the functions called getMockVariants
, providing the name of the view you would like mocked. There is also a reified version for simplicity - getMockVariants<MyFragment>()