Тестирование имеет большое значение. Модульное тестирование — еще большее, это бесспорно. Вот пишешь какой-то код, и надо бы покрыть его тестами. Но как только представишь, сколько для этого потребуется приложить усилий, весь энтузиазм тут же улетучивается и желание писать тесты пропадает или пишешь их меньше, чем хотелось бы. Знакомо вам такое?
А ведь модульные тесты — штука полезная, и писать их нужно с удовольствием. Ну а как же иначе?😉 И в какой-то момент я задумался о том, почему написание модульных тестов кажется таким рутинным занятием. И о том, что неплохо было бы выявить, что этому способствует, и оптимизировать все эти факторы, чтобы снова сделать написание модульных тестов увлекательным занятием. А мне стать более продуктивным.
И это удалось. Как именно это произошло, расскажу в этой статье. Вот те лучшие практики, следование которым помогает облегчить написание модульных тестов.
Функция «Sample»
Представьте себе функцию, которая принимает некую коммерческую модель (например, модель торговых сигналов PriceAlert) и текущую цену. Когда текущая цена превышает целевую цену, функция возвращает true
. Здесь важна только целевая цена, все остальные свойства PriceAlert нас не интересуют. Но вот в чем загвоздка: торговые сигналы нужны с конкретной целевой ценой, при этом все остальные свойства должны быть заполнены.
struct PriceAlert: Equatable {
let id: String
let symbol: String
let targetPrice: MoneyItem
let createdAt: Date
}
// тест
let priceAlert = PriceAlert(
id: String = "42",
symbol: String = "AAPL",
targetPrice: MoneyItem = MoneyItem(amount: 1050, currency: "USD"),
createdAt: Date = Date(timeIntervalSince1970: 1526202500)
)
check(
priceAlert: priceAlert,
currectPrice: MoneyItem(amount: 123, currency: "USD")
)
Для этого в каждой коммерческой модели указывается фабричный метод. С его помощью создается экземпляр без входных данных, хотя при желании есть возможность переопределить некоторые свойства:
extension PriceAlert {
static func sample(
id: String = "42",
symbol: String = "AAPL",
targetPrice: MoneyItem = MoneyItem(amount: 1050, currency: "USD"),
createdAt: Date = Date(timeIntervalSince1970: 1526202500)
) -> PriceAlert {
return PriceAlert(
id: id,
symbol: symbol,
targetPrice: targetPrice,
createdAt: createdAt
)
}
Здесь для каждого свойства указывается значение по умолчанию. Поэтому, когда они не особо важны, просто запрашивается экземпляр. Чаще всего бывает нужно указать одно или два конкретных значения — есть возможность сделать и это. Кроме того, стоит иметь в виду, что каждая функция sample — это чистая функция, поэтому не следует использовать какие-либо динамические значения, например Date()
.
Объект «MockFunc»
Второй обнаруженной мной проблемой были заглушки. А конкретнее, их настройка. Раньше для создания заглушек использовался генератор на основе замыкания. Вот так примерно:
class AnimatorSpy: Animator {
var invokedAnimate = false
var invokedAnimateCount = 0
var invokedAnimateParameters: (duration: TimeInterval, Void)?
var invokedAnimateParametersList = [(duration: TimeInterval, Void)]()
var shouldInvokeAnimateAnimations = false
var stubbedAnimateCompletionResult: (Bool, Void)?
var stubbedAnimateResult: Bool! = false
func animate(duration: TimeInterval, animations: () -> (), completion: (Bool) -> ()) -> Bool {
invokedAnimate = true
invokedAnimateCount += 1
invokedAnimateParameters = (duration, ())
invokedAnimateParametersList.append((duration, ()))
if shouldInvokeAnimateAnimations {
animations()
}
if let result = stubbedAnimateCompletionResult {
completion(result.0)
}
return stubbedAnimateResult
}
}
Проблема была в том, что эти заглушки были супердетализированными и очень быстро загромождали тестовые файлы. И снова загвоздка. Для решения этой проблемы я создал простой мок-объект, представляющий собой имитацию, если хотите:
А сама заглушка выглядит так:
final class PriceAlertRepositoryMock: PriceAlertRepositoryProtocol {
var cachedPriceAlertsMockFunc = MockFunc<String?, [PriceAlert]>()
func cachedPriceAlerts(symbol: String?) -> [PriceAlert] {
return cachedPriceAlertsMockFunc.callAndReturn(symbol)
}
}
Мок-объект работает очень просто. Ему известно только, что в него помещают и что из него получают. А вот как преобразовываются входные данные в выходные, конечно неизвестно. И здесь ему поможем мы. Обычно просто указываем выходные данные, какими бы ни были данные входные. Например, так:
var priceAlertRepository = PriceAlertRepositoryMock()
priceAlertRepository.cachedPriceAlertsMockFunc.returns([.sample(id: "53")])
В объекте «MockFunc» также имеется довольно много удобных условных обозначений:
someMockFunc.succeeds(.sample()) // когда в выходных данных Result<T>
someMockFunc.fails(error) // когда в выходных данных Result<T>
someMockFunc.returnsNil() // когда в выходных данных T?
someMockFunc.returns() // когда в выходных данных Void
expect(someMockFunc).to(beCalled())
Полная реализация «MockFunc» находится здесь.
Builder
Еще одна проблема была замечена на первом этапе модульных тестов — подготовка главного объекта. Она влияет, скорее, на удобство для восприятия человеком, но это немаловажный фактор. Он способствует написанию модульных тестов, ведь уже имеющиеся тесты, удобные для восприятия человеком, быстрее читать. И это тоже имеет значение, ребята!
Итак, раньше все готовили внутри тестового сценария. Например, такого:
// подготовка
var priceAlertApiService = PriceAlertApiServiceMock()
var priceAlertStorageService = PriceAlertStorageServiceMock()
let repository = PriceAlertRepository(
network: priceAlertApiService,
storage: priceAlertStorageService
)
// сам тест
priceAlertStorageService.cachedPriceAlertsMockFunc.returns(
[.sample(id: "1"), .sample(id: "2")]
)
let result = repository.cachedPriceAlerts(symbol: "AAPL")
expect(result.map { $0.id }) == ["1", "2"]
Этот простой пример подготовки занимает места не меньше, чем сам тест. Это не есть хорошо! Но и эта проблема решается. Благодаря классу builder
:
private class Builder {
var priceAlertApiService = PriceAlertApiServiceMock()
var priceAlertStorageService = PriceAlertStorageServiceMock()
func makeRepository() -> PriceAlertRepository {
let repository = PriceAlertRepository(
network: priceAlertApiService,
storage: priceAlertStorageService
)
return repository
}
}
Теперь тестовый сценарий не будет перегружен, а мы сосредоточимся на наиболее увлекательной части — написании самих модульных тестов.
// подготовка
let builder = Builder()
let repository = builder.makeRepository()
// сам тест
builder.priceAlertStorageService.cachedPriceAlertsMockFunc
.returns([.sample(id: "1"), .sample(id: "2")])
let result = repository.cachedPriceAlerts(symbol: "AAPL")
expect(result.map { $0.id }) == ["1", "2"]
Это способствует уменьшению шума. А кроме того, дает возможность использовать общую настройку с другими тестовыми сценариями. Например, когда надо настроить репозиторий с имеющимся кешем, создается пользовательский makeRepository
. И тогда приведенный выше код упрощается:
let repository = builder.makeRepository(
with: [.sample(id: "1"), .sample(id: "2")]
)
Заключение
Когда рассматриваешь эти практики отдельно, их значимость не столь очевидна. И только объединив их, понимаешь: они действительно помогают контролировать тестовое покрытие и каждую неделю предоставлять новые обновления практически без регрессии. А какие лучшие практики есть у вас? С удовольствием добавил бы их в свою коллекцию!
Читайте также:
- Визуализация стратегии автоматизированного тестирования
- Основы обработки естественного языка за 10 минут
- Тестирование уровня данных в Android Room с помощью Rxjava, LiveData и сопрограмм Kotlin
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Arsen Gasparyan: Best Practices For Unit Testing at Revolut