Тестирование многопоточного кода в
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(priority: TaskPriority?, operation: @escaping () async -> T) -> Task
}
struct DefaultTaskProvider: TaskProvider {
func task(priority: TaskPriority?, operation: @escaping () async -> T) -> Task {
Task(priority: priority, operation: operation)
}
}
// Мок для тестов
class MockTaskProvider: TaskProvider {
private var tasks: [Task] = []
func task(priority: TaskPriority?, operation: @escaping () async -> T) -> Task {
let task = Task(priority: priority) {
await operation()
}
tasks.append(task as! Task)
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.