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

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

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

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

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

Заключение

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

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

Читайте нас в TelegramVK и Яндекс.Дзен


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

Предыдущая статьяОсновы API Time для Java
Следующая статьяЧто думают ученые-компьютерщики о влиянии ИИ на общество