Найти в Дзене
Nuances of programming

Лучшие практики модульного тестирования

Оглавление

Источник: Nuances of Programming

Тестирование имеет большое значение. Модульное тестирование  —  еще большее, это бесспорно. Вот пишешь какой-то код, и надо бы покрыть его тестами. Но как только представишь, сколько для этого потребуется приложить усилий, весь энтузиазм тут же улетучивается и желание писать тесты пропадает или пишешь их меньше, чем хотелось бы. Знакомо вам такое?

А ведь модульные тесты  —  штука полезная, и писать их нужно с удовольствием. Ну а как же иначе?😉 И в какой-то момент я задумался о том, почему написание модульных тестов кажется таким рутинным занятием. И о том, что неплохо было бы выявить, что этому способствует, и оптимизировать все эти факторы, чтобы снова сделать написание модульных тестов увлекательным занятием. А мне стать более продуктивным.

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

Функция «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
}
}

Проблема была в том, что эти заглушки были супердетализированными и очень быстро загромождали тестовые файлы. И снова загвоздка. Для решения этой проблемы я создал простой мок-объект, представляющий собой имитацию, если хотите:

public struct MockFunc<Input, Output> {
public var parameters: [Input] = []
public var result: (Input) -> Output = { _ in fatalError() }

public mutating func callAndReturn(_ input: Input) -> Output {
parameters.append(input)
return result(input)
}

public mutating func returns(_ value: Output) {
result = { _ in value }
}
}

А сама заглушка выглядит так:

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")]
)

Заключение

Когда рассматриваешь эти практики отдельно, их значимость не столь очевидна. И только объединив их, понимаешь: они действительно помогают контролировать тестовое покрытие и каждую неделю предоставлять новые обновления практически без регрессии. А какие лучшие практики есть у вас? С удовольствием добавил бы их в свою коллекцию!

Читайте также:

Читайте нас в TelegramVK

Перевод статьи Arsen Gasparyan: Best Practices For Unit Testing at Revolut