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:

  1. 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)

   ...

}
  1. 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.

  1. Structure. Tests should consist of 3 main blocks: Arrange, act, assert.

    1. In the Arrange block, the necessary components are created, initialized, and configured.

    2. Act contains a call to the code under test

    3. 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.

  1. 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)
}
  1. 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
       }
   }
}
  1. 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)
}
  1. 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()
}
  1. 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.

  1. Specialization. Avoid having multiple Act/Asset blocks in unit tests (Allowed in integration tests). If you want to add some Act 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *