Тестирование многопоточного кода в Swift всегда было не без проблем. С появлением Swift Concurrency ситуация стала лучше, но не идеальной. Самые проблемные - плавающие тесты, которые то проходят, то падают без видимых причин. Корень проблемы в недетерминированном выполнении асинхронных операций, особенно при использовании Task {}. Сейчас разберем, как заставить такие тесты работать предсказуемо.
Почему обычные тесты с Task не работают:
Когда мы создаем Task внутри синхронного метода, тест не ждет его завершения:
// Плавающий тест
func testAsyncOperation() {
let service = DataService()
service.startProcessing() // Внутри создается Task
XCTAssertTrue(service.isCompleted) // Может упасть
}
Тест завершится раньше, чем Task выполнится, потому что Task {} запускает асинхронную операцию, которая живет своей жизнью.
Решение - абстракция над Task:
Ключевая идея - создать протокол TaskProvider, который инкапсулирует создание задач:
protocol TaskProvider {
func task<T>(priority: TaskPriority?, operation: @escaping () async -> T) -> Task<T, Never>
}
struct DefaultTaskProvider: TaskProvider {
func task<T>(priority: TaskPriority?, operation: @escaping () async -> T) -> Task<T, Never> {
Task(priority: priority, operation: operation)
}
}
// Мок для тестов
class MockTaskProvider: TaskProvider {
private var tasks: [Task<Void, Never>] = []
func task<T>(priority: TaskPriority?, operation: @escaping () async -> T) -> Task<T, Never> {
let task = Task(priority: priority) {
await operation()
}
tasks.append(task as! Task<Void, Never>)
return task
}
// Ждем завершения всех созданных задач
func waitForAllTasks() async {
for task in tasks {
await task.value
}
}
}Использование в коде:
Меняем зависимость в нашем сервисе:
class DataService {
private let taskProvider: TaskProvider
init(taskProvider: TaskProvider = DefaultTaskProvider()) {
self.taskProvider = taskProvider
}
func startProcessing() {
taskProvider.task(priority: .medium) {
// Долгая асинхронная операция
await self.processData()
}
}
}Стабильный тест:
Теперь тест может дождаться выполнения всех задач:
func testAsyncOperation() async {
let mockProvider = MockTaskProvider()
let service = DataService(taskProvider: mockProvider)
service.startProcessing()
await mockProvider.waitForAllTasks() // Ждем завершения
XCTAssertTrue(service.isCompleted) // Стабильно проходит
}Преимущества подхода:
🔵Контроль приоритетов: можно проверять, что задачи создаются с правильными приоритетами.
🔵Отслеживание создания: можно вести лог созданных задач для assertions.
🔵Изоляция тестов: каждый тест работает с изолированным моком.
🔵Поддержка detached задач: паттерн легко расширяется для Task.detached.
Важное замечание:
Этот подход не заменяет async/await тесты, а дополняет их. Для кода, который уже использует async функции, лучше тестировать через await. Но для legacy-кода или ситуаций, где Task создается внутри синхронных методов, этот паттерн незаменим.
🔗 Ссылка на подробную статью
💡 Вывод:
Тестирование многопоточного кода требует особого подхода и абстракция TaskProvider предоставляет его. Она превращает плавающие тесты в детерминированные, давая полный контроль над выполнением асинхронных операций. Через инверсию зависимостей сохраняется чистота архитектуры, а случайные падения в CI/CD становятся историей.
Особенно ценен этот паттерн при работе с legacy-кодом, где синхронные и асинхронные вызовы соседствуют в процессе миграции на Swift Concurrency.
➡️ Подписаться на канал
Мобильный трудоголик