About testing mobile applications. Part 2. Unit tests

The previous article gave a brief overview of the main concepts and topics that will be discussed next. I suggest starting with unit tests, better known as unit tests.
So, at the base of the testing pyramid are unit tests, they are also unit tests. The main purpose of which is to test the minimum units of programs: methods, variables, classes.
Properties that characterize good unit tests:
Fast execution. Modern projects can have thousands and tens of thousands of tests. Running unit tests should not take too long.
Rational labor costs. Writing and maintaining tests should not take more time than writing the code itself.
Isolated. Tests should be self-contained and independent of the execution environment (network, file system, etc.).
automated. Should not require outside intervention in order to determine the result of execution.
stable. The result of running a test should remain the same unless the code it tests has been changed.
unambiguous. Tests should fall in the case where the functionality they are testing is broken – clearly demonstrated when approaching Test Driven Development – TDD, when tests are written before the implementation of the functionality itself and, accordingly, initially “red”, but as the functionality of the application becomes “green” (begin to end with a successful result).
Best practics:
Planning. Think about tests at the coding stage. Even if you’re not using a TDD approach, make sure that the components under test are visible and configurable from the outside. DI should be your best friend (DI doesn’t mean using third party frameworks like Dagger or Koin, providing the necessary dependencies via constructor arguments will make writing tests a lot easier).
For example, a delegate implicitly depends on the repository and mapper, so it will be almost impossible to test it in isolation.
class LocationSelectionViewModelDelegate(private val mainScope: CoroutineScope) : LocationSelectionViewModel {
private val repo: LocationRepository = LocationRepositoryImpl(Dispatchers.IO, LocationDataSourceImpl())
private val locationItemMapper: LocationItemMapper = LocationItemMapper()
…
}
It is better to immediately make sure that all dependencies can be transferred from the outside:
class LocationSelectionViewModelDelegate(
private val mainScope: CoroutineScope,
private val repo: LocationRepository,
private val locationItemMapper: LocationItemMapper
) : LocationSelectionViewModel {
…
}
Thus, we can easily replace the implementation of all the necessary dependencies:
class LocationSelectionViewModelDelegateTest {
private val testScope = TestScope()
private val locationRepository: LocationRepository = mock()
private val locationItemMapper: LocationItemMapper = mock()
private val delegate: LocationSelectionViewModelDelegate =
LocationSelectionViewModelDelegate(testScope, locationRepository, locationItemMapper)
...
}
Name. The test name should include 3 main components: the method or behavior being tested, the scenario being tested, and the expected result.
For example
fun `test incorrect input`() {
// Arrange
val dateTimeItems = listOf("2023-01-01T00:00")
// Act
val mapped = mapper.map(dateTimeItems, null)
// Assert
assertNull(mapped)
}
It will be impossible to determine what exactly is wrong without looking into the code itself. In addition, it is not immediately clear why the input data is considered incorrect.
Let’s fix:
fun `map valid items with missing timezone to null`() {
// Arrange
val dateTimeItems = listOf("2023-01-01T00:00")
val timezone = null
// Act
val mapped = mapper.map(dateTimeItems, timezone)
// Assert
assertNull(mapped)
}
Adhering to the same naming style, the team will be able to quickly navigate the results of running tests, track changes without even looking at the code itself. Also, unit tests perform the function of documentation and can allow you to understand the project without having to look at the implementation itself.
Structure. Tests should consist of 3 main blocks: Arrange, act, assert.
In the Arrange block, the necessary components are created, initialized, and configured.
Act contains a call to the code under test
Assert – comparison of received and expected results.
It is not immediately clear what is happening here, and what exactly is being tested:
fun `initial success state with no selection`() = testScope.runTest {
val testLocation = Location(id = "0", "Test City", Coordinate(30.0, 45.0), "Test/Zone")
val testItem = LocationItem(testLocation, "Test City", false)
whenever(locationRepository.getLocations()).thenReturn(listOf(testLocation))
whenever(locationItemMapper.map(testLocation, isSelected = false)).thenReturn(testItem)
var uiState: LocationSelectionUiState? = null
val observeUiStateJob = launch(UnconfinedTestDispatcher(testScheduler)) {
delegate.uiState.collect {
uiState = it
}
}
delegate.fetchData()
assertThat(uiState).isNotNull
assertThat(uiState).isInstanceOf(SuccessState::class.java)
with(uiState as SuccessState) {
assertThat(selectedItem).isEqualTo(-1)
assertThat(locations).isEqualTo(listOf(testItem))
}
observeUiStateJob.cancel()
}
Explicitly separating the same code greatly improves its readability:
fun `initial success state with no selection`() = testScope.runTest {
// Arrange
val testLocation = Location(id = "0", "Test City", Coordinate(30.0, 45.0), "Test/Zone")
val testItem = LocationItem(testLocation, "Test City", false)
whenever(locationRepository.getLocations()).thenReturn(listOf(testLocation))
whenever(locationItemMapper.map(testLocation, isSelected = false)).thenReturn(testItem)
var uiState: LocationSelectionUiState? = null
// Act
val observeUiStateJob = launch(UnconfinedTestDispatcher(testScheduler)) {
delegate.uiState.collect {
uiState = it
}
}
delegate.fetchData()
// Assert
assertThat(uiState).isNotNull
assertThat(uiState).isInstanceOf(SuccessState::class.java)
with(uiState as SuccessState) {
assertThat(selectedItem).isEqualTo(-1)
assertThat(locations).isEqualTo(listOf(testItem))
}
observeUiStateJob.cancel()
}
Such a structure will make it easier to navigate the test code and highlight the main points. They will also thank you at the code review stage or during refactoring or changing the code under test.
Standards. Unit tests are strongly related to the code they are written to and should adhere to the same standards as the production code itself. Pay special attention to the naming of methods and variables. Very often unit tests are written for edge cases (min/max values, empty sets, negative numbers, empty strings). It is very important to explicitly state in the name why a particular value is used.
@Test(expected = DateTimeParseException::class)
fun `map incorrect datetime format throws an exception`() {
// Arrange
val dateTime = "2023-01-0100:00"
val dateTimeItems = listOf(dateTime)
val timezone = "Europe/London"
// Act
mapper.map(dateTimeItems, timezone)
}
A slight change in the variable name gives more information about the nature of the error:
@Test(expected = DateTimeParseException::class)
fun `map incorrect datetime format throws an exception`() {
// Arrange
val dateTimeWithMissingDivider = "2023-01-0100:00"
val dateTimeItems = listOf(dateTimeWithMissingDivider)
val timezone = "Europe/London"
// Act
mapper.map(dateTimeItems, timezone)
}
Simplicity of parameters. Test code should be as simple as possible. Use the minimum required set and the simplest values of the input parameters. The use of complex constructs can be confusing and difficult to edit if the code under test changes. Using additional factory methods will make it easier to write and understand tests: imagine an Arrange block, for this example, without highlighting separate methods for creating typical objects:
class ForecastDataMapperTest {
@Test
fun `map correct input with 2 items to correct output with 2 items`() {
// Arrange
val mapper = ForecastDataMapper()
val correctForecastWith2Items = buildDefaultForecastApiResponse()
// Act
val mapped = mapper.map(correctForecastWith2Items)
// Assert
Assertions.assertThat(mapped).isNotNull
// Some mandatory checks
Assertions.assertThat(mapped!!.temperature.data.size).isEqualTo(2)
}
private fun buildDefaultForecastApiResponse(
lat: Double? = -33.87,
lon: Double? = 151.21,
generationTimeMillis: Double? = 0.55,
utcOffsetSeconds: Int? = 39600,
timezone: String? = "Australia/Sydney",
timezoneAbbreviation: String? = "AEDT",
elevation: Double? = 658.0,
hourlyUnits: HourlyDataUnitsApiResponse? = buildDefaultHourlyUnits(),
hourlyData: HourlyDataApiResponse? = buildDefaultHourlyDataApiResponse(),
): ForecastApiResponse {
return ForecastApiResponse().apply {
this.lat = lat
this.lon = lon
this.generationTimeMillis = generationTimeMillis
this.utcOffsetSeconds = utcOffsetSeconds
this.timezone = timezone
this.timezoneAbbreviation = timezoneAbbreviation
this.elevation = elevation
this.hourlyUnits = hourlyUnits
this.hourlyData = hourlyData
}
}
private fun buildDefaultHourlyUnits(
time: String? = "iso8601",
temperature: String? = "°C",
humidity: String? = "%",
precipitation: String? = "mm",
windSpeed: String? = "km/h",
weatherCode: String? = "wmo code",
): HourlyDataUnitsApiResponse {
return HourlyDataUnitsApiResponse().apply {
this.time = time
this.temperature = temperature
this.humidity = humidity
this.precipitation = precipitation
this.windSpeed = windSpeed
this.weatherCode = weatherCode
}
}
private fun buildDefaultHourlyDataApiResponse(
time: List<String?>? = listOf("2023-01-22T00:00", "2023-01-22T01:00"),
temperature: List<Double?>? = listOf(14.4, 14.2),
humidity: List<Int?>? = listOf(86, 87),
precipitation: List<Double?>? = listOf(0.0, 1.4),
windSpeed: List<Double?>? = listOf(3.1, 2.2),
weatherCode: List<Int?>? = listOf(3, 80),
): HourlyDataApiResponse {
return HourlyDataApiResponse().apply {
this.time = time
this.temperature = temperature
this.humidity = humidity
this.precipitation = precipitation
this.windSpeed = windSpeed
this.weatherCode = weatherCode
}
}
}
Ease of implementation. Avoid complex logic in unit tests (not so rigidly required in other types of testing). The presence of complex logic can reduce the quality of the signal received from unit tests, besides, we should not write tests for the tests themselves 🙂
@Test
fun `map valid input with few items correctly`() {
// Arrange
val dateTimeItems = listOf(1, 2, 3).map { "2022-12-31T0$it:00" }
val inputTimezone = "Europe/London"
// Act
val mapped = mapper.map(dateTimeItems, inputTimezone)
// Assert
assertThat(mapped!!.size).isEqualTo(2)
}
On the one hand, such code can make it easier to add more elements, but what happens if a number greater than 9, 24 is passed? It is better to specify the input values explicitly.
@Test
fun `map valid input with few items correctly`() {
// Arrange
val dateTimeItems = listOf("2022-12-31T23:59", "2023-01-01T00:00")
val inputTimezone = "Europe/London"
// Act
val mapped = mapper.map(dateTimeItems, inputTimezone)
// Assert
assertThat(mapped!!.size).isEqualTo(2)
}
Rationality. Unit tests are aimed at testing individual methods, functions or variables. It is good practice to use simplified dependency implementations (mocks, stubs, fakes), however, you should be rational in this matter and sometimes leave the real implementation, even if it is not tested, if this significantly simplifies setup. The most suitable entities for this are the simplest mappers. However, in such cases, I recommend making sure that the object being used is itself well tested, and in the case where the tests are broken, it is better to start debugging with the simplest classes.
In this example, you can leave the real implementation locationItemMapper
instead of using duplicate
private val testScope = TestScope()
private val locationRepository: LocationRepository = mock()
private val locationItemMapper: LocationItemMapper = mock()
private val delegate: LocationSelectionViewModelDelegate =
LocationSelectionViewModelDelegate(testScope, locationRepository, locationItemMapper)
@Test
fun `initial success state with no selection`() = testScope.runTest {
// Arrange
val testLocation = Location(id = "0", "Test City", Coordinate(30.0, 45.0), "Test/Zone")
val testItem = LocationItem(testLocation, "Test City", false)
whenever(locationRepository.getLocations()).thenReturn(listOf(testLocation))
whenever(locationItemMapper.map(testLocation, isSelected = false)).thenReturn(testItem)
var uiState: LocationSelectionUiState? = null
// Act
val observeUiStateJob = launch(UnconfinedTestDispatcher(testScheduler)) {
delegate.uiState.collect {
uiState = it
}
}
delegate.fetchData()
// Assert
assertThat(uiState).isNotNull
assertThat(uiState).isInstanceOf(SuccessState::class.java)
with(uiState as SuccessState) {
assertThat(selectedItem).isEqualTo(-1)
assertThat(locations).isEqualTo(listOf(testItem))
}
observeUiStateJob.cancel()
}
Particular. Prefer to put the setup and cleanup logic in the test itself, instead of putting everything in blocks
@Before
and@After
. Leave them for the really important and necessary instructions and commands required by the libraries and frameworks you use. Otherwise, it will be difficult to return to the tests and change them if new changes are made to the application logic. Write additional factory methods to create generic objects, this will also increase the readability of the tests. (This skill trains very well and is useful in preparing and passing coding interview sessions, when it is required to implement the algorithm on a whiteboard or in a notebook in a short time).
private lateinit var testItem: LocationItem
private lateinit var testLocation: Location
private lateinit var observeUiStateJob: Job
@Before
fun setup() {
testItem = LocationItem(testLocation, "Test City", false)
whenever(locationItemMapper.map(testLocation, isSelected = false)).thenReturn(testItem)
}
@After
fun tearDown() {
observeUiStateJob.cancel()
}
@Test
fun `initial success state with no selection`() = testScope.runTest {
// Arrange
val testLocation = Location(id = "0", "Test City", Coordinate(30.0, 45.0), "Test/Zone")
whenever(locationRepository.getLocations()).thenReturn(listOf(testLocation))
var uiState: LocationSelectionUiState? = null
// Act
observeUiStateJob = launch(UnconfinedTestDispatcher(testScheduler)) {
delegate.uiState.collect {
uiState = it
}
}
delegate.fetchData()
// Assert
assertThat(uiState).isNotNull
assertThat(uiState).isInstanceOf(SuccessState::class.java)
with(uiState as SuccessState) {
assertThat(selectedItem).isEqualTo(-1)
assertThat(locations).isEqualTo(listOf(testItem))
}
}
The presence of a code in setup
/tearDown
blocks that do not apply to all (most) tests can cause the tests to mutually influence each other and degrade their quality. Let’s fix:
@Test
fun `initial success state with no selection`() = testScope.runTest {
// Arrange
val testLocation = Location(id = "0", "Test City", Coordinate(30.0, 45.0), "Test/Zone")
val testItem = LocationItem(testLocation, "Test City", false)
whenever(locationRepository.getLocations()).thenReturn(listOf(testLocation))
whenever(locationItemMapper.map(testLocation, isSelected = false)).thenReturn(testItem)
var uiState: LocationSelectionUiState? = null
// Act
val observeUiStateJob = launch(UnconfinedTestDispatcher(testScheduler)) {
delegate.uiState.collect {
uiState = it
}
}
delegate.fetchData()
// Assert
assertThat(uiState).isNotNull
assertThat(uiState).isInstanceOf(SuccessState::class.java)
with(uiState as SuccessState) {
assertThat(selectedItem).isEqualTo(-1)
assertThat(locations).isEqualTo(listOf(testItem))
}
observeUiStateJob.cancel()
}
This way, the tests will be more readable, expressive, and much easier to maintain and modify if the very functionality they are testing is rewritten.
Specialization. Avoid having multiple
Act
/Asset
blocks in unit tests (Allowed in integration tests). If you want to add someAct
blocks, consider splitting one large test into several independent ones. Tests that are too long slow down the process of debugging an application because test execution ends after the first failed test.
For example test
@Test
fun `location selection success flow`() = testScope.runTest {
// Arrange
val testLocation1 = Location(id = "1", "Test City 1", Coordinate(30.0, 45.0), "Test/Zone1")
val testLocation2 = Location(id = "2", "Test City 2", Coordinate(45.0, 30.0), "Test/Zone2")
val testItem1 = LocationItem(testLocation1, "Test City 1", false)
val testItem2 = LocationItem(testLocation2, "Test City 2", false)
val testItem1Selected = LocationItem(testLocation1, "Test City 1", true)
val testItem2Selected = LocationItem(testLocation2, "Test City 2", true)
whenever(locationRepository.getLocations()).thenReturn(listOf(testLocation1, testLocation2))
whenever(locationItemMapper.map(testLocation1, isSelected = false)).thenReturn(testItem1)
whenever(locationItemMapper.map(testLocation2, isSelected = false)).thenReturn(testItem2)
whenever(locationItemMapper.map(testLocation2, isSelected = true)).thenReturn(
testItem2Selected
)
whenever(locationItemMapper.map(testLocation1, isSelected = true)).thenReturn(
testItem1Selected
)
var uiState: LocationSelectionUiState? = null
// Act
val observeUiStateJob = launch(UnconfinedTestDispatcher(testScheduler)) {
delegate.uiState.collect {
uiState = it
}
}
delegate.fetchData()
delegate.onSelectionActionButtonClick(testLocation2)
// Assert
assertThat(uiState).isNotNull
assertThat(uiState).isInstanceOf(SuccessState::class.java)
with(uiState as SuccessState) {
assertThat(selectedItem).isEqualTo(1)
assertThat(locations).isEqualTo(listOf(testItem1, testItem2Selected))
}
// Act
delegate.onSelectionActionButtonClick(testLocation1)
// Assert
assertThat(uiState).isNotNull
assertThat(uiState).isInstanceOf(SuccessState::class.java)
with(uiState as SuccessState) {
assertThat(selectedItem).isEqualTo(0)
assertThat(locations).isEqualTo(listOf(testItem1Selected, testItem2))
}
observeUiStateJob.cancel()
}
It allows you to do a lot of checks, but it is no longer a unit test, it is almost an integration test, so it is better to leave such scenarios to them or break them into several independent small unit tests.
If you look at the pyramid, the unit test block will be the largest and located at the bottom. It is believed that in projects they should be the most. However, in practice this is not always the case, and later I will give examples where this is not entirely justified and it is much easier and more efficient to rely on other types of tests.
Keep for updates.