Better Android Testing at Airbnb — Part 4: Testing ViewModels
In the fourth installment of our series on Android Testing at Airbnb, we look at a framework for unit testing logic in ViewModels.
In part three of this series we saw how automated interaction testing can cover some of the code paths in our ViewModels by recording state changes. However, this can’t test all edge cases of our logic. ViewModel logic is crucial to the correct behavior of each screen, and thus is worth testing in more depth.
Unit Testing ViewModels
ViewModels are a case where we support manually written tests, but as usual we take an approach to minimize the overhead of manual testing by providing our developers with a unit test framework. This includes a DSL to simplify test statements, integration with our network stack to make assertions on executed requests, and a tie in with our state mocking system to easily set ViewModel states for testing.
We developed the unit test framework based on a few core tenets:
- A ViewModel function should be independently testable. The design of the ViewModel should not rely on interactions between multiple function calls.
- The behavior of a function call should be purely defined by the ViewModel’s State when the function is invoked, and the parameters it is passed.
- The output of the function should either be a new State set on the ViewModel, or a call out to a dependency.
Given these tenets, our framework takes the following approach:
- Each unit test invokes a single ViewModel function
- The input to the test is the initial state of the ViewModel, plus the parameters that are passed to the function
- The output of the test is an assertion on what was changed in the state, and/or verification of an expected call to a dependency (via Mockito).
A Basic Example
Let’s look at a basic ViewModel that stores an updatable String.
The code to test the setText function looks like this:
This specifies a reference to the function that we are testing, the parameter to invoke the function with, and what state change we should expect as a result. Here we test that calling setText(“hello”) results in the text state being updated to “hello”.
The expectState function takes the initial State as a receiver and returns the expected output State. This returned State must match the output exactly, otherwise the tests fails and the framework prints out which properties were not equal. Effectively, expectState defines which properties are expected to change and what the new values should be. This prevents side-effects from being missed.
The test framework takes care of initializing the ViewModel, collecting test statements, and checking assertions.
Tests are run with a normal JUnit and Robolectric setup, and each test class corresponds to a single ViewModel. The class implements an interface which the test frameworks uses to initialize a new ViewModel for each test.
For example, the full test class for the above ViewModel would look like this:
The test framework uses the buildViewModel() function to create a new ViewModel for each test.
The initial state of the ViewModel can be a mock state reused from an existing mock for the screen. This allows screenshot tests, interaction tests, and ViewModel unit tests to all share the same underlying mock instance. This greatly reduces the work involved to setup tests, and if the State data structure changes the mock only needs to be updated in one place.
If a test needs to use a modified version of the default state it can leverage our previously mentioned data class DSL to easily make changes to nested state.
To demonstrate, let’s expand our example to be a bit more complicated. Now it has some additional state that allows us to track whether the text is bold.
Our test syntax to check the setBold function looks like this:
This does the following:
- Initializes the nested bold boolean property to false
- Invokes the setBold with the parameter value true
- Validates that the final state of the ViewModel has the bold property now set to true
The DSL uses a pluggable system so that 3rd party extension functions can add custom statements and assertions. We use this ourselves to check that expected calls to our network stack are made.
In our example ViewModel let’s add a function that loads text from a network request.
The function to test this would look like:
- Invokes the loadText function with the id argument “1”
- Makes an assertion that we expect the ViewModel to execute a network GET request to the given API path with the id as a query parameter
- Specifies a mock response value of “server result”
- Asserts that the final state value of the text matches our mocked response value “server result”
This allows us to test the boundary of our ViewModel with the network layer, easily checking that the desired request is made and mocking the return value.
The expectRequests function is an extension function to the unit testing framework. This allows us to open source the core library but still test our internal libraries at Airbnb.
Also noteworthy is that in both unit and integration tests we never mock network requests at the JSON layer. We find maintaining JSON file mocks to be difficult and unnecessary. Instead, we are adopting GraphQL to give compile time guarantees on each request schema. This means we just need to assert that the proper query was made, and we can trust that the response will be in a valid, expected format.
This simplifies the scope of our tests, improves maintainability, and still offers us guarantees on the functionality of our network layer.
The unit test framework provides some other nice utilities for testing common ViewModel patterns.
One common case is a ViewModel function that updates a single property on the State, such as our example above that toggles the “bold” boolean. The framework offers special handling for this case to make it testable in just a few lines.
With this syntax, the test:
- Indicates that the setBold function is being tested
- Specifies a reference to the nested state property that will be changed as a result
- Detects the parameter type of the setBold function (boolean, in this case)
- Generates test inputs based on the parameter type. For a boolean this will be true and false. If it is nullable it will also test a “null” input.
- Invokes the setBold function with the generated input values, and after each invocation checks that the “bold” property in the state has been updated to the same value.
This works for any function with a single primitive type — the type is detected with reflection and the function is invoked with various test values. Then the property value on the state is checked to make sure it equals the expected test value.
In the more generic case, we also support multi parameter functions as well as cases where the state property type is different from the function parameter type.
For example, the following tests a function that squares an input and sets the value on the “result” property of the state.
This makes it easy to list a mapping of inputs to outputs; it automatically invokes the function with each input and checks for the corresponding output on the state.
It is also common for a ViewModel to execute network requests or other tasks during its initialization; that is, when a new instance is instantiated and the constructor is invoked.
For example, imagine our TextViewModel from above was modified to load text from a network request when it is created. We can test that behavior with this syntax.
This asserts that when the ViewModel is instantiated it:
- Makes a network GET request to an expected API path
- Sets the text property to a “Loading” state
This syntax is required compared to the normal function testing syntax because it must wrap the instantiation of the ViewModel and isolate the behavior there. On the other hand, when testing functions we exclude the behavior during instantiation in order to not conflate them.
Generating Test Scaffolding
Finally, in a multi module world it can be tedious to set up a unit test environment for each new module that is created (we have hundreds of modules!). For each module we need:
- A Robolectric test runner
- A base test for test classes to extend so the runner is applied
- Scaffolding to support dagger test overrides (a new Dagger module plus a Test application to setup injection of the dagger module).
- A mockito plugin to support mocking final classes and functions (for Kotlin usage)
We’ve created tooling that automatically generates all of this test scaffolding for a module, so a developer can instantly start writing unit tests without dealing with any of the tedium of configuration.
Additionally, we’ve created an Intellij IDEA plugin that can generate new MvRx ViewModels for us. This allows us to automatically create a test file for each new ViewModel that we add.
Next: Our Automation Infrastructure
Overall, our goal for this unit test framework was to:
- Remove friction from testing ViewModel logic, while;
- Providing a simple, yet flexible, API that can cover all use cases of a ViewModel.
Additionally, we designed the library to be extensible so we can open source it, allowing teams to easily add their own assertions to the DSL.
While this has been great for us, and was necessary for comprehensively testing business logic, the nicest tests are ones we can automatically generate! Next, in Part 5 of the series, we’ll revisit our automated integration testing framework to see how it powers our interaction and screenshot tests.
This is a seven part article series on testing at Airbnb.
Part 3 — Automated Interaction Testing
Part 4 (This article) — A Framework for Unit Testing ViewModels
Part 6 — Obstacles to Consistent Mocking
Part 7 — Test Generation and CI Configuration
Want to work with us on these and other Android projects at scale? Airbnb is hiring for several Android engineer positions across the company! See https://careers.airbnb.com for current openings.