Разделы портала

Онлайн-тренинги

.
Про тестирование мобильных приложений. Часть 2. Unit tests
24.05.2023 00:00

Автор: Виталий Никоноров
Оригинальная публикация

В предыдущей статье был приведен краткий обзор основных понятий и тем, о которых речь пойдет дальше. Предлагаю начать с модульных тестов, более известных, как юнит тесты.

Итак, в основании пирамиды тестирования расположены модульные тесты, они же юнит (unit) тесты. Главное предназначение которых - тестирование минимальных единиц программ: методов, переменных, классов. 

Свойства, характеризующие хорошие unit тесты:

  • Быстрое выполнение. Современные проекты могут иметь тысячи и десятки тысяч тестов. Прогон unit тестов не должен занимать слишком много времени.

  • Рациональные трудозатраты. Написание и поддержка тестов не должны занимать больше времени, чем написание самого кода.

  • Изолированные. Тесты должны быть самодостаточными и не зависеть от среды выполнения (сети, файловой системы и т.п.).

  • Автоматизированные. Не должны требовать вмешательства извне для того, чтобы определить результат выполнения.

  • Стабильные. Результат выполнения теста должен оставаться неизменным, если код, который он тестирует, не был изменен.

  • Однозначные. Тесты должны падать в случае, когда функциональность, которую они тестирует, сломана - наглядно демонстрируется при подходе Разработка через тестирование - TDD, когда тесты пишутся до реализации самой функциональности и соответственно изначально “красные”, но по мере реализации функциональности приложения становятся “зелеными” (начинают заканчиваться успешным результатом).

Лучшие практики:

  1. Планирование. Думайте о тестах еще на этапе написания кода. Даже если не пользуетесь подходом TDD, позаботьтесь о том, чтобы тестируемые компоненты были видны и могли быть настроены снажури. DI должен стать вашим лучшим другом (DI не значит использование сторонних фреймворков вроде Dagger или Koin, предоставление необходимых зависимостей через аргументы конструктора сильно облегчит написание тестов).

    Например делегат неявно зависит от репозитория и маппера, следовательно его будет практически невозможно протестировать изолированно. 

class LocationSelectionViewModelDelegate(private val mainScope: CoroutineScope) : LocationSelectionViewModel {

   private val repo: LocationRepository = LocationRepositoryImpl(Dispatchers.IO, LocationDataSourceImpl())
   private val locationItemMapper: LocationItemMapper = LocationItemMapper()

 …

}

Лучше сразу позаботиться  о том, чтобы все зависимости можно было передать извне: 

class LocationSelectionViewModelDelegate(
   private val mainScope: CoroutineScope,
   private val repo: LocationRepository,
   private val locationItemMapper: LocationItemMapper
) : LocationSelectionViewModel {

…

}

Таким образом мы легко можем подменить реализацию всех необходимых зависимостей:

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. Наименование. Название теста должно включать в себя 3 основных компонента: тестируемый метод или поведение, тестируемый сценарий и ожидаемый результат. 

Например 

fun `test incorrect input`() {

   // Arrange
   val dateTimeItems = listOf("2023-01-01T00:00")

   // Act
   val mapped = mapper.map(dateTimeItems, null)

   // Assert
   assertNull(mapped)

}

Будет невозможно определить, что именно не так, не заглядывая в сам код. К тому же, не сразу понятно, почему же входные данные считаются некорректными. 

Исправим:

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)
}

Придерживаясь одинакового стиля наименования, команда сможет быстрее ориентироваться в результатах запуска тестов, отслеживать изменения даже не заглядывая в сам код. Также, юнит тесты выполняют функцию документации и могут позволить понять проект без необходимости смотреть саму реализацию. 

  1. Структура. Тесты должны состоять из 3 основных блоков: Arrange, Act, Assert

    1. В блоке Arrange происходит создание, инициализация и настройка необходимых компонентов.

    2. Act содержит вызов тестируемого кода

    3. Assert - сопоставление полученного и ожидаемого результатов. 

Не сразу понятно, что же здесь происходит, и что конкретно тестируется:

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

Явное разделение того же самого кода значительно улучшает его читаемость:

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

Такая структура позволит проще ориентироваться в коде тестов, выделить основные моменты. Также вам скажут спасибо на этапе code review или во время рефакторинга или изменения тестируемого кода.

  1. Стандарты. Unit тесты - сильно связаны с самим кодом, на который они пишутся и должны придерживаться таких же стандартов, как и сам production код. Уделяйте особое внимание наименованию методов и переменных. Очень часто юнит тесты пишутся для пограничных случаев (минимальные/максимальные значения, пустые множества, отрицательные числа, пустые строки). Очень важно явно указать в наименовании, почему используется то или иное значение.

@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)
}

Небольшое изменение в наименовании переменной дает больше информации о природе ошибки:

@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. Простота параметров. Код тестов должен быть максимально прост. Используйте минимально необходимый набор и самые простые значения входных параметров. Использование сложных конструкций может ввести в заблуждение и усложнит редактирование в случае изменения тестируемого кода. Использование дополнительных фабричных методов позволит упростить написание и понимание тестов: представьте Arrange блок, для данного примера, без выделения отдельных методов для создания типовых объектов:

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. Простота реализации. Избегайте сложной логики в unit тестах (не так жестко требуется в других видах тестирования). Наличие сложной логики может снизить качество сигнала, получаемого от unit тестов, к тому же мы не должны писать тесты на сами тесты :)

@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)
}

С одной стороны подобный код может упростить добавление большего количества элементов, но что будет, если будет передано число большее 9, 24? Лучше явно указать входные значения.

@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. Рациональность. Unit тесты направлены на тестирование отдельных методов, функций или переменных. Хорошей практикой является использование упрощенных реализаций зависимостей (mocks, stubs, fakes), однако, в этом вопросе следует быть рациональным и порой оставлять настоящую реализацию, даже если она не тестируется, если это заметно упростит настройку. Наиболее подходящие для этого сущности - простейшие мапперы. Однако в таких случаях рекомендую убедиться, что используемый объект сам хорошо протестирован, а в случае, когда тесты сломаны, лучше начать отладку с наиболее простых классов.

В данном примере можно оставить настоящую реализацию locationItemMapper вместо использования дубликата

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. Частность. Предпочитайте помещать логику настройки и очистки ресурсов в сам тест, вместо размещения всего в блоках @Before и @After. Оставьте их для действительно важных и необходимых инструкций и команд требуемых используемыми библиотеками и фреймворками. Иначе будет сложно возвращаться к тестам и изменять их в случае внесения новых изменений в логику приложения.  Напишите дополнительные фабричные методы для создания типовых объектов, это также повысит читаемость тестов. (Этот навык очень хорошо тренируется и полезен при подготовке и прохождении coding сессий интервью, когда в короткие сроки требуется реализовать алгоритм на доске или в блокноте).

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))
   }
}

Наличие кода в setup/tearDown блоках, который не относится ко всем (большинству) тестов, может вызвать взаимное влияние тестов друг на друга и ухудшить их качество. Исправим:

@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. Специализация. Избегайте наличия нескольких Act/Asset блоков в unit тестах (Допустимо в интеграционных тестах). Если появляется желание добавить несколько Act блоков, подумайте о том, чтобы разделить один большой  тест на несколько независимых. Слишком длинные тесты замедляют процесс отладки приложения, поскольку выполнение теста заканчивается после первой неудачной проверки. 

Например, тест

@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()
}

Позволяет сделать много проверок, однако он уже не является unit тестом, это уже практически интеграционный тест, так лучше оставить такого рода сценарии им или разбить на несколько независимых небольших unit тестов.

Если взглянуть на пирамиду, то блок unit тестов будет самым большим и расположен в основании. Считается, что в проектах их должно быть больше всего. Однако на практике это не всегда так, и позднее я приведу примеры, когда это не совсем оправдано и гораздо проще и эффективнее полагаться на другие виды тестов.

Следите за обновлениями.

Обсудить в форуме