Привет! Меня зовут Владислав Сединкин, я работаю iOS-разработчиком в СберМаркете. Сегодня я расскажу, как мы проводим юнит-тестирование, с какими сложностями сталкивались при написании тестов и как их решали.
Я выступал с этим докладом на iOS Meetup | СберМаркет Tech, здесь его сжатая версия.
Стили юнит-тестов
Юнит-тесты проверяют, правильно ли работает фрагмент кода. Они быстро выполняются и не зависят друг от друга. Хорошие юнит-тесты:
интегрированы в цикл разработки — в идеале должны запускаться на CI;
проверяют бизнес-логику, а не простые вещи;
защищают от багов;
устойчивы к рефакторингу.
В зависимости от подхода к изоляции и разбиению кода на небольшие фрагменты выделяют два стиля юнит-тестирования.
Классические юнит-тесты проверяют связанные объекты. Такие тесты изолируются друг от друга.
Лондонские юнит-тесты проверяют один объект, который изолирован от зависимостей. Для этого все зависимости в тесте заменяются на заглушки test doubles. Есть пять видов test doubles:
dummy-объект — обычная заглушка, которая не нарушает сигнатуру вызова функции. Не несёт полезной нагрузки для теста, поэтому в передаваемой функции его можно заменить на nil;
stub-объект предоставляет данные из теста, полностью заменяет исходную реализацию;
fake-объект похож на stub. Он подменяет и упрощает сложную реализацию. Логика и и выход при этом остаются прежними;
spy-объект записывает количество вызовов тестируемой функции и полученные аргументы;
mock-объект вбирает в себя особенности stub и spy, но при этом может завалить тест, если условие не удовлетворяется. Например, если функция вызывается большее количество раз, чем необходимо, или аргументы приходят не такими, какими нужно.
СберМаркет использует лондонский стиль юнит-тестирования.
Какой способ юнит-тестирования используем
Основных способов юнит-тестирования два: проверять выходные значения или детали реализации.
В СберМаркете мы обычно тестируем выходные значения. Для этого используем метод «черный ящик»: разработчик пишет тесты, не зная устройство функции. Главное, чтобы её выходные значения совпали с ожидаемым результатом. Так мы проверяем, что, а не как должна делать система.
Тесты по методу «черного ящика» разбивают на три блока:
В Given устанавливают моки, начальные и ожидаемые значения. Обычно это самый большой блок.
В When вызывают функцию, которую нужно протестировать.
В Then проверяют результаты. Это может быть несколько проверок — главное, чтобы все они относились к этой функции и к этому сценарию.
Вот как это выглядит на практике:
func test_sumCalculation() {
// given
let aValue = 7
let bValue = 3
let expectedValue = 10
// sut.setMocks
// when
let result = sut.calculate(a: aValue: b: bValue)
// then
XCTAssertEqual(result, expectedValue)
}
Какой инструмент используем для юнит-тестирования
Наш основной инструмент — фреймворк SwiftyMocky, который генерирует заглушки для зависимостей. С его помощью тестировщики проверяют результаты вызовов и настраивают тестовые значения для свойств и методов. Полное описание функций есть в GitHub-репозитории библиотеки.
Обычно для зависимостей нужно составлять сложные графы и долго их настраивать. SwiftyMocky позволяет помечать зависимости протоколом AutoMockable — это проще и быстрее. Пример юнит-теста с использованием SwiftyMocky:
func test_foo() {
// given
let storeId = 5
// задаём ожидаемое значение методом given
dependency1.given(.currentStoreId(willReturn: storeId))
// when
sut.fooMethod(storeId: storeId)
// then
// проверяем, сколько раз вызывалась функция
// и какие аргументы она получила, методом verify
dependency1.verify( .currentStoreId(), count: .once)
}
Что тестируем
В СберМаркете основной пласт тестов связан с бизнес-логикой. Например, мы пишем юнит-тесты для протоколов Presenter и Interactor.
Presenter — это протокол, который обрабатывает события от пользовательского интерфейса. Например, можно подписать вьюшку на свойство ViewState у Presenter. View будет перерисовываться после каждого обновления свойства. В тестах мы проверяем, что ViewState корректно взаимодействует с зависимостями, например с output или координатором.
Вот пример такого теста для Presenter:
func test_ifModuleIsConfigured_viewStateIsConfigured() {
// given
let codeLength = 6
let resendLimit = 3
let phone = "9991234567"
let formattedPhone = "+7 (999) 123 45 67"
// when
presenter.configure(
isPromoTermsAccepted: true,
isB2B: true,
phoneNumber: phone,
source: .addressModal
)
// then
XCTAssertEqual(viewState.codeFieldModel.codeLength, codeLength)
XCTAssertEqual(viewState.phoneNumber, formattedPhone)
XCTAssertEqual(viewState. isResendCodeButtonEnabled, false)
XCTAssertEqual(viewState.resendInSeconds, resendLimit)
}
Interactor — протокол, который готовит данные для передачи в Presenter. Поэтому в тестах для Interactor мы проверяем, что данные получены корректно. Вот пример такого теста для Interactor:
func test_updateStoreld() {
// given
let oldStoreId = 5
let newStoreId = 6
storeSelectionService.given(.currentStoreId(willReturn: oldStoreId))
// when
interactor.updateStoreId(storeId: newStoreId)
// then
storagesCleaner.verify(.cleanStoreData(), count: .once)
storeSelectionService.verify(
.setCurrentStoreId(.value(newStoreId), nil, completion: nil),
count: .once
)
}
Как решили проблему с асинхронным кодом из-за Combine
Первая сложность, с которой мы столкнулись при юнит-тестировании, связана с фреймворком Combine. Практически весь код на Combine выполняется асинхронно — это затрудняет тестирование.
Если код асинхронный, то тесты ждут, пока выполнится тестируемый метод. Из-за этого растет время проверки и код: например, приходится писать условие завершения метода.
Простое решение — использовать expectation, но оно также увеличивает размер и время теста. Замедление особенно заметно, если использовать операторы delay, debounce или throttle.
Пример метода, который нерационально тестировать с помощью expectation:
viewState.fieldModel
.$text
.debounce(for: 0.3, scheduler: scheduler)
.sink { [weak self] text in
…
}
.store(in: &textSubscriptions)
Представим, что у нас есть published-свойство text. Оно изменяется, когда пользователь набирает текст в поле ввода. Мы получаем это событие и обрабатываем текст через 300 миллисекунд после ввода. Тест для этого метода выглядел бы следующим образом:
func test_viewDidAppear_textTypingHandled_requestSuggests_delayed() {
// given
let testText = "test"
interactor.given(
.getSuggests(
for: .value(testText),
willReturn: Result.Publisher([]).eraseToAnyPublisher())
)
// Создаём expectation
let expectation = expectation(description: #function)
presenter.viewDidAppear()
// when
// Триггерим изменение свойства text
viewState.fteldModel.updateText("test")
// Ждём, пока пройдёт debounce
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: expectation.fulfill)
// then
wait(for: expectation, timeout: 0.4)
interactor.verify( .getSuggests(for: .value(testText)))
}
Итог: тест никогда не выполняется быстрее, чем за 350 миллисекунд. Это долго.
Более удачное решение — Combine Schedulers. Они позволяют синхронно выполнять асинхронный код без временных потерь.
Самый известный Scheduler — ImmediateScheduler, который Combine предоставляет из коробки. Для нашей задачи он не подойдёт, поэтому мы будем использовать кастомный объект — TestScheduler.
TestScheduler более продвинутый, чем стандартный ImmediateScheduler. Он позволяет управлять цепочкой Combine — например, запускать выполнение в нужный момент.
Чтобы использовать Scheduler, его нужно внедрить напрямую в тестируемый объект через инициализатор. Если тестируемый метод возвращает Publisher, можно использовать оператор receive(on:) с нужным Scheduler.
Вот как тест из примера выше выглядит с TestScheduler:
func test_viewDidAppear_textTypingHandled_requestSuggests_delayed() {
// given
let testText = "test"
…
presenter.viewDidAppear()
// when
// Триггерим изменение свойства text
viewState.fieldModel.updateText("test")
// Сдвигаем виртуальное время на 100 миллисекунд
scheduler.advance(by: .milliseconds(100))
// Проверяем, не произошла ли обработка
interactor.verify(.getSuggests(for: .value(testText), count: 0)
// Сдвигаем виртуальное время ещё на 100 миллисекунд
scheduler.advance(by: .milliseconds(100))
// Снова проверяем, не произошла ли обработка
interactor.verify(.getSuggests(for: .value(testText), count: 0)
// Сдвигаем виртуальное время ещё на 100 миллисекунд
// Общий сдвиг — 300 миллисекунд
scheduler.advance(by: .milliseconds(100))
// then
// По истечении 300 секунд обработка должна произойти
interactor.verify(.getSuggests(for: .value(testText)), count: 1)
}
Итог: тест выполняется практически мгновенно, а не за 300 секунд.
Как мы решили проблему с асинхронным кодом из-за замыканий
Вторая сложность связана с замыканиями (closures). Проблема всё та же: из-за асинхронного кода необходимо использовать expectation. Есть два варианта, как немного упростить себе жизнь: использовать метод sync или сделать обёртку над expectation.
Первый подход — вызывать метод sync на background queue. Он заблокирует вызываемый поток до тех пор, пока все задачи на background queue не завершатся. Это позволит достигнуть линейности выполнения. Чтобы воспользоваться этим подходом, понадобится протокол BackgroundTestable:
public protocol BackgroundTestable {
var backgroundQueue: DispatchQueue { get }
}
public extension RealmBackgroundTestable {
func waitForBackgroundOperations() {
// Ждём выполнения всех операций на background потоке
backgroundQueue.sync {}
}
}
BackgroundTestable реализует нужный тест-кейс, который встраивает свойство backgroundQueue в тестируемый объект. Вот как в итоге выглядит тест:
func test_create0rUpdateOrder_create() {
// given опущен
// when
// Вызываем тестируемую функцию
sut.invokeMethod(object, completion: nil)
// После выполнения главный поток заблокируется до тех пор,
// пока все задачи на background-потоке не завершатся
// Затем управление вернётся главному потоку и выполнение теста продолжится
waitForBackgroundOperations()
// then опущен
}
Минус решения: если completion block вызывается не на background-потоке, тест продолжится до выполнения блока. Так случится, потому что главный поток освободится раньше, чем сработает completion block.
Это будет выглядеть так:
Задачи на background-потоке завершатся.
Главный поток разблокируется.
Выполнение теста продолжится.
Completion асинхронно вызовется на главном потоке на следующей итерации runloop.
Другое решение — обёртка над expectation. Полностью уйти от expectation всё равно не получится, но обёртка минимизирует шаблонный код для его создания. Вот как сделать обёртку над expectation:
public extension XCTestCase {
// name — название функции, из которой происходит вызов
// timeout — время, чтобы тестируемая функция могла завершиться
// action — замыкание, с помощью которого вызывается тестируемый код
func wattAsyncActton(
_ name: String = #function,
timeout: TimeInterval = 1,
action: (@escaping () -> Void
) throws -> Void) rethrows {
let expectation = self.expectation(description: name)
try action(expectation.fulfill)
waitForExpectations(timeout: timeout)
}
}
Плюсы решения:
код лаконичный;
код быстро пишется, потому что XCode помогает автонаписанием;
не нужно постоянно писать дублирующийся код для создания expectation вручную.
Полезные материалы
Книга Владимира Хорикова «Принципы юнит-тестирования», 2021
Книга Gerard Meszaros “xUnit Test Patterns: Refactoring Test Code”, 2007
GitHub-репозиторий с демонстрацией подходов юнит-тестирования из этой статьи
Мы завели соцсети с новостями и анонсами Tech-команды. Если хотите узнать, что под капотом высоконагруженного e-commerce, следите за нами там, где вам удобнее всего: Telegram, VK.