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

Итак, в основании пирамиды тестирования расположены модульные тесты, они же юнит (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 тестов будет самым большим и расположен в основании. Считается, что в проектах их должно быть больше всего. Однако на практике это не всегда так, и позднее я приведу примеры, когда это не совсем оправдано и гораздо проще и эффективнее полагаться на другие виды тестов.

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

Комментарии (15)


  1. Redwid
    22.01.2023 22:50
    +1

    Если используем mockito доля тестов, то для создания моков можно использовать аннотацию - @Mock, получается короче.

    А статья о coverage будет? Как померить на сколько мы код покрыли.


    1. TheSecond Автор
      22.01.2023 23:55
      +1

      Согласен, в случае использования mockito, создавать моки можно с помощью аннотации. Я предпочитаю запись вида val locationRepository: LocationRepository = mock(),потому что в этом случае можно использовать val вместо var, ну и зачастую так получается 1 строка вместо 2.

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


  1. Rusrst
    22.01.2023 23:31

    Работу с асинхронным кодом рассматривать будут? Там есть особенности.


    1. TheSecond Автор
      23.01.2023 00:00

      Хорошее замечание! С асинхронный кодом всегда следует быть особенно внимательным, и там есть достаточно много особенностей.

      На данный момент пока не планирую статей про особенности таких тестов. В этих вопросах зачастую много особенностей выбранной технологии. (Rx, Classic threads, Kotlin Coroutines, свои проприетарные решения).


      1. Rusrst
        23.01.2023 01:05

        К сожалению тогда большое количество кода останется за скобками - тем более сейчас когда корутины упростили работу с фоновыми задачами по сути до myFun = job (т.е. viewModel scope.launch{})

        А у корутин не самое понятное апи для тестирования, откровенно говоря.


        1. TheSecond Автор
          23.01.2023 01:14

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


  1. ws233
    23.01.2023 08:56
    +1

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

    Дедушка Мартин это назвал FIRST. Легко запомнить. Как SOLID для приложений.


    1. ws233
      23.01.2023 09:06

      Еще можно добавить, что нужно избегать сразу нескольких ассертов в одном тесте. Это имеет смысл только, если проверяемые значения однозначно зависят друг от друга. В других же случаях, лучше написать 2 однотипных тестах, в которых вы проверите только 1 значение.

      Чем это лучше? Это реализация свойства "Изоляция" в Ваших тестах. Если Вы сломаете логику расчета одного из проверяемых параметров, то в первом случае у Вас упадет сразу несколько тестов, в каждом вы проверяете несколько результирующих значений – как итог, Вам придется долго сидеть и анализировать все, что происходит в проверяющем тесте, чтобы найти, а в чем же конкретно проблема. Во втором же случае, у вас упадет только 1 тест, в котором только 1 проверка, вы тут же поймете, куда конкретно вам надо смотреть.

      Принцип "Изоляции" подразумевает, что в случае проблемы в коде, у вас должен упасть ровно 1 тест, четко указывающий на 1 единственное место, где искать проблему.

      Подумайте, как поправить Ваш тест, в котором сразу несколько ассертов.


      1. Rusrst
        23.01.2023 09:22

        А как быть если я хочу порядок вызовов у моков проверить?


        1. ws233
          23.01.2023 09:29
          +1

          А зачем Вам проверять порядок вызова методов у моков?

          Есть еще одно золотое правило тестирования. Вы не проверяете внутреннюю реализацию Вашего кода, Вы проверяете результат. Тестирование Вы должны проводить "черного ящика", а не "белого".

          Это тоже реализация принципа "Изоляции". Ваш тест не должен зависеть от особенностей реализации Вашего кода. Если Вы захотите отрефакторить Ваш код и поменять реализацию, то Вам придется переписывать все Ваши тесты. А этого надо избегать. И - изоляция.

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

          Пример покажете? Можно будет подумать вместе.


          1. Rusrst
            23.01.2023 09:33

            Ну мы же об андроид разработке? Я хочу проверить что спустя 200 мс будет показан прогресс бар, если данные не пошли, если пошли, то не будет показан. Или что отмена progress bar происходит после отправки готовых данных - не мигает белый экран. Юнит тесты же это позволяют проверить. Возможно я не верно ставлю задачу?


            1. ws233
              23.01.2023 09:44

              Базовые принципы тем и хороши, что их можно к любой разработке применять. Даже к вебу или серверу ^.^

              если данные не пошли, если пошли, то не будет показан

              Вот это мне не очень понятно, но вообще вся идея про 200мс кажется неуместной. а что если запрос будет у вас отрабатывать 300мс, все равно будем мерцание? Но ок, давайте рассмотрим и ее.

              Все, что вы тут описали можно разбить на кучу разных тестов с 1 лишь проверкой. Разве нет?

              1. проверяем, что запустился таймер. ждать 200мс в тестах - моветон по двум причинам:

                1. нарушаем буковку F - Fast, тест становится медленным

                2. тестировать системное поведение (то, что таймер вызовется, если мы его запустили) - глупо. Это за пределами нашего влияния, поэтому и тестировать это бесполезно.

              2. показ прогресс бара вы проверяете уже в другом тесте и в другой функции.

              3. отмена прогресс бара - еще один тест.

              4. то, что отмена вызовется по в колбеке получения данных - еще один и т.д.

              Еще одно золотое правило в написании тестов: "Разделяй и властвуй". Не старайтесь одним тестом покрыть все Ваше приложение.


              1. Rusrst
                23.01.2023 09:52

                Подождите, корутины оперируют виртуальным временем, ждать там ничего не надо. delay(200) просто сдвинет виртуальное время.

                Всю функцию можно свести к extension над coroutine - соответственно это цельная функция с изменением состояния стороннего объекта. Ее тесты все равно желательно дробить?


                1. ws233
                  23.01.2023 10:22

                  Ну, я считаю, что да. Причины описал выше. Нужно стремиться, чтобы в одном конкретном месте у вас падал только 1 тест. Тогда любой человек через 20 лет придет, увидит 1 упавший тест и за 5 минут поймет, где и что он сломал. А если через 20 лет будет падать 100500 тестов, то этот человек просто удалит такие тесты :)


                  1. Rusrst
                    23.01.2023 10:30
                    +1

                    Я понял, спасибо большое за ответы!