Автор: Виталий Никоноров Оригинальная публикация
В предыдущей статье был приведен краткий обзор основных понятий и тем, о которых речь пойдет дальше. Предлагаю начать с модульных тестов, более известных, как юнит тесты. Итак, в основании пирамиды тестирования расположены модульные тесты, они же юнит (unit) тесты. Главное предназначение которых - тестирование минимальных единиц программ: методов, переменных, классов.
Свойства, характеризующие хорошие unit тесты: Быстрое выполнение. Современные проекты могут иметь тысячи и десятки тысяч тестов. Прогон unit тестов не должен занимать слишком много времени. Рациональные трудозатраты. Написание и поддержка тестов не должны занимать больше времени, чем написание самого кода. Изолированные. Тесты должны быть самодостаточными и не зависеть от среды выполнения (сети, файловой системы и т.п.). Автоматизированные. Не должны требовать вмешательства извне для того, чтобы определить результат выполнения. Стабильные. Результат выполнения теста должен оставаться неизменным, если код, который он тестирует, не был изменен. Однозначные. Тесты должны падать в случае, когда функциональность, которую они тестирует, сломана - наглядно демонстрируется при подходе Разработка через тестирование - TDD, когда тесты пишутся до реализации самой функциональности и соответственно изначально “красные”, но по мере реализации функциональности приложения становятся “зелеными” (начинают заканчиваться успешным результатом).
Лучшие практики:Планирование. Думайте о тестах еще на этапе написания кода. Даже если не пользуетесь подходом 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)
...
}
Наименование. Название теста должно включать в себя 3 основных компонента: тестируемый метод или поведение, тестируемый сценарий и ожидаемый результат.
Например fun `test incorrect input`() {
val dateTimeItems = listOf("2023-01-01T00:00")
val mapped = mapper.map(dateTimeItems, null)
assertNull(mapped)
}
Будет невозможно определить, что именно не так, не заглядывая в сам код. К тому же, не сразу понятно, почему же входные данные считаются некорректными. Исправим: fun `map valid items with missing timezone to null`() {
val dateTimeItems = listOf("2023-01-01T00:00")
val timezone = null
val mapped = mapper.map(dateTimeItems, timezone)
assertNull(mapped)
}
Придерживаясь одинакового стиля наименования, команда сможет быстрее ориентироваться в результатах запуска тестов, отслеживать изменения даже не заглядывая в сам код. Также, юнит тесты выполняют функцию документации и могут позволить понять проект без необходимости смотреть саму реализацию. Структура. Тесты должны состоять из 3 основных блоков: Arrange, Act, Assert. В блоке Arrange происходит создание, инициализация и настройка необходимых компонентов. Act содержит вызов тестируемого кода 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 {
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()
}
Такая структура позволит проще ориентироваться в коде тестов, выделить основные моменты. Также вам скажут спасибо на этапе code review или во время рефакторинга или изменения тестируемого кода. Стандарты. Unit тесты - сильно связаны с самим кодом, на который они пишутся и должны придерживаться таких же стандартов, как и сам production код. Уделяйте особое внимание наименованию методов и переменных. Очень часто юнит тесты пишутся для пограничных случаев (минимальные/максимальные значения, пустые множества, отрицательные числа, пустые строки). Очень важно явно указать в наименовании, почему используется то или иное значение.
@Test(expected = DateTimeParseException::class)
fun `map incorrect datetime format throws an exception`() {
val dateTime = "2023-01-0100:00"
val dateTimeItems = listOf(dateTime)
val timezone = "Europe/London"
mapper.map(dateTimeItems, timezone)
}
Небольшое изменение в наименовании переменной дает больше информации о природе ошибки: @Test(expected = DateTimeParseException::class)
fun `map incorrect datetime format throws an exception`() {
val dateTimeWithMissingDivider = "2023-01-0100:00"
val dateTimeItems = listOf(dateTimeWithMissingDivider)
val timezone = "Europe/London"
mapper.map(dateTimeItems, timezone)
}
Простота параметров. Код тестов должен быть максимально прост. Используйте минимально необходимый набор и самые простые значения входных параметров. Использование сложных конструкций может ввести в заблуждение и усложнит редактирование в случае изменения тестируемого кода. Использование дополнительных фабричных методов позволит упростить написание и понимание тестов: представьте Arrange блок, для данного примера, без выделения отдельных методов для создания типовых объектов:
class ForecastDataMapperTest {
@Test
fun `map correct input with 2 items to correct output with 2 items`() {
val mapper = ForecastDataMapper()
val correctForecastWith2Items = buildDefaultForecastApiResponse()
val mapped = mapper.map(correctForecastWith2Items)
Assertions.assertThat(mapped).isNotNull
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
}
}
}
Простота реализации. Избегайте сложной логики в unit тестах (не так жестко требуется в других видах тестирования). Наличие сложной логики может снизить качество сигнала, получаемого от unit тестов, к тому же мы не должны писать тесты на сами тесты :)
@Test
fun `map valid input with few items correctly`() {
val dateTimeItems = listOf(1, 2, 3).map { "2022-12-31T0$it:00" }
val inputTimezone = "Europe/London"
val mapped = mapper.map(dateTimeItems, inputTimezone)
assertThat(mapped!!.size).isEqualTo(2)
}
С одной стороны подобный код может упростить добавление большего количества элементов, но что будет, если будет передано число большее 9, 24? Лучше явно указать входные значения. @Test
fun `map valid input with few items correctly`() {
val dateTimeItems = listOf("2022-12-31T23:59", "2023-01-01T00:00")
val inputTimezone = "Europe/London"
val mapped = mapper.map(dateTimeItems, inputTimezone)
assertThat(mapped!!.size).isEqualTo(2)
}
Рациональность. 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 {
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()
}
Частность. Предпочитайте помещать логику настройки и очистки ресурсов в сам тест, вместо размещения всего в блоках @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 {
val testLocation = Location(id = "0", "Test City", Coordinate(30.0, 45.0), "Test/Zone")
whenever(locationRepository.getLocations()).thenReturn(listOf(testLocation))
var uiState: LocationSelectionUiState? = null
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))
}
}
Наличие кода в setup /tearDown блоках, который не относится ко всем (большинству) тестов, может вызвать взаимное влияние тестов друг на друга и ухудшить их качество. Исправим: @Test
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()
}
Таким образом тесты будут более читаемы, выразительны и их будет намного проще поддерживать и изменять в случае переписывания самой функциональности которую они тестируют. Специализация. Избегайте наличия нескольких Act /Asset блоков в unit тестах (Допустимо в интеграционных тестах). Если появляется желание добавить несколько Act блоков, подумайте о том, чтобы разделить один большой тест на несколько независимых. Слишком длинные тесты замедляют процесс отладки приложения, поскольку выполнение теста заканчивается после первой неудачной проверки.
Например, тест @Test
fun `location selection success flow`() = testScope.runTest {
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
val observeUiStateJob = launch(UnconfinedTestDispatcher(testScheduler)) {
delegate.uiState.collect {
uiState = it
}
}
delegate.fetchData()
delegate.onSelectionActionButtonClick(testLocation2)
assertThat(uiState).isNotNull
assertThat(uiState).isInstanceOf(SuccessState::class.java)
with(uiState as SuccessState) {
assertThat(selectedItem).isEqualTo(1)
assertThat(locations).isEqualTo(listOf(testItem1, testItem2Selected))
}
delegate.onSelectionActionButtonClick(testLocation1)
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 тестов будет самым большим и расположен в основании. Считается, что в проектах их должно быть больше всего. Однако на практике это не всегда так, и позднее я приведу примеры, когда это не совсем оправдано и гораздо проще и эффективнее полагаться на другие виды тестов. Следите за обновлениями. Обсудить в форуме |